Skripty konektoru: vykonna cast s manifestem a kontrolou parametru
Konektory dostaly vykonnou cast. Jeden skript je jeden soubor, ktery nese manifest (vstupni a vystupni parametry) i kod. Diky manifestu s nim umi pracovat strom automatizace, aniz by o kodu cokoliv vedel. Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se musel prelozit a to je presne to otaceni, ktere tady nema byt. Registr sleduje cas zmeny souboru, takze uprava v portalu, rucni uprava souboru i novy soubor ve slozce funguji stejne a bez restartu. Pridano: - scripts/ se skripty konektoru, nazev souboru je zaroven ID operace - kontrola vstupu i vystupu proti manifestu, jedna funkce pro obe strany. Chybejici povinny vystup je chyba skriptu, ne uzivatele - jinak by strom veril parametru, ktery nikdy nedosel - ctx predavany skriptu: http nad adresou napojeni, util, log, config, idempotencyKey, fail a retry. Skript nedostane pristupove udaje - rozliseni opakovatelne a koncove chyby. Runner nikdy nevyhodi vyjimku, vzdy vraci vysledek vcetne retryable - redakce tajnych hodnot pred zapisem do logu. Cizi API rado vraci prijaty token v chybove zprave a log ticketu vidi klient - napojeni z environment variables vcetne iDokladu - sest ukazkovych skriptu pro iDoklad proti skutecnemu API sluzby services.csbot.cz/apps/idoklad, kazdy na jiny vzor - stranka /dashboard/skripty: seznam, manifest, editor, zkusebni spusteni. Formular testu se sklada z manifestu, nepise se pro kazdy skript - endpointy /api/dashboard/scripts vcetne Swaggeru Zmeneno: - katalog konektoru uz neni jen staticky seznam. Akce ze skriptu se domeruji prekryvem v src/data/connectors.ts, takze se naraz objevi ve validaci stromu, ve vypoctu scope i v sablonach. Pri stejnem ID vyhrava skript - ConnectorOperation ma implementation a scriptId - ApiError na klientovi nese cele telo odpovedi a umi z nej vytahnout issues - Dockerfile kopiruje scripts/ do vysledneho image Ukladani nemuze rozbit fungujici skript: kod se nejdriv zapise do docasneho souboru, ten se nacte a overi, a az pak prepise puvodni. K tomu tri dokumenty navrhu dalsich kroku: 09 datove modely a prava, 10 runtime a rozpocet na 150 klientu, 11 popis skriptu konektoru. Overeno: npm run typecheck prochazi na serveru i webu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bbc2236c0d
commit
6f6b287d7e
@@ -8,9 +8,15 @@
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
function positiveNumber(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tajny klic pro podpis tokenu.
|
||||
*
|
||||
@@ -68,6 +74,31 @@ export const config = {
|
||||
* relativni tvar - nikdy se nehardcoduje produkcni domena.
|
||||
*/
|
||||
publicOrigin: (process.env.PUBLIC_ORIGIN ?? '').trim().replace(/\/+$/, ''),
|
||||
|
||||
// ------------------------------------------------------- skripty konektoru
|
||||
|
||||
/**
|
||||
* Adresar se skripty konektoru. Relativne k adresari, ze ktereho aplikace
|
||||
* bezi, aby to fungovalo v containeru (`/app/scripts`) i lokalne.
|
||||
*/
|
||||
scriptsDir: path.resolve(process.env.SCRIPTS_DIR ?? path.join(process.cwd(), 'scripts')),
|
||||
/**
|
||||
* Zaklad adres napojenych sluzeb, napr. "https://services.csbot.cz/apps".
|
||||
* Konkretni konektor lze presmerovat pres `<KONEKTOR>_BASE_URL`.
|
||||
* Nikdy se nehardcoduje do logiky, viz AGENTS.md.
|
||||
*/
|
||||
servicesBaseUrl: (process.env.SERVICES_BASE_URL ?? 'https://services.csbot.cz/apps')
|
||||
.trim()
|
||||
.replace(/\/+$/, ''),
|
||||
/** Strop na jeden beh skriptu, kdyz si ho manifest neurci sam. */
|
||||
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),
|
||||
/**
|
||||
* Povoli skriptum volat na localhost a do privatnich rozsahu IP.
|
||||
* Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute.
|
||||
*/
|
||||
allowPrivateTargets: process.env.ALLOW_PRIVATE_TARGETS === 'true',
|
||||
};
|
||||
|
||||
/** Zaklad verejne adresy aplikace vcetne prefixu proxy. */
|
||||
|
||||
+60
-1
@@ -89,6 +89,14 @@ export interface ConnectorOperation {
|
||||
* Diky nim jde stavet podminky nad daty, ktera si nikdo nevymyslel.
|
||||
*/
|
||||
providedFields?: ProvidedField[];
|
||||
/**
|
||||
* `script` = operaci obsluhuje skript ze slozky skriptu, tedy se opravdu
|
||||
* vykona. Kdyz chybi, je to zatim jen zapis v katalogu.
|
||||
* Doplnuje `src/scripts/catalog.ts`, rucne se to nepise.
|
||||
*/
|
||||
implementation?: 'script';
|
||||
/** Ktery skript operaci obsluhuje. Vyplnene spolu s `implementation`. */
|
||||
scriptId?: string;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
@@ -1097,6 +1105,57 @@ export function findConnector(connectorId: string): Connector | undefined {
|
||||
return connectors.find((c) => c.id === connectorId);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- akce ze skriptu
|
||||
|
||||
/**
|
||||
* Akce domerene ze skriptu konektoru. Plni to `src/scripts/registry.ts`
|
||||
* pri kazdem nacteni skriptu.
|
||||
*
|
||||
* Je to prekryv, ne zapis do `connectors`. Dva duvody: staticky katalog
|
||||
* zustane citelny a z operace jde poznat, odkud je (`implementation`).
|
||||
*
|
||||
* Prekryv je zamerne tady, ne ve zvlastnim modulu. Vsechno ostatni v aplikaci
|
||||
* uz se pta pres `findOperation`, takze tim se skripty naraz objevi ve validaci
|
||||
* stromu, ve vypoctu scope i v sablonach - bez toho, aby se to psalo trikrat.
|
||||
*/
|
||||
const scriptActions = new Map<string, ConnectorOperation[]>();
|
||||
|
||||
/** Nahradi cely prekryv. Volani je idempotentni, poradi nezalezi. */
|
||||
export function setScriptActions(byConnector: Map<string, ConnectorOperation[]>): void {
|
||||
scriptActions.clear();
|
||||
for (const [connectorId, operations] of byConnector) {
|
||||
scriptActions.set(connectorId, operations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Akce konektoru vcetne tech ze skriptu.
|
||||
* Kdyz skript nese ID operace, ktera uz v katalogu je, **skript vyhrava**.
|
||||
* Staticky zapis je popis toho, co umime, skript je to, co se opravdu stane.
|
||||
*/
|
||||
export function actionsFor(connectorId: string): ConnectorOperation[] {
|
||||
const connector = findConnector(connectorId);
|
||||
if (!connector) return [];
|
||||
|
||||
const fromScripts = scriptActions.get(connectorId);
|
||||
if (!fromScripts || fromScripts.length === 0) return connector.actions;
|
||||
|
||||
const replaced = new Set(fromScripts.map((operation) => operation.id));
|
||||
return [
|
||||
...connector.actions.filter((action) => !replaced.has(action.id)),
|
||||
...fromScripts,
|
||||
].sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
}
|
||||
|
||||
/** Katalog pro portal. Nemodifikuje `connectors`, sklada nove objekty. */
|
||||
export function connectorCatalog(): Connector[] {
|
||||
return connectors.map((connector) =>
|
||||
scriptActions.has(connector.id)
|
||||
? { ...connector, actions: actionsFor(connector.id) }
|
||||
: connector,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi, ze konektor existuje a ma danou operaci pozadovaneho druhu.
|
||||
* Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky.
|
||||
@@ -1108,7 +1167,7 @@ export function findOperation(
|
||||
): ConnectorOperation | undefined {
|
||||
const connector = findConnector(connectorId);
|
||||
if (!connector) return undefined;
|
||||
const pool = type === 'trigger' ? connector.triggers : connector.actions;
|
||||
const pool = type === 'trigger' ? connector.triggers : actionsFor(connectorId);
|
||||
return pool.find((op) => op.id === operationId);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { contactRouter } from './routes/contact.js';
|
||||
import { dashboardRouter } from './routes/dashboard.js';
|
||||
import { simulateRouter } from './routes/simulate.js';
|
||||
import { webhookRouter } from './routes/webhook.js';
|
||||
import { ensureLoaded, scriptsDir } from './scripts/registry.js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */
|
||||
@@ -168,11 +169,19 @@ app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Skripty konektoru se nactou jeste pred prijimanim provozu, protoze doplnuji
|
||||
* katalog a bez nich by prvni ulozeni stromu neznalo operace ze skriptu.
|
||||
* `ensureLoaded` chyby polyka a loguje, takze start nemuze shodit (AGENTS.md).
|
||||
*/
|
||||
await ensureLoaded(true);
|
||||
|
||||
// Poslouchat na vsech rozhranich containeru, ne jen na localhost (AGENTS.md).
|
||||
const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
console.info(`[start] csbot-prototype bezi na portu ${config.port}`);
|
||||
console.info(`[start] ROOT_PATH: ${config.rootPath || '(neni nastaven)'}`);
|
||||
console.info(`[start] health: ${config.rootPath}/health, docs: ${config.rootPath}/docs`);
|
||||
console.info(`[start] skripty konektoru: ${scriptsDir()}`);
|
||||
});
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
|
||||
+306
@@ -26,6 +26,7 @@ export function buildOpenApiDocument() {
|
||||
{ name: 'Dashboard', description: 'Data klientskeho portalu' },
|
||||
{ name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' },
|
||||
{ name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' },
|
||||
{ name: 'Skripty', description: 'Vykonna cast konektoru: manifest, kod a zkusebni beh' },
|
||||
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
|
||||
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
|
||||
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
|
||||
@@ -98,6 +99,145 @@ export function buildOpenApiDocument() {
|
||||
personId: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
ScriptField: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Parametr skriptu. Stejny tvar pro vstup i vystup - kontrola je pak ' +
|
||||
'jedna funkce, ne dve skoro stejne.',
|
||||
required: ['id', 'label', 'type', 'required'],
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
example: 'invoiceId',
|
||||
description: 'Pouziva se v sablonach jako {{invoiceId}}.',
|
||||
},
|
||||
label: { type: 'string', example: 'ID faktury v iDokladu' },
|
||||
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
|
||||
required: { type: 'boolean' },
|
||||
hint: { type: 'string' },
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Vyber z hodnot. Jina hodnota neprojde kontrolou.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { value: { type: 'string' }, label: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
pattern: { type: 'string', description: 'Jen u typu string.' },
|
||||
multiline: { type: 'boolean', description: 'Jen u typu string.' },
|
||||
default: { description: 'Dosadi se, kdyz hodnota chybi a parametr neni povinny.' },
|
||||
},
|
||||
},
|
||||
ScriptManifest: {
|
||||
type: 'object',
|
||||
description: 'Co skript umi. Podle nej s nim umi pracovat strom automatizace.',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
example: 'idoklad.get-issued-invoice',
|
||||
description: 'Tvar konektor.operace. Nazev souboru musi byt <id>.js.',
|
||||
},
|
||||
connectorId: { type: 'string', example: 'idoklad' },
|
||||
operationId: { type: 'string', example: 'get-issued-invoice' },
|
||||
name: { type: 'string', example: 'Získat vydanou fakturu' },
|
||||
description: { type: 'string' },
|
||||
inputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
|
||||
outputs: { type: 'array', items: { $ref: '#/components/schemas/ScriptField' } },
|
||||
timeoutMs: { type: 'integer', example: 15000 },
|
||||
},
|
||||
},
|
||||
ScriptProblem: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Rozbity skript. Nesmi shodit ostatni ani tise zmizet, proto se vraci sem.',
|
||||
properties: {
|
||||
file: { type: 'string', example: 'idoklad.get-issued-invoice.js' },
|
||||
scriptId: { type: 'string', nullable: true },
|
||||
message: { type: 'string' },
|
||||
issues: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { field: { type: 'string' }, message: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ConnectionStatus: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Stav napojeni konektoru. Hodnoty pristupovych udaju se nevraci nikdy, ' +
|
||||
'jen jmena promennych, ktere chybi.',
|
||||
properties: {
|
||||
connectorId: { type: 'string', example: 'idoklad' },
|
||||
baseUrl: { type: 'string', example: 'https://services.csbot.cz/apps/idoklad' },
|
||||
ready: { type: 'boolean' },
|
||||
missing: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['IDOKLAD_CLIENT_SECRET'],
|
||||
},
|
||||
headers: { type: 'array', items: { type: 'string' }, example: ['X-ClientId'] },
|
||||
},
|
||||
},
|
||||
ScriptRunResult: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Vysledek behu skriptu. `retryable` rika, jestli ma smysl zkusit to znovu - ' +
|
||||
'timeout ano, spatny vstup ne.',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
scriptId: { type: 'string' },
|
||||
outputs: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description: 'Prazdne, kdyz beh selhal.',
|
||||
},
|
||||
logs: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
at: { type: 'string', format: 'date-time' },
|
||||
message: { type: 'string' },
|
||||
detail: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
durationMs: { type: 'integer' },
|
||||
httpCalls: { type: 'integer' },
|
||||
error: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'not_found',
|
||||
'config',
|
||||
'validation',
|
||||
'output',
|
||||
'retryable',
|
||||
'terminal',
|
||||
'timeout',
|
||||
'internal',
|
||||
],
|
||||
},
|
||||
message: { type: 'string' },
|
||||
retryable: { type: 'boolean' },
|
||||
status: { type: 'integer' },
|
||||
detail: { type: 'string' },
|
||||
issues: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { field: { type: 'string' }, message: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
LoginRequest: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
@@ -811,10 +951,176 @@ export function buildOpenApiDocument() {
|
||||
get: {
|
||||
tags: ['Automatizace'],
|
||||
summary: 'Katalog konektoru',
|
||||
description:
|
||||
'Operace, ktere obsluhuje skript, nesou `implementation: script` a `scriptId`, ' +
|
||||
'a maji skutecne `inputs` a `outputFields` z manifestu toho skriptu.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Konektory, kategorie a operatory podminek' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/scripts': {
|
||||
get: {
|
||||
tags: ['Skripty'],
|
||||
summary: 'Seznam skriptu konektoru',
|
||||
description:
|
||||
'Manifesty vsech nactenych skriptu, rozbite skripty v `problems` ' +
|
||||
'a stav napojeni v `connections`. Pristupove udaje se nikdy nevraci, ' +
|
||||
'jen jmena chybejicich environment variables.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Skripty, problemy a stav napojeni',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ScriptManifest' },
|
||||
},
|
||||
problems: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ScriptProblem' },
|
||||
},
|
||||
connections: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ConnectionStatus' },
|
||||
},
|
||||
directory: { type: 'string', example: '/app/scripts' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/scripts/reload': {
|
||||
post: {
|
||||
tags: ['Skripty'],
|
||||
summary: 'Znovu nacist skripty ze slozky',
|
||||
description:
|
||||
'Skripty se nacitaji samy podle casu zmeny souboru. Tenhle endpoint ' +
|
||||
'to jen vynuti hned, bez cekani.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
'200': { description: 'Skripty po nacteni' },
|
||||
'403': { description: 'Jen spravce platformy' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/scripts/{id}': {
|
||||
get: {
|
||||
tags: ['Skripty'],
|
||||
summary: 'Manifest a kod skriptu',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
example: 'idoklad.get-issued-invoice',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Kod se vraci vzdy. `manifest` je null, kdyz je skript rozbity.',
|
||||
},
|
||||
'400': { description: 'Neplatne ID skriptu' },
|
||||
'404': { description: 'Skript neexistuje' },
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ['Skripty'],
|
||||
summary: 'Ulozit kod skriptu',
|
||||
description:
|
||||
'Nejdriv se kod nacte a overi, az pak prepise soubor. Rozbita uprava ' +
|
||||
'se neulozi a puvodni skript dal funguje.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
example: 'idoklad.get-issued-invoice',
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description: 'Cely obsah souboru vcetne exportu manifest a run.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': { description: 'Ulozeno, vraci se overeny manifest' },
|
||||
'400': {
|
||||
description: 'Kod nebo manifest neprosel, v `issues` je co opravit',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
'403': { description: 'Jen spravce platformy' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/scripts/{id}/test': {
|
||||
post: {
|
||||
tags: ['Skripty'],
|
||||
summary: 'Zkusebni spusteni skriptu',
|
||||
description:
|
||||
'POZOR: vola opravdovou sluzbu. Vystavena faktura opravdu vznikne. ' +
|
||||
'Chyba skriptu neni chyba API, vraci se 200 s popisem v `error`.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
example: 'idoklad.get-issued-invoice',
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
inputs: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
example: { invoiceId: 12345 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Vysledek behu',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/ScriptRunResult' } },
|
||||
},
|
||||
},
|
||||
'400': { description: 'Neplatne ID nebo vstupy' },
|
||||
'403': { description: 'Jen spravce platformy' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/automations': {
|
||||
get: {
|
||||
tags: ['Automatizace'],
|
||||
|
||||
+11
-2
@@ -18,8 +18,8 @@ import {
|
||||
} from '../data/automationStore.js';
|
||||
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
|
||||
import {
|
||||
connectorCatalog,
|
||||
connectorCategories,
|
||||
connectors,
|
||||
findOperation,
|
||||
providedFieldsFor,
|
||||
} from '../data/connectors.js';
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
type TicketStatus,
|
||||
} from '../data/ticketStore.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { scriptsRouter } from './scripts.js';
|
||||
import { streamRouter } from './stream.js';
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
@@ -341,12 +342,20 @@ dashboardRouter.post('/tickets/:id/comment', (req, res) => {
|
||||
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
|
||||
dashboardRouter.use('/stream', streamRouter);
|
||||
|
||||
// Skripty konektoru. Taky pred obecnymi cestami.
|
||||
dashboardRouter.use('/scripts', scriptsRouter);
|
||||
|
||||
// ---------------------------------------------------------------- konektory
|
||||
|
||||
/**
|
||||
* Katalog uz neni jen staticky seznam. Operace, ktere obsluhuje skript, se
|
||||
* domeruji z jeho manifestu, takze builder vidi skutecne vstupy a vystupy.
|
||||
* Podrobnosti v `src/scripts/catalog.ts`.
|
||||
*/
|
||||
dashboardRouter.get('/connectors', (_req, res) => {
|
||||
res.json({
|
||||
categories: connectorCategories,
|
||||
items: connectors,
|
||||
items: connectorCatalog(),
|
||||
// Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu,
|
||||
// a jakou zakladni adresu ukazat u webhooku.
|
||||
operatorsByType,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Sprava skriptu konektoru z portalu.
|
||||
*
|
||||
* Cteni smi kazdy prihlaseny - builder potrebuje vedet, co skript umi.
|
||||
* Uprava a spusteni smi jen spravce platformy. Uprava skriptu meni chovani
|
||||
* vseho, co ho pouziva, takze to neni pravo, ktere se dava vedle prava
|
||||
* zakladat tickety (viz documentation/09-navrh-rozsireni.md, bod 9).
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { requirePlatformAdmin } from '../middleware/auth.js';
|
||||
import { connectionStatus, connectorsWithAuth } from '../scripts/connections.js';
|
||||
import { connectorIdOf, operationIdOf } from '../scripts/manifest.js';
|
||||
import {
|
||||
ensureLoaded,
|
||||
getScript,
|
||||
isValidScriptId,
|
||||
listManifests,
|
||||
readSource,
|
||||
saveSource,
|
||||
scriptProblems,
|
||||
scriptsDir,
|
||||
} from '../scripts/registry.js';
|
||||
import { runScript } from '../scripts/runner.js';
|
||||
|
||||
export const scriptsRouter = Router();
|
||||
|
||||
/** Manifest plus to, co si klient nema dopocitavat sam. */
|
||||
async function scriptSummaries() {
|
||||
const manifests = await listManifests();
|
||||
return manifests.map((manifest) => ({
|
||||
...manifest,
|
||||
connectorId: connectorIdOf(manifest.id),
|
||||
operationId: operationIdOf(manifest.id),
|
||||
}));
|
||||
}
|
||||
|
||||
scriptsRouter.get('/', async (_req, res) => {
|
||||
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
|
||||
|
||||
res.json({
|
||||
items,
|
||||
problems,
|
||||
connections: connectorsWithAuth().map(connectionStatus),
|
||||
/** Kam se soubory ukladaji. Kdo ma na server pristup, upravi je i rucne. */
|
||||
directory: scriptsDir(),
|
||||
});
|
||||
});
|
||||
|
||||
scriptsRouter.post('/reload', requirePlatformAdmin, async (_req, res) => {
|
||||
await ensureLoaded(true);
|
||||
const [items, problems] = await Promise.all([scriptSummaries(), scriptProblems()]);
|
||||
res.json({ items, problems });
|
||||
});
|
||||
|
||||
scriptsRouter.get('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!isValidScriptId(id)) {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' });
|
||||
}
|
||||
|
||||
const [script, source] = await Promise.all([getScript(id), readSource(id)]);
|
||||
if (source === null) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Skript neexistuje.' });
|
||||
}
|
||||
|
||||
// Manifest muze chybet, kdyz je soubor rozbity. Kod se vrati vzdy, aby slo opravit.
|
||||
const problems = await scriptProblems();
|
||||
return res.json({
|
||||
id,
|
||||
connectorId: connectorIdOf(id),
|
||||
operationId: operationIdOf(id),
|
||||
manifest: script?.manifest ?? null,
|
||||
code: source,
|
||||
problem: problems.find((problem) => problem.scriptId === id) ?? null,
|
||||
connection: connectionStatus(connectorIdOf(id)),
|
||||
});
|
||||
});
|
||||
|
||||
const saveSchema = z.object({
|
||||
code: z.string().min(1, 'Kód skriptu nesmí být prázdný.'),
|
||||
});
|
||||
|
||||
scriptsRouter.put('/:id', requirePlatformAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!isValidScriptId(id)) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.',
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = saveSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await saveSource(id, parsed.data.code);
|
||||
if (!result.ok) {
|
||||
// Rozbita uprava se neulozi a puvodni skript dal funguje.
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: result.message,
|
||||
issues: result.issues ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ id, manifest: result.manifest });
|
||||
});
|
||||
|
||||
const testSchema = z.object({
|
||||
inputs: z.record(z.unknown()).default({}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zkusebni spusteni.
|
||||
*
|
||||
* Vola opravdovou sluzbu, tedy vystavena faktura opravdu vznikne. Zamerne:
|
||||
* test, ktery volani predstira, nerekne nic o tom, jestli skript funguje.
|
||||
* Portal na to upozorni pred stiskem.
|
||||
*/
|
||||
scriptsRouter.post('/:id/test', requirePlatformAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (!isValidScriptId(id)) {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Neplatné ID skriptu.' });
|
||||
}
|
||||
|
||||
const parsed = testSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Vstupy musí být objekt s hodnotami parametrů.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runScript(id, parsed.data.inputs);
|
||||
console.info(`[scripts] test ${id} uzivatelem ${req.user!.email}: ${result.ok ? 'ok' : 'chyba'}`);
|
||||
|
||||
// Chyba skriptu neni chyba API. Vysledek se vraci vzdy s 200 vcetne popisu.
|
||||
return res.json(result);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Napojeni konektoru: kam se vola a cim se to autorizuje.
|
||||
*
|
||||
* Zamerne oddelene od skriptu. Skript rika "GET /issued-invoices/12",
|
||||
* napojeni rika, na jake adrese to je a jaké hlavicky se pridaji. Skript se
|
||||
* tim k pristupovym udajum vubec nedostane.
|
||||
*
|
||||
* Tady je zatim jedno napojeni na konektor, sestavene z environment variables.
|
||||
* Cilovy stav je napojeni za firmu v databazi, viz documentation/09, bod 9.
|
||||
* Az to bude, prepise se vnitrek `resolveConnection` a nic dalsiho.
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
|
||||
export interface ConnectorAuthSpec {
|
||||
/** Hlavicka -> jmeno environment variable, ze ktere se plni. */
|
||||
headers: Record<string, string>;
|
||||
/** Ktere hlavicky musi byt vyplnene, aby se dalo volat. */
|
||||
required: string[];
|
||||
/** Necitliva nastaveni pristupna skriptu jako `ctx.config`. */
|
||||
config?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autorizace jednotlivych konektoru.
|
||||
*
|
||||
* iDoklad podle https://services.csbot.cz/apps/idoklad/docs: sluzba prijima
|
||||
* `X-ClientId` a `X-ClientSecret`, `X-ApplicationId` jen partnerske aplikace.
|
||||
* OAuth tok resi ta sluzba, my posilame jen tyto hlavicky.
|
||||
*/
|
||||
const authSpecs: Record<string, ConnectorAuthSpec> = {
|
||||
idoklad: {
|
||||
headers: {
|
||||
'X-ClientId': 'IDOKLAD_CLIENT_ID',
|
||||
'X-ClientSecret': 'IDOKLAD_CLIENT_SECRET',
|
||||
'X-ApplicationId': 'IDOKLAD_APPLICATION_ID',
|
||||
},
|
||||
required: ['X-ClientId', 'X-ClientSecret'],
|
||||
config: { language: 'IDOKLAD_LANGUAGE' },
|
||||
},
|
||||
};
|
||||
|
||||
export interface ResolvedConnection {
|
||||
connectorId: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
/** Vcetne tajemstvi. Nikdy neposilat na klienta ani do logu. */
|
||||
headers: Record<string, string>;
|
||||
/** Necitliva cast, skript ji vidi jako `ctx.config`. */
|
||||
config: Record<string, string>;
|
||||
/** false = chybi pristupove udaje, volat nema smysl. */
|
||||
ready: boolean;
|
||||
/** Jmena environment variables, ktere chybi. */
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
/** `search-console` -> `SEARCH_CONSOLE`, aby slo skladat jmena promennych. */
|
||||
function envPrefix(connectorId: string): string {
|
||||
return connectorId.replace(/-/g, '_').toUpperCase();
|
||||
}
|
||||
|
||||
function readEnv(name: string): string | undefined {
|
||||
const value = process.env[name];
|
||||
return value !== undefined && value.trim() !== '' ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vychozi adresa sluzby. Verejna domena se nikdy nehardcoduje do logiky,
|
||||
* bere se z `SERVICES_BASE_URL` (viz AGENTS.md).
|
||||
* Jednotlive konektory lze presmerovat pres `<KONEKTOR>_BASE_URL`.
|
||||
*/
|
||||
function resolveBaseUrl(connectorId: string): string {
|
||||
return (
|
||||
readEnv(`${envPrefix(connectorId)}_BASE_URL`) ?? `${config.servicesBaseUrl}/${connectorId}`
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveConnection(connectorId: string): ResolvedConnection {
|
||||
const spec = authSpecs[connectorId];
|
||||
const headers: Record<string, string> = {};
|
||||
const scriptConfig: Record<string, string> = {};
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const [header, envName] of Object.entries(spec?.headers ?? {})) {
|
||||
const value = readEnv(envName);
|
||||
if (value !== undefined) headers[header] = value;
|
||||
else if (spec?.required.includes(header)) missing.push(envName);
|
||||
}
|
||||
|
||||
for (const [key, envName] of Object.entries(spec?.config ?? {})) {
|
||||
const value = readEnv(envName);
|
||||
if (value !== undefined) scriptConfig[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
connectorId,
|
||||
name: `${connectorId} (z environment variables)`,
|
||||
baseUrl: resolveBaseUrl(connectorId),
|
||||
headers,
|
||||
config: scriptConfig,
|
||||
ready: missing.length === 0,
|
||||
missing,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hodnoty, ktere se musi zredigovat, nez cokoliv skonci v logu. */
|
||||
export function connectionSecrets(connection: ResolvedConnection): string[] {
|
||||
return Object.values(connection.headers);
|
||||
}
|
||||
|
||||
export interface ConnectionStatus {
|
||||
connectorId: string;
|
||||
baseUrl: string;
|
||||
ready: boolean;
|
||||
/** Jen jmena chybejicich promennych, nikdy hodnoty. */
|
||||
missing: string[];
|
||||
/** Ktere hlavicky jsou vyplnene. Hodnoty se nevraci. */
|
||||
headers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stav napojeni pro portal. Vraci se **jen jmena**, nikdy hodnoty -
|
||||
* secrets se z beznych endpointu nevraci (AGENTS.md).
|
||||
*/
|
||||
export function connectionStatus(connectorId: string): ConnectionStatus {
|
||||
const connection = resolveConnection(connectorId);
|
||||
return {
|
||||
connectorId,
|
||||
baseUrl: connection.baseUrl,
|
||||
ready: connection.ready,
|
||||
missing: connection.missing,
|
||||
headers: Object.keys(connection.headers),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ktere konektory maji popsanou autorizaci. */
|
||||
export function connectorsWithAuth(): string[] {
|
||||
return Object.keys(authSpecs);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* HTTP klient, ktery dostane skript jako `ctx.http`.
|
||||
*
|
||||
* Delá ctyri veci, ktere by jinak resil kazdy skript znovu a spatne:
|
||||
* - sklada adresu z napojeni, takze skript zna jen cestu,
|
||||
* - pridava autorizacni hlavicky, takze skript nezna tajemstvi,
|
||||
* - rozlisuje opakovatelnou chybu od koncove,
|
||||
* - loguje volani bez hlavicek a bez tel, jen metodu, cestu a kod.
|
||||
*/
|
||||
|
||||
import { config } from '../config.js';
|
||||
import type { ResolvedConnection } from './connections.js';
|
||||
import { ScriptError, type ScriptHttp, type ScriptHttpOptions, type ScriptHttpResponse } from './types.js';
|
||||
import { describe } from './util.js';
|
||||
|
||||
/** Kody, u kterych ma smysl opakovat. Zbytek je koncova chyba. */
|
||||
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
|
||||
/** Chyby spojeni od Node. Vsechny jsou docasne. */
|
||||
const retryableCodes = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
'EPIPE',
|
||||
'ENOTFOUND',
|
||||
'UND_ERR_SOCKET',
|
||||
'UND_ERR_CONNECT_TIMEOUT',
|
||||
]);
|
||||
|
||||
const privateHostPattern =
|
||||
/^(localhost|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|172\.(1[6-9]|2\d|3[01])\.)/i;
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '');
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresu skladame my z napojeni, ale az budou napojeni nastavovat klienti,
|
||||
* je tohle to jedine, co brani volani na vnitrni sit. Proto tady, ne pozdeji.
|
||||
*/
|
||||
function assertAllowedUrl(url: URL): void {
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new ScriptError('config', `Adresa ${url.protocol} není povolená, jen http a https.`);
|
||||
}
|
||||
if (!config.allowPrivateTargets && privateHostPattern.test(url.hostname)) {
|
||||
throw new ScriptError(
|
||||
'config',
|
||||
`Adresa ${url.hostname} míří do vnitřní sítě. Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(connection: ResolvedConnection, path: string, options?: ScriptHttpOptions): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(joinUrl(connection.baseUrl, path));
|
||||
} catch {
|
||||
throw new ScriptError('config', `Neplatná adresa: ${joinUrl(connection.baseUrl, path)}`);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(options?.query ?? {})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
assertAllowedUrl(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
function statusError(status: number, url: URL, detail: string): ScriptError {
|
||||
const where = `${url.pathname} vrátilo HTTP ${status}`;
|
||||
if (retryableStatuses.has(status)) {
|
||||
return new ScriptError('retryable', `Služba je momentálně nedostupná: ${where}.`, {
|
||||
status,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return new ScriptError('config', `Přístup zamítnut: ${where}. Zkontrolujte přístupové údaje.`, {
|
||||
status,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
if (status === 404) {
|
||||
return new ScriptError('terminal', `Záznam nenalezen: ${where}.`, { status, detail });
|
||||
}
|
||||
return new ScriptError('terminal', `Volání selhalo: ${where}.`, { status, detail });
|
||||
}
|
||||
|
||||
function transportError(err: unknown, url: URL): ScriptError {
|
||||
if (err instanceof ScriptError) return err;
|
||||
|
||||
const code =
|
||||
err !== null && typeof err === 'object' && 'code' in err ? String((err as { code: unknown }).code) : '';
|
||||
const name = err instanceof Error ? err.name : '';
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (name === 'AbortError' || name === 'TimeoutError') {
|
||||
return new ScriptError('timeout', `Volání ${url.pathname} nedoběhlo v limitu.`, { cause: err });
|
||||
}
|
||||
if (retryableCodes.has(code)) {
|
||||
return new ScriptError('retryable', `Nepodařilo se spojit se službou (${code}).`, { cause: err });
|
||||
}
|
||||
return new ScriptError('retryable', `Volání ${url.pathname} selhalo: ${message}`, { cause: err });
|
||||
}
|
||||
|
||||
export interface CreateHttpOptions {
|
||||
connection: ResolvedConnection;
|
||||
signal: AbortSignal;
|
||||
idempotencyKey: string;
|
||||
/** Zredigovana verze textu, aby se tajemstvi nedostalo do logu. */
|
||||
redact: (value: string) => string;
|
||||
log: (message: string, detail?: unknown) => void;
|
||||
onCall: () => void;
|
||||
}
|
||||
|
||||
export function createHttp(options: CreateHttpOptions): ScriptHttp {
|
||||
const { connection, signal, idempotencyKey, redact, log, onCall } = options;
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
httpOptions?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>> {
|
||||
const url = buildUrl(connection, path, httpOptions);
|
||||
const hasBody = body !== undefined && method !== 'GET' && method !== 'DELETE';
|
||||
const startedAt = Date.now();
|
||||
onCall();
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Druhy pokus tehoz kroku nesmi vystavit druhou fakturu.
|
||||
'Idempotency-Key': idempotencyKey,
|
||||
...connection.headers,
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...httpOptions?.headers,
|
||||
},
|
||||
body: hasBody ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
throw transportError(err, url);
|
||||
}
|
||||
|
||||
const declaredSize = Number(response.headers.get('content-length') ?? 0);
|
||||
if (declaredSize > config.scriptMaxResponseBytes) {
|
||||
throw new ScriptError(
|
||||
'terminal',
|
||||
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
if (raw.length > config.scriptMaxResponseBytes) {
|
||||
throw new ScriptError(
|
||||
'terminal',
|
||||
`Odpověď je větší než povolený limit ${config.scriptMaxResponseBytes} bajtů.`,
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('json') ?? false;
|
||||
let parsed: unknown = raw;
|
||||
if (isJson && raw.length > 0) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new ScriptError('terminal', `Odpověď ${url.pathname} není platný JSON.`, {
|
||||
status: response.status,
|
||||
detail: redact(describe(raw, 300)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log(`${method} ${url.pathname} -> ${response.status} (${Date.now() - startedAt} ms)`);
|
||||
|
||||
const allowed = httpOptions?.allowStatus ?? [];
|
||||
if (!response.ok && !allowed.includes(response.status)) {
|
||||
throw statusError(response.status, url, redact(describe(parsed, 400)));
|
||||
}
|
||||
|
||||
return { status: response.status, body: parsed as T };
|
||||
}
|
||||
|
||||
return {
|
||||
get: (path, httpOptions) => request('GET', path, undefined, httpOptions),
|
||||
post: (path, body, httpOptions) => request('POST', path, body, httpOptions),
|
||||
patch: (path, body, httpOptions) => request('PATCH', path, body, httpOptions),
|
||||
put: (path, body, httpOptions) => request('PUT', path, body, httpOptions),
|
||||
del: (path, httpOptions) => request('DELETE', path, undefined, httpOptions),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Prevody kolem manifestu skriptu.
|
||||
*
|
||||
* Skript je zdroj pravdy o svych parametrech. Katalog konektoru z nich jen
|
||||
* odvozuje to, co potrebuje builder. Kdyby se pole psala na dvou mistech,
|
||||
* jedno by se casem rozeslo a strom by nabizel parametr, ktery skript nezna.
|
||||
*/
|
||||
|
||||
import type { ConnectorOperation, ProvidedField } from '../data/connectors.js';
|
||||
import type { OperationField } from '../data/connectors.js';
|
||||
import { scriptManifestSchema, type FieldIssue, type ScriptField, type ScriptManifest } from './types.js';
|
||||
|
||||
/** `idoklad.get-issued-invoice` -> `idoklad` */
|
||||
export function connectorIdOf(scriptId: string): string {
|
||||
return scriptId.slice(0, scriptId.indexOf('.'));
|
||||
}
|
||||
|
||||
/** `idoklad.get-issued-invoice` -> `get-issued-invoice` */
|
||||
export function operationIdOf(scriptId: string): string {
|
||||
return scriptId.slice(scriptId.indexOf('.') + 1);
|
||||
}
|
||||
|
||||
export type ParseResult =
|
||||
| { ok: true; manifest: ScriptManifest }
|
||||
| { ok: false; issues: FieldIssue[] };
|
||||
|
||||
/**
|
||||
* Overi manifest a zaroven to, ze odpovida nazvu souboru.
|
||||
* Nesoulad nazvu je chyba, ne varovani - jinak by se skript ulozil pod jednim
|
||||
* jmenem a nacetl pod druhym.
|
||||
*/
|
||||
export function parseManifest(raw: unknown, expectedId: string): ParseResult {
|
||||
const parsed = scriptManifestSchema.safeParse(raw);
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: parsed.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.') || 'manifest',
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.data.id !== expectedId) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: [
|
||||
{
|
||||
field: 'id',
|
||||
message: `Manifest má id "${parsed.data.id}", ale soubor se jmenuje "${expectedId}.js". Musí být stejné.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, manifest: parsed.data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nastavitelne pole akce pro builder.
|
||||
* `kind` se odvodi z manifestu, aby se nemuselo psat dvakrat.
|
||||
*/
|
||||
function toOperationField(field: ScriptField): OperationField {
|
||||
return {
|
||||
id: field.id,
|
||||
label: field.label,
|
||||
kind: field.options ? 'choice' : field.multiline ? 'longtext' : 'text',
|
||||
required: field.required,
|
||||
...(field.options ? { options: field.options } : {}),
|
||||
...(field.hint ? { hint: field.hint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vystup kroku pro strom.
|
||||
*
|
||||
* `id` nese prefix konektoru, protoze se na nej odkazuji podminky v ulozenych
|
||||
* stromech a musi byt jednoznacne. `name` je to, co se pise do sablony -
|
||||
* stejne rozdeleni jako u ostatnich konektoru, viz documentation/06-tickety.md.
|
||||
*/
|
||||
function toProvidedField(scriptId: string, field: ScriptField): ProvidedField {
|
||||
return {
|
||||
id: `${connectorIdOf(scriptId)}.${field.id}`,
|
||||
name: field.id,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
};
|
||||
}
|
||||
|
||||
/** Operace katalogu odvozena ze skriptu. Tohle vidi builder. */
|
||||
export function toConnectorOperation(manifest: ScriptManifest): ConnectorOperation {
|
||||
return {
|
||||
id: operationIdOf(manifest.id),
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
inputs: manifest.inputs.map(toOperationField),
|
||||
outputFields: manifest.outputs.map((field) => toProvidedField(manifest.id, field)),
|
||||
implementation: 'script',
|
||||
scriptId: manifest.id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Nacitani skriptu ze souboru.
|
||||
*
|
||||
* Myslenka: skript jde vytvorit nebo upravit v rozhrani i rucne v souboru
|
||||
* a **nic se kvuli tomu neotaci**. Registr proto sleduje cas zmeny souboru
|
||||
* a pri zmene ho nacte znovu. Nazev souboru je `<id>.js`, takze mezi souborem
|
||||
* a operaci v katalogu neni zadna mapa, ktera by mohla lhat.
|
||||
*
|
||||
* Soubory jsou zamerne obycejny JavaScript, ne TypeScript. TypeScript by se
|
||||
* musel prelozit, a to je presne to otaceni, ktere tady nema byt.
|
||||
*
|
||||
* Rozbity skript nesmi shodit ostatni. Zapise se do `problems()` a portal ho
|
||||
* ukaze - zadna ticha selhani.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { config } from '../config.js';
|
||||
import { connectors, setScriptActions, type ConnectorOperation } from '../data/connectors.js';
|
||||
import { connectorIdOf, parseManifest, toConnectorOperation } from './manifest.js';
|
||||
import type { FieldIssue, ScriptContext, ScriptManifest, ScriptValues } from './types.js';
|
||||
|
||||
export type ScriptRunFn = (
|
||||
inputs: ScriptValues,
|
||||
ctx: ScriptContext,
|
||||
) => Promise<unknown> | unknown;
|
||||
|
||||
export interface LoadedScript {
|
||||
manifest: ScriptManifest;
|
||||
run: ScriptRunFn;
|
||||
file: string;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface ScriptProblem {
|
||||
/** Nazev souboru, ne cela cesta - cesta na serveru nikomu nic nerekne. */
|
||||
file: string;
|
||||
scriptId: string | null;
|
||||
message: string;
|
||||
issues?: FieldIssue[];
|
||||
}
|
||||
|
||||
/** ID smi byt jen tohle. Chrani zapis i cteni proti vyskoku z adresare. */
|
||||
const idPattern = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/;
|
||||
|
||||
const scripts = new Map<string, LoadedScript>();
|
||||
const problems = new Map<string, ScriptProblem>();
|
||||
|
||||
/** Nez se znovu prohleda adresar. Bez toho by se stat volal pri kazdem dotazu. */
|
||||
const RESCAN_MS = 1000;
|
||||
let lastScanAt = 0;
|
||||
let scanning: Promise<void> | null = null;
|
||||
|
||||
export function scriptsDir(): string {
|
||||
return config.scriptsDir;
|
||||
}
|
||||
|
||||
export function isValidScriptId(id: string): boolean {
|
||||
return idPattern.test(id);
|
||||
}
|
||||
|
||||
function fileFor(id: string): string {
|
||||
if (!isValidScriptId(id)) throw new Error(`Neplatné ID skriptu: ${id}`);
|
||||
return path.join(scriptsDir(), `${id}.js`);
|
||||
}
|
||||
|
||||
function idFor(fileName: string): string {
|
||||
return fileName.replace(/\.js$/, '');
|
||||
}
|
||||
|
||||
function problemMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nacte jeden soubor. Query `?v=` je nutna - bez ni si Node drzi prvni verzi
|
||||
* modulu v cache a uprava souboru by se nikdy neprojevila.
|
||||
*/
|
||||
async function importScript(
|
||||
file: string,
|
||||
mtimeMs: number,
|
||||
expectedId: string,
|
||||
): Promise<LoadedScript | ScriptProblem> {
|
||||
const fileName = path.basename(file);
|
||||
const url = `${pathToFileURL(file).href}?v=${mtimeMs}`;
|
||||
|
||||
let module: { manifest?: unknown; run?: unknown };
|
||||
try {
|
||||
module = (await import(url)) as { manifest?: unknown; run?: unknown };
|
||||
} catch (err) {
|
||||
return { file: fileName, scriptId: expectedId, message: `Soubor se nepodařilo načíst: ${problemMessage(err)}` };
|
||||
}
|
||||
|
||||
if (typeof module.run !== 'function') {
|
||||
return {
|
||||
file: fileName,
|
||||
scriptId: expectedId,
|
||||
message: 'Soubor musí exportovat funkci run(inputs, ctx).',
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseManifest(module.manifest, expectedId);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
file: fileName,
|
||||
scriptId: expectedId,
|
||||
message: 'Manifest není platný.',
|
||||
issues: parsed.issues,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
manifest: parsed.manifest,
|
||||
run: module.run as ScriptRunFn,
|
||||
file: fileName,
|
||||
mtimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function isProblem(value: LoadedScript | ScriptProblem): value is ScriptProblem {
|
||||
return 'message' in value;
|
||||
}
|
||||
|
||||
async function scan(): Promise<void> {
|
||||
const dir = scriptsDir();
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(dir);
|
||||
} catch (err) {
|
||||
// Chybejici adresar neni chyba aplikace, jen nejsou zadne skripty.
|
||||
console.warn(`[scripts] adresar ${dir} nelze precist: ${problemMessage(err)}`);
|
||||
scripts.clear();
|
||||
problems.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Soubory od podtrzitka jsou pomocne, nejsou to skripty.
|
||||
const files = entries.filter((name) => name.endsWith('.js') && !name.startsWith('_'));
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const fileName of files) {
|
||||
const id = idFor(fileName);
|
||||
seen.add(id);
|
||||
const file = path.join(dir, fileName);
|
||||
|
||||
if (!isValidScriptId(id)) {
|
||||
problems.set(id, {
|
||||
file: fileName,
|
||||
scriptId: null,
|
||||
message: 'Název souboru musí mít tvar konektor.operace.js, jen malá písmena a pomlčky.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
mtimeMs = (await fs.stat(file)).mtimeMs;
|
||||
} catch (err) {
|
||||
problems.set(id, { file: fileName, scriptId: id, message: problemMessage(err) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const cached = scripts.get(id);
|
||||
if (cached && cached.mtimeMs === mtimeMs) {
|
||||
problems.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const loaded = await importScript(file, mtimeMs, id);
|
||||
if (isProblem(loaded)) {
|
||||
// Rozbita uprava nesmi zahodit posledni funkcni verzi v pameti.
|
||||
problems.set(id, loaded);
|
||||
console.error(`[scripts] ${fileName}: ${loaded.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
scripts.set(id, loaded);
|
||||
problems.delete(id);
|
||||
console.info(`[scripts] nacten ${id} (${loaded.manifest.name})`);
|
||||
}
|
||||
|
||||
for (const id of [...scripts.keys()]) {
|
||||
if (!seen.has(id)) {
|
||||
scripts.delete(id);
|
||||
console.info(`[scripts] ${id} zmizel z adresare`);
|
||||
}
|
||||
}
|
||||
for (const id of [...problems.keys()]) {
|
||||
if (!seen.has(id)) problems.delete(id);
|
||||
}
|
||||
|
||||
publishToCatalog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prenese nactene skripty do katalogu konektoru.
|
||||
*
|
||||
* Tim se naraz objevi ve validaci stromu, ve vypoctu toho, co je v kterem kroku
|
||||
* videt, i v sablonach - vsechno se uz pta pres `findOperation`.
|
||||
*/
|
||||
function publishToCatalog(): void {
|
||||
const byConnector = new Map<string, ConnectorOperation[]>();
|
||||
|
||||
for (const script of scripts.values()) {
|
||||
const connectorId = connectorIdOf(script.manifest.id);
|
||||
if (!connectors.some((connector) => connector.id === connectorId)) {
|
||||
problems.set(script.manifest.id, {
|
||||
file: script.file,
|
||||
scriptId: script.manifest.id,
|
||||
message: `Konektor ${connectorId} v katalogu neexistuje. Skript se nedá použít ve stromu.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const list = byConnector.get(connectorId) ?? [];
|
||||
list.push(toConnectorOperation(script.manifest));
|
||||
byConnector.set(connectorId, list);
|
||||
}
|
||||
|
||||
setScriptActions(byConnector);
|
||||
}
|
||||
|
||||
/** Prohleda adresar, nejvyse jednou za RESCAN_MS. Soubezne volani se sdili. */
|
||||
export async function ensureLoaded(force = false): Promise<void> {
|
||||
if (!force && Date.now() - lastScanAt < RESCAN_MS) return;
|
||||
if (scanning) return scanning;
|
||||
|
||||
scanning = scan()
|
||||
.catch((err: unknown) => {
|
||||
console.error('[scripts] nacitani selhalo:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
lastScanAt = Date.now();
|
||||
scanning = null;
|
||||
});
|
||||
|
||||
return scanning;
|
||||
}
|
||||
|
||||
export async function listScripts(): Promise<LoadedScript[]> {
|
||||
await ensureLoaded();
|
||||
return [...scripts.values()].sort((a, b) => a.manifest.id.localeCompare(b.manifest.id));
|
||||
}
|
||||
|
||||
export async function listManifests(): Promise<ScriptManifest[]> {
|
||||
return (await listScripts()).map((script) => script.manifest);
|
||||
}
|
||||
|
||||
export async function getScript(id: string): Promise<LoadedScript | undefined> {
|
||||
await ensureLoaded();
|
||||
return scripts.get(id);
|
||||
}
|
||||
|
||||
export async function scriptProblems(): Promise<ScriptProblem[]> {
|
||||
await ensureLoaded();
|
||||
return [...problems.values()].sort((a, b) => a.file.localeCompare(b.file));
|
||||
}
|
||||
|
||||
export async function readSource(id: string): Promise<string | null> {
|
||||
if (!isValidScriptId(id)) return null;
|
||||
try {
|
||||
return await fs.readFile(fileFor(id), 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SaveResult =
|
||||
| { ok: true; manifest: ScriptManifest }
|
||||
| { ok: false; message: string; issues?: FieldIssue[] };
|
||||
|
||||
/**
|
||||
* Ulozi kod skriptu.
|
||||
*
|
||||
* Poradi je zamerne: nejdriv se zapise do docasneho souboru, ten se nacte
|
||||
* a overi, a az pak prepise puvodni. Rozbita uprava tim nikdy neshodi
|
||||
* skript, ktery fungoval.
|
||||
*/
|
||||
export async function saveSource(id: string, code: string): Promise<SaveResult> {
|
||||
if (!isValidScriptId(id)) {
|
||||
return { ok: false, message: 'Neplatné ID skriptu. Povolený tvar je konektor.operace.' };
|
||||
}
|
||||
if (code.trim().length === 0) {
|
||||
return { ok: false, message: 'Kód skriptu nesmí být prázdný.' };
|
||||
}
|
||||
|
||||
const target = fileFor(id);
|
||||
// Cas v nazvu, aby si Node nenacetl predchozi pokus z cache.
|
||||
const temp = path.join(scriptsDir(), `_tmp.${id}.${Date.now()}.js`);
|
||||
|
||||
try {
|
||||
await fs.mkdir(scriptsDir(), { recursive: true });
|
||||
await fs.writeFile(temp, code, 'utf8');
|
||||
|
||||
const mtimeMs = (await fs.stat(temp)).mtimeMs;
|
||||
const loaded = await importScript(temp, mtimeMs, id);
|
||||
|
||||
if (isProblem(loaded)) {
|
||||
return { ok: false, message: loaded.message, issues: loaded.issues };
|
||||
}
|
||||
|
||||
await fs.rename(temp, target);
|
||||
// Nova mtime, at si registr vezme skutecny soubor a ne docasny.
|
||||
const finalMtime = (await fs.stat(target)).mtimeMs;
|
||||
scripts.set(id, { ...loaded, file: `${id}.js`, mtimeMs: finalMtime });
|
||||
problems.delete(id);
|
||||
publishToCatalog();
|
||||
console.info(`[scripts] ulozen ${id}`);
|
||||
|
||||
return { ok: true, manifest: loaded.manifest };
|
||||
} catch (err) {
|
||||
return { ok: false, message: `Uložení selhalo: ${problemMessage(err)}` };
|
||||
} finally {
|
||||
await fs.rm(temp, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Spusteni jednoho skriptu.
|
||||
*
|
||||
* Runner nikdy nevyhodi vyjimku. Vzdy vrati vysledek, ve kterem je bud vystup,
|
||||
* nebo popsana chyba vcetne toho, jestli ma smysl zkusit to znovu. Az bude
|
||||
* existovat runtime automatizaci, bude tohle jeho jediny vstupni bod na kroku,
|
||||
* takze fronta nemusi resit nic z toho, co je tady.
|
||||
*
|
||||
* Poradi je vzdy stejne: overit vstup, spustit, overit vystup. Neoverený
|
||||
* vystup by znamenal, ze strom veri parametru, ktery neexistuje.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
import { connectionSecrets, resolveConnection } from './connections.js';
|
||||
import { createHttp } from './http.js';
|
||||
import { connectorIdOf } from './manifest.js';
|
||||
import { getScript } from './registry.js';
|
||||
import {
|
||||
isRetryableKind,
|
||||
ScriptError,
|
||||
type ScriptContext,
|
||||
type ScriptErrorKind,
|
||||
type ScriptLogEntry,
|
||||
type ScriptRunResult,
|
||||
} from './types.js';
|
||||
import { createRedactor, describe, scriptUtil } from './util.js';
|
||||
import { validateValues } from './values.js';
|
||||
|
||||
export interface RunScriptOptions {
|
||||
/**
|
||||
* Stabilni pres vsechny pokusy tehoz kroku. Kdyz chybi, dopocita se
|
||||
* ze skriptu a vstupu - dva stejne pokusy tak dostanou stejny klic.
|
||||
*/
|
||||
idempotencyKey?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
function defaultIdempotencyKey(scriptId: string, inputs: unknown): string {
|
||||
const hash = createHash('sha256')
|
||||
.update(scriptId)
|
||||
.update(JSON.stringify(inputs) ?? '')
|
||||
.digest('base64url');
|
||||
return `${scriptId}:${hash.slice(0, 24)}`;
|
||||
}
|
||||
|
||||
function toRunError(
|
||||
err: unknown,
|
||||
redact: (value: string) => string,
|
||||
): { kind: ScriptErrorKind; message: string; status?: number; detail?: string } {
|
||||
if (err instanceof ScriptError) {
|
||||
return {
|
||||
kind: err.kind,
|
||||
message: redact(err.message),
|
||||
...(err.status !== undefined ? { status: err.status } : {}),
|
||||
...(err.detail !== undefined ? { detail: redact(err.detail) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Neocekavana vyjimka ve skriptu. Opakovat ji nema smysl, kod se sam nespravi.
|
||||
return { kind: 'internal', message: redact(`Skript selhal: ${message}`) };
|
||||
}
|
||||
|
||||
export async function runScript(
|
||||
scriptId: string,
|
||||
rawInputs: unknown,
|
||||
options: RunScriptOptions = {},
|
||||
): Promise<ScriptRunResult> {
|
||||
const startedAt = Date.now();
|
||||
const logs: ScriptLogEntry[] = [];
|
||||
let httpCalls = 0;
|
||||
|
||||
const finish = (
|
||||
partial: Pick<ScriptRunResult, 'ok' | 'outputs' | 'error'>,
|
||||
): ScriptRunResult => ({
|
||||
scriptId,
|
||||
logs,
|
||||
durationMs: Date.now() - startedAt,
|
||||
httpCalls,
|
||||
...partial,
|
||||
});
|
||||
|
||||
const script = await getScript(scriptId);
|
||||
if (!script) {
|
||||
return finish({
|
||||
ok: false,
|
||||
outputs: {},
|
||||
error: {
|
||||
kind: 'not_found',
|
||||
message: `Skript ${scriptId} neexistuje nebo se nepodařilo načíst.`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { manifest } = script;
|
||||
const connection = resolveConnection(connectorIdOf(scriptId));
|
||||
const redact = createRedactor(connectionSecrets(connection));
|
||||
|
||||
if (!connection.ready) {
|
||||
return finish({
|
||||
ok: false,
|
||||
outputs: {},
|
||||
error: {
|
||||
kind: 'config',
|
||||
message: `Napojení na ${connection.connectorId} není nastavené. Chybí: ${connection.missing.join(', ')}.`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const validatedInputs = validateValues(manifest.inputs, rawInputs, {
|
||||
logLabel: `${scriptId} vstup`,
|
||||
});
|
||||
if (!validatedInputs.ok) {
|
||||
return finish({
|
||||
ok: false,
|
||||
outputs: {},
|
||||
error: {
|
||||
kind: 'validation',
|
||||
message: 'Vstupní parametry nejsou v pořádku.',
|
||||
retryable: false,
|
||||
issues: validatedInputs.issues,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const idempotencyKey =
|
||||
options.idempotencyKey ?? defaultIdempotencyKey(scriptId, validatedInputs.values);
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? manifest.timeoutMs ?? config.scriptTimeoutMs;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const log = (message: string, detail?: unknown) => {
|
||||
// Log muze cist klient, proto vzdy pres redakci a vzdy zkraceny.
|
||||
logs.push({
|
||||
at: new Date().toISOString(),
|
||||
message: redact(describe(message, 300)),
|
||||
...(detail !== undefined ? { detail: redact(describe(detail)) } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const ctx: ScriptContext = {
|
||||
http: createHttp({
|
||||
connection,
|
||||
signal: controller.signal,
|
||||
idempotencyKey,
|
||||
redact,
|
||||
log,
|
||||
onCall: () => {
|
||||
httpCalls += 1;
|
||||
},
|
||||
}),
|
||||
util: scriptUtil,
|
||||
log,
|
||||
config: Object.freeze({ ...connection.config }),
|
||||
idempotencyKey,
|
||||
fail(message, detail) {
|
||||
throw new ScriptError('terminal', message, { detail: describe(detail) });
|
||||
},
|
||||
retry(message, detail) {
|
||||
throw new ScriptError('retryable', message, { detail: describe(detail) });
|
||||
},
|
||||
};
|
||||
|
||||
let returned: unknown;
|
||||
try {
|
||||
returned = await script.run(validatedInputs.values, ctx);
|
||||
} catch (err) {
|
||||
const error = toRunError(err, redact);
|
||||
console.warn(`[scripts] ${scriptId} selhal (${error.kind}): ${error.message}`);
|
||||
return finish({
|
||||
ok: false,
|
||||
outputs: {},
|
||||
error: { ...error, retryable: isRetryableKind(error.kind) },
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
return finish({
|
||||
ok: false,
|
||||
outputs: {},
|
||||
error: {
|
||||
kind: 'timeout',
|
||||
message: `Skript nedoběhl v limitu ${timeoutMs} ms.`,
|
||||
retryable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const validatedOutputs = validateValues(manifest.outputs, returned, {
|
||||
logLabel: `${scriptId} výstup`,
|
||||
});
|
||||
if (!validatedOutputs.ok) {
|
||||
// Chyba skriptu, ne uzivatele. Strom by jinak veril parametru, ktery nedosel.
|
||||
console.error(
|
||||
`[scripts] ${scriptId} nevratil deklarovane vystupy: ${validatedOutputs.issues
|
||||
.map((issue) => issue.message)
|
||||
.join(' ')}`,
|
||||
);
|
||||
return finish({
|
||||
ok: false,
|
||||
outputs: {},
|
||||
error: {
|
||||
kind: 'output',
|
||||
message: 'Skript nevrátil parametry, které má v manifestu.',
|
||||
retryable: false,
|
||||
issues: validatedOutputs.issues,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return finish({ ok: true, outputs: validatedOutputs.values, error: null });
|
||||
}
|
||||
|
||||
/** Vysledek jako jeden radek do logu ticketu. Az bude runtime, pouzije tohle. */
|
||||
export function summarizeRun(result: ScriptRunResult): string {
|
||||
if (result.ok) {
|
||||
const pairs = Object.entries(result.outputs)
|
||||
.map(([key, value]) => `${key}=${value === null ? '-' : String(value)}`)
|
||||
.join(', ');
|
||||
return `${result.scriptId} ok za ${result.durationMs} ms${pairs ? ` (${pairs})` : ''}`;
|
||||
}
|
||||
return `${result.scriptId} selhal: ${result.error?.message ?? 'neznámá chyba'}`;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Co je skript konektoru.
|
||||
*
|
||||
* Skript je jeden soubor, ktery nese dve veci: **manifest** (jak se jmenuje,
|
||||
* co potrebuje na vstupu, co vraci na vystupu) a **kod**, ktery to udela.
|
||||
* Diky manifestu s nim umi pracovat strom automatizace, aniz by o jeho kodu
|
||||
* cokoliv vedel.
|
||||
*
|
||||
* Zdroj pravdy o tvaru manifestu je zod schema tady v tomhle souboru. Typy
|
||||
* se z nej odvozuji, aby nebyl na dvou mistech a jednou se nerozesel.
|
||||
*
|
||||
* Souvisejici navrh: documentation/09-navrh-rozsireni.md, bod 9.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ------------------------------------------------------------------- hodnoty
|
||||
|
||||
/** Skript pracuje jen s temito hodnotami. Zadne objekty ani pole. */
|
||||
export type ScriptValue = string | number | boolean | null;
|
||||
export type ScriptValues = Record<string, ScriptValue>;
|
||||
|
||||
// -------------------------------------------------------------------- schema
|
||||
|
||||
const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
|
||||
|
||||
/**
|
||||
* Jeden parametr, vstupni nebo vystupni. Zamerne je to jeden typ pro obe
|
||||
* strany - validace je pak taky jedna funkce, ne dve skoro stejne.
|
||||
*
|
||||
* `id` se pouziva v sablonach jako `{{id}}`, proto smi obsahovat jen to,
|
||||
* co jde napsat bez preklepu.
|
||||
*/
|
||||
export const scriptFieldSchema = z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[A-Za-z][A-Za-z0-9_]*$/,
|
||||
'ID parametru musí začínat písmenem a obsahovat jen písmena, číslice a podtržítko.',
|
||||
),
|
||||
label: z.string().min(1, 'Popis parametru nesmí být prázdný.'),
|
||||
type: fieldTypeSchema,
|
||||
required: z.boolean(),
|
||||
/** Napoveda pod polem v builderu. */
|
||||
hint: z.string().optional(),
|
||||
/** Vyber z hodnot. Jina hodnota neprojde validaci. */
|
||||
options: z
|
||||
.array(z.object({ value: z.string(), label: z.string() }))
|
||||
.min(1)
|
||||
.optional(),
|
||||
/** Jen u typu string: dalsi kontrola regularnim vyrazem. */
|
||||
pattern: z.string().optional(),
|
||||
/** Jen u typu string: pole na vic radku. Builder ho vykresli jako longtext. */
|
||||
multiline: z.boolean().optional(),
|
||||
/** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */
|
||||
default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ScriptField = z.infer<typeof scriptFieldSchema>;
|
||||
|
||||
/**
|
||||
* Manifest skriptu.
|
||||
*
|
||||
* `id` ma tvar `<konektor>.<operace>`, napriklad `idoklad.get-issued-invoice`.
|
||||
* Z nej se dopocita, do ktereho konektoru operace patri, takze se to nepise
|
||||
* dvakrat. Nazev souboru musi byt `<id>.js`.
|
||||
*
|
||||
* `.strict()` je zamer: preklep v nazvu klice (`outputFileds`) se ma ohlasit,
|
||||
* ne tise ignorovat.
|
||||
*/
|
||||
export const scriptManifestSchema = z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/,
|
||||
'ID skriptu musí mít tvar konektor.operace, například idoklad.get-issued-invoice.',
|
||||
),
|
||||
name: z.string().min(1, 'Název skriptu nesmí být prázdný.'),
|
||||
description: z.string().min(1, 'Popis skriptu nesmí být prázdný.'),
|
||||
inputs: z.array(scriptFieldSchema).default([]),
|
||||
outputs: z.array(scriptFieldSchema).default([]),
|
||||
/** Strop na jeden beh. Kdyz chybi, pouzije se hodnota z konfigurace. */
|
||||
timeoutMs: z.number().int().min(1000).max(120_000).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((manifest, ctx) => {
|
||||
for (const key of ['inputs', 'outputs'] as const) {
|
||||
const seen = new Set<string>();
|
||||
for (const field of manifest[key]) {
|
||||
if (seen.has(field.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [key],
|
||||
message: `Parametr ${field.id} je uveden dvakrát.`,
|
||||
});
|
||||
}
|
||||
seen.add(field.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type ScriptManifest = z.infer<typeof scriptManifestSchema>;
|
||||
|
||||
// --------------------------------------------------------------------- chyby
|
||||
|
||||
/**
|
||||
* Druh selhani. Rozdeleni na `retryable` a `terminal` je to podstatne:
|
||||
* timeout nebo 503 ma smysl zkusit znovu, chyba ve vstupu nebo 403 ne.
|
||||
* Opakovat koncovou chybu jen vypali kvotu u cizi sluzby.
|
||||
*/
|
||||
export type ScriptErrorKind =
|
||||
| 'not_found'
|
||||
| 'config'
|
||||
| 'validation'
|
||||
| 'output'
|
||||
| 'retryable'
|
||||
| 'terminal'
|
||||
| 'timeout'
|
||||
| 'internal';
|
||||
|
||||
const retryableKinds: ScriptErrorKind[] = ['retryable', 'timeout'];
|
||||
|
||||
export function isRetryableKind(kind: ScriptErrorKind): boolean {
|
||||
return retryableKinds.includes(kind);
|
||||
}
|
||||
|
||||
export class ScriptError extends Error {
|
||||
readonly kind: ScriptErrorKind;
|
||||
/** HTTP kod cizi sluzby, kdyz chyba prisla z volani. */
|
||||
readonly status?: number;
|
||||
/** Kratky detail k zobrazeni. Uz zredigovany, bez tajemstvi. */
|
||||
readonly detail?: string;
|
||||
|
||||
constructor(
|
||||
kind: ScriptErrorKind,
|
||||
message: string,
|
||||
options: { status?: number; detail?: string; cause?: unknown } = {},
|
||||
) {
|
||||
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
||||
this.name = 'ScriptError';
|
||||
this.kind = kind;
|
||||
this.status = options.status;
|
||||
this.detail = options.detail;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- kontext
|
||||
|
||||
export interface ScriptHttpResponse<T = unknown> {
|
||||
status: number;
|
||||
body: T;
|
||||
}
|
||||
|
||||
export interface ScriptHttpOptions {
|
||||
query?: Record<string, string | number | boolean | undefined | null>;
|
||||
headers?: Record<string, string>;
|
||||
/** Kody, ktere se nemaji brat jako chyba. Vychozi je 2xx. */
|
||||
allowStatus?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP klient predany skriptu. Adresu a autorizaci doplnuje runtime podle
|
||||
* napojeni, takze **skript se k pristupovym udajum nedostane**.
|
||||
*/
|
||||
export interface ScriptHttp {
|
||||
get<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
|
||||
post<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>>;
|
||||
patch<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>>;
|
||||
put<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: ScriptHttpOptions,
|
||||
): Promise<ScriptHttpResponse<T>>;
|
||||
del<T = unknown>(path: string, options?: ScriptHttpOptions): Promise<ScriptHttpResponse<T>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pomocne funkce. Jsou na kontextu, ne v importu, ze dvou duvodu: skript
|
||||
* nemusi resit relativni cesty a stejny podpis bude fungovat i pozdeji
|
||||
* v sandboxu, kde zadny import neni.
|
||||
*/
|
||||
export interface ScriptUtil {
|
||||
/** Rozbali obalku odpovedi, tedy `{ Data: ... }` i `{ data: ... }`. */
|
||||
unwrap<T = unknown>(body: unknown): T;
|
||||
/** Prvni existujici pole bez ohledu na velka a mala pismena. */
|
||||
pick(source: unknown, ...names: string[]): unknown;
|
||||
/** Prvni prvek pole, nebo null. */
|
||||
first<T = unknown>(value: unknown): T | null;
|
||||
text(value: unknown, fallback?: string | null): string | null;
|
||||
num(value: unknown, fallback?: number | null): number | null;
|
||||
bool(value: unknown): boolean;
|
||||
/** Datum jako ISO retezec, nebo null. */
|
||||
date(value: unknown): string | null;
|
||||
/** Zaokrouhli na dane desetinne misto. Uctuje se v halerich. */
|
||||
round(value: number, decimals?: number): number;
|
||||
/**
|
||||
* Vrati hodnotu, nebo skonci chybou s citelnou zpravou.
|
||||
* Pro povinne vystupy: kdyz je cizi odpoved jina, nez skript ceka, ma se to
|
||||
* poznat hned a s nazvem pole, ne az na chybejicim parametru ve strome.
|
||||
*/
|
||||
need<T>(value: T | null | undefined, label: string): T;
|
||||
}
|
||||
|
||||
export interface ScriptContext {
|
||||
http: ScriptHttp;
|
||||
util: ScriptUtil;
|
||||
/** Zapise radek do logu behu. Nikdy sem nedavat pristupove udaje. */
|
||||
log(message: string, detail?: unknown): void;
|
||||
/** Necitliva cast nastaveni napojeni. */
|
||||
config: Readonly<Record<string, string>>;
|
||||
/**
|
||||
* Stabilni pres vsechny pokusy tehoz kroku. Predava se cizim sluzbam jako
|
||||
* `Idempotency-Key`, aby druhy pokus nevystavil druhou fakturu.
|
||||
*/
|
||||
idempotencyKey: string;
|
||||
/** Koncova chyba, neopakuje se. Typicky nesmyslny vstup nebo 404 od sluzby. */
|
||||
fail(message: string, detail?: unknown): never;
|
||||
/** Opakovatelna chyba. Typicky vypadek nebo docasna nedostupnost. */
|
||||
retry(message: string, detail?: unknown): never;
|
||||
}
|
||||
|
||||
/** Co soubor skriptu exportuje. */
|
||||
export interface ScriptModule {
|
||||
manifest: unknown;
|
||||
run: (inputs: ScriptValues, ctx: ScriptContext) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- vysledek
|
||||
|
||||
export interface ScriptLogEntry {
|
||||
at: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ScriptRunError {
|
||||
kind: ScriptErrorKind;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
status?: number;
|
||||
detail?: string;
|
||||
/** Vyplnene jen u chyb ve vstupu nebo vystupu. */
|
||||
issues?: FieldIssue[];
|
||||
}
|
||||
|
||||
export interface FieldIssue {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ScriptRunResult {
|
||||
ok: boolean;
|
||||
scriptId: string;
|
||||
/** Prazdne, kdyz beh selhal. */
|
||||
outputs: ScriptValues;
|
||||
logs: ScriptLogEntry[];
|
||||
durationMs: number;
|
||||
httpCalls: number;
|
||||
error: ScriptRunError | null;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Pomocne funkce predane skriptu jako `ctx.util`, plus redakce tajemstvi.
|
||||
*
|
||||
* Duvod, proc to neni v kazdem skriptu znovu: cizi API vraci pokazde jinak.
|
||||
* iDoklad pouziva velka pocatecni pismena a nekde obaluje odpoved do `Data`,
|
||||
* jine sluzby ne. Bez `unwrap` a `pick` by kazdy skript resil totez a jeden
|
||||
* z nich by to resil spatne.
|
||||
*/
|
||||
|
||||
import { ScriptError, type ScriptUtil } from './types.js';
|
||||
|
||||
/** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */
|
||||
function unwrap<T = unknown>(body: unknown): T {
|
||||
if (body === null || typeof body !== 'object') return body as T;
|
||||
const record = body as Record<string, unknown>;
|
||||
if ('Data' in record) return record.Data as T;
|
||||
if ('data' in record) return record.data as T;
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prvni existujici pole bez ohledu na velikost pismen.
|
||||
* `pick(invoice, 'documentNumber')` najde `DocumentNumber` i `documentNumber`.
|
||||
*/
|
||||
function pick(source: unknown, ...names: string[]): unknown {
|
||||
if (source === null || typeof source !== 'object') return undefined;
|
||||
const record = source as Record<string, unknown>;
|
||||
|
||||
for (const name of names) {
|
||||
if (record[name] !== undefined) return record[name];
|
||||
}
|
||||
|
||||
const lowered = new Map<string, unknown>();
|
||||
for (const [key, value] of Object.entries(record)) lowered.set(key.toLowerCase(), value);
|
||||
for (const name of names) {
|
||||
const value = lowered.get(name.toLowerCase());
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function first<T = unknown>(value: unknown): T | null {
|
||||
if (Array.isArray(value)) return (value[0] as T) ?? null;
|
||||
const unwrapped = unwrap(value);
|
||||
if (Array.isArray(unwrapped)) return (unwrapped[0] as T) ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function text(value: unknown, fallback: string | null = null): string | null {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
if (typeof value === 'object') return fallback;
|
||||
const result = String(value).trim();
|
||||
return result === '' ? fallback : result;
|
||||
}
|
||||
|
||||
function num(value: unknown, fallback: number | null = null): number | null {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
const parsed = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function bool(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
const lowered = String(value ?? '').trim().toLowerCase();
|
||||
return lowered === 'true' || lowered === '1' || lowered === 'yes' || lowered === 'ano';
|
||||
}
|
||||
|
||||
function date(value: unknown): string | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value));
|
||||
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
|
||||
}
|
||||
|
||||
function round(value: number, decimals = 2): number {
|
||||
const factor = 10 ** decimals;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function need<T>(value: T | null | undefined, label: string): T {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
throw new ScriptError('output', `Odpověď služby neobsahuje ${label}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const scriptUtil: ScriptUtil = { unwrap, pick, first, text, num, bool, date, round, need };
|
||||
|
||||
// ------------------------------------------------------------------- redakce
|
||||
|
||||
/**
|
||||
* Nahradi tajne hodnoty hvezdickami.
|
||||
*
|
||||
* Neni to kosmetika. Log ticketu ukazuje, co sluzba vratila, a cizi API rado
|
||||
* vraci prijaty token v chybove zprave. Bez redakce by tajemstvi skoncilo
|
||||
* v logu, ktery se navic zobrazuje klientovi.
|
||||
*/
|
||||
export function createRedactor(secrets: Array<string | undefined>): (value: string) => string {
|
||||
// Kratke hodnoty se neredigují - nahradit "1" hvezdickami by rozbilo cely text.
|
||||
const values = secrets
|
||||
.filter((value): value is string => typeof value === 'string' && value.length >= 6)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
if (values.length === 0) return (value) => value;
|
||||
|
||||
return (value: string) => {
|
||||
let result = value;
|
||||
for (const secret of values) result = result.split(secret).join('***');
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/** Zkrati text na danou delku, aby jeden log nezabral megabajt. */
|
||||
export function truncate(value: string, max = 600): string {
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max)} (zkráceno, celkem ${value.length} znaků)`;
|
||||
}
|
||||
|
||||
/** Bezpecne prevede cokoliv na kratky text do logu. */
|
||||
export function describe(value: unknown, max = 600): string {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'string') return truncate(value, max);
|
||||
try {
|
||||
return truncate(JSON.stringify(value) ?? String(value), max);
|
||||
} catch {
|
||||
return truncate(String(value), max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Kontrola parametru skriptu.
|
||||
*
|
||||
* Jedna funkce pro vstup i vystup. Kdyby to byly dve, jedna by se casem
|
||||
* opravila a druha ne, a strom by pak veril vystupu, ktery nikdo neoveril.
|
||||
*
|
||||
* Pravidla:
|
||||
* - povinny parametr bez hodnoty je chyba, ne prazdny retezec,
|
||||
* - nepovinny parametr bez hodnoty dostane `default`, jinak `null`,
|
||||
* - hodnota se prevede na deklarovany typ, kdyz to jde bez hadani,
|
||||
* - parametr, ktery v manifestu neni, se zahodi a zaloguje.
|
||||
*/
|
||||
|
||||
import type { FieldIssue, ScriptField, ScriptValue, ScriptValues } from './types.js';
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; values: ScriptValues }
|
||||
| { ok: false; issues: FieldIssue[] };
|
||||
|
||||
/** Retezce, ktere lidi i sluzby pouzivaji pro ano a ne. */
|
||||
const truthy = new Set(['true', '1', 'yes', 'y', 'ano', 'on']);
|
||||
const falsy = new Set(['false', '0', 'no', 'n', 'ne', 'off']);
|
||||
|
||||
function isMissing(value: unknown): boolean {
|
||||
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevede jednu hodnotu na deklarovany typ.
|
||||
* Vraci bud hodnotu, nebo text chyby - nikdy nehada.
|
||||
*/
|
||||
function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { error: string } {
|
||||
switch (field.type) {
|
||||
case 'string': {
|
||||
if (typeof raw === 'object') return { error: 'Očekává se text, přišel objekt.' };
|
||||
const text = field.multiline ? String(raw) : String(raw).trim();
|
||||
if (field.pattern) {
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(field.pattern);
|
||||
} catch {
|
||||
return { error: `Manifest má neplatný pattern: ${field.pattern}` };
|
||||
}
|
||||
if (!regex.test(text)) return { error: `Hodnota neodpovídá tvaru ${field.pattern}.` };
|
||||
}
|
||||
return { value: text };
|
||||
}
|
||||
|
||||
case 'number': {
|
||||
if (typeof raw === 'boolean') return { error: 'Očekává se číslo, přišlo ano/ne.' };
|
||||
// Ceska desetinna carka je bezna, nema smysl na ni padat.
|
||||
const text = typeof raw === 'string' ? raw.trim().replace(',', '.') : raw;
|
||||
const num = typeof text === 'number' ? text : Number(text);
|
||||
if (!Number.isFinite(num)) return { error: `"${String(raw)}" není číslo.` };
|
||||
return { value: num };
|
||||
}
|
||||
|
||||
case 'boolean': {
|
||||
if (typeof raw === 'boolean') return { value: raw };
|
||||
const text = String(raw).trim().toLowerCase();
|
||||
if (truthy.has(text)) return { value: true };
|
||||
if (falsy.has(text)) return { value: false };
|
||||
return { error: `"${String(raw)}" není ano ani ne.` };
|
||||
}
|
||||
|
||||
case 'date': {
|
||||
const text = raw instanceof Date ? raw.toISOString() : String(raw).trim();
|
||||
const parsed = Date.parse(text);
|
||||
if (Number.isNaN(parsed)) return { error: `"${text}" není platné datum.` };
|
||||
return { value: new Date(parsed).toISOString() };
|
||||
}
|
||||
|
||||
default: {
|
||||
// Vetev je nedosazitelna, dokud FieldType nema dalsi hodnotu.
|
||||
return { error: 'Neznámý typ parametru.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ValidateOptions {
|
||||
/** Kam se zapisuje varovani o parametrech navic. */
|
||||
logLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi a prevede sadu hodnot proti deklaraci parametru.
|
||||
* Vraci vsechny chyby najednou, ne jen prvni - uzivatel ma opravit vse.
|
||||
*/
|
||||
export function validateValues(
|
||||
fields: ScriptField[],
|
||||
raw: unknown,
|
||||
options: ValidateOptions,
|
||||
): ValidationResult {
|
||||
const source: Record<string, unknown> =
|
||||
raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
||||
|
||||
const issues: FieldIssue[] = [];
|
||||
const values: ScriptValues = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const incoming = source[field.id];
|
||||
|
||||
if (isMissing(incoming)) {
|
||||
if (field.required) {
|
||||
issues.push({ field: field.id, message: `${field.label} je povinné.` });
|
||||
continue;
|
||||
}
|
||||
values[field.id] = field.default ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = coerce(field, incoming);
|
||||
if ('error' in result) {
|
||||
issues.push({ field: field.id, message: `${field.label}: ${result.error}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.options && !field.options.some((option) => option.value === String(result.value))) {
|
||||
const allowed = field.options.map((option) => option.value).join(', ');
|
||||
issues.push({
|
||||
field: field.id,
|
||||
message: `${field.label}: povolené hodnoty jsou ${allowed}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
values[field.id] = result.value;
|
||||
}
|
||||
|
||||
// Parametry navic neodmitame, jen o nich chceme vedet. Stejne jako u webhooku.
|
||||
const declared = new Set(fields.map((field) => field.id));
|
||||
const extra = Object.keys(source).filter((key) => !declared.has(key));
|
||||
if (extra.length > 0) {
|
||||
console.warn(`[scripts] ${options.logLabel}: parametry mimo manifest: ${extra.join(', ')}`);
|
||||
}
|
||||
|
||||
return issues.length > 0 ? { ok: false, issues } : { ok: true, values };
|
||||
}
|
||||
Reference in New Issue
Block a user