@formio/uag-sdk 0.0.0-stage → 2.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/loader.js ADDED
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { ENGINE_API_VERSION, definitionProblems } = require('./define');
5
+ const { EngineConfigError, redact } = require('./errors');
6
+ const { checkName, checkType, checkUnique, isPackageName } = require('./instances');
7
+ const { missingSetting, resolveSettings, secretValues, settingVariable } = require('./settings');
8
+
9
+ /**
10
+ * Finding, configuring and creating engine instances. The UAG, `uag-eval` and
11
+ * the engine server all load engines here, so one configuration means the same
12
+ * engines in each.
13
+ */
14
+
15
+ /**
16
+ * The directories whose `node_modules` engine packages are looked up in: the
17
+ * host's own installation and each directory above it (where Node finds the
18
+ * host's own dependencies, so engines installed beside the host are found),
19
+ * then each absolute directory in `UAG_ENGINE_PATH`, without the directories
20
+ * above it. Never the working directory, `NODE_PATH` or Node's global folders.
21
+ */
22
+ function enginePaths(root, env = process.env) {
23
+ const extra = String(env.UAG_ENGINE_PATH || '').split(path.delimiter).map((dir) => dir.trim()).filter(Boolean);
24
+ for (const dir of extra) {
25
+ if (!path.isAbsolute(dir)) {
26
+ throw new EngineConfigError(`UAG_ENGINE_PATH must list absolute directories; "${dir}" is relative.`);
27
+ }
28
+ }
29
+ const host = [];
30
+ for (let dir = root && path.resolve(root); dir; ) {
31
+ // As Node does, never look for node_modules/node_modules.
32
+ if (path.basename(dir) !== 'node_modules') host.push(dir);
33
+ const parent = path.dirname(dir);
34
+ dir = parent === dir ? undefined : parent;
35
+ }
36
+ return [...host, ...extra];
37
+ }
38
+
39
+ /** The packages a type name may come from, in order. */
40
+ function packageCandidates(type) {
41
+ return isPackageName(type) ? [type] : [`@formio/uag-${type}`, `uag-engine-${type}`];
42
+ }
43
+
44
+ /** Whether `file` is `dir` or inside it. */
45
+ function isInside(file, dir) {
46
+ const relative = path.relative(dir, file);
47
+ return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
48
+ }
49
+
50
+ /**
51
+ * Find `name` in `<dir>/node_modules` of the first of `paths` that has it, and
52
+ * nowhere else: not in the directories above, `NODE_PATH` or Node's global
53
+ * folders, which `require.resolve` would also search. Returns its entry file
54
+ * and package.json, or undefined when no path has it.
55
+ */
56
+ function findPackage(name, paths) {
57
+ for (const dir of paths) {
58
+ const packageDir = path.join(dir, 'node_modules', ...name.split('/'));
59
+ const manifest = path.join(packageDir, 'package.json');
60
+ if (!fs.existsSync(manifest)) continue;
61
+ let json;
62
+ try {
63
+ json = JSON.parse(fs.readFileSync(manifest, 'utf8'));
64
+ }
65
+ catch (err) {
66
+ throw new EngineConfigError(`The package "${name}" has an unreadable package.json (${manifest}): ${err && err.message}`);
67
+ }
68
+ let entry;
69
+ try {
70
+ entry = require.resolve(name, { paths: [dir] });
71
+ }
72
+ catch (err) {
73
+ throw new EngineConfigError(`The package "${name}" (${packageDir}) has no entry point that require() can load: ${err && (err.code || err.message)}.`);
74
+ }
75
+ // A broken package must not fall through to a copy found elsewhere.
76
+ // Real paths, so a linked workspace package is still its own.
77
+ if (!isInside(fs.realpathSync(entry), fs.realpathSync(packageDir))) {
78
+ throw new EngineConfigError(`The package "${name}" in ${packageDir} resolved to ${entry}, outside it; it was not loaded.`);
79
+ }
80
+ return { entry, json };
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ /** The message of anything thrown, including a string. */
86
+ function errorText(err) {
87
+ return err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err);
88
+ }
89
+
90
+ /** Check a definition, from this SDK, another copy of it, or none. */
91
+ function checkDefinition(definition, source, apiVersion) {
92
+ const problems = definitionProblems(definition);
93
+ if (problems.length) {
94
+ throw new EngineConfigError(`${source} is not a UAG engine: it does not export a valid engine type definition (${problems.join('; ')}).`);
95
+ }
96
+ if (definition.apiVersion !== apiVersion) {
97
+ throw new EngineConfigError(
98
+ `${source} (engine type "${definition.type}") was built for engine API version ${definition.apiVersion}; ` +
99
+ `this server supports version ${apiVersion}.`
100
+ );
101
+ }
102
+ return definition;
103
+ }
104
+
105
+ /**
106
+ * The definition for an instance's type: a definition given directly, a type in
107
+ * `types` (built-in, or passed by the embedding application), then the packages
108
+ * `@formio/uag-<type>` and `uag-engine-<type>`, or the package named.
109
+ * Returns the package's name and version when it came from one.
110
+ */
111
+ function resolveType(type, { types = {}, paths = [], apiVersion = ENGINE_API_VERSION } = {}) {
112
+ if (type && typeof type === 'object') {
113
+ return { definition: checkDefinition(type, 'The engine type given in configuration', apiVersion) };
114
+ }
115
+ checkType(type);
116
+ if (!isPackageName(type) && Object.prototype.hasOwnProperty.call(types, type)) {
117
+ return { definition: checkDefinition(types[type], `The built-in engine type "${type}"`, apiVersion) };
118
+ }
119
+ const candidates = packageCandidates(type);
120
+ for (const candidate of candidates) {
121
+ const found = findPackage(candidate, paths);
122
+ if (!found) continue;
123
+ const { entry, json: pkg } = found;
124
+ let exported;
125
+ try {
126
+ exported = require(entry);
127
+ }
128
+ catch (err) {
129
+ throw new EngineConfigError(`The package "${candidate}" failed to load: ${errorText(err)}`);
130
+ }
131
+ const definition = exported && exported.__esModule && exported.default ? exported.default : exported;
132
+ return {
133
+ definition: checkDefinition(definition, `The package "${candidate}"`, apiVersion),
134
+ package: candidate,
135
+ ...(pkg.version && { version: pkg.version })
136
+ };
137
+ }
138
+ const known = Object.keys(types);
139
+ throw new EngineConfigError(
140
+ `No engine type "${type}" is installed. Looked for ${candidates.map((name) => `"${name}"`).join(' and ')}. ` +
141
+ `Install one (npm install ${candidates[0]}), or add a directory that contains it to UAG_ENGINE_PATH.` +
142
+ (known.length ? ` Types available without installing: ${known.join(', ')}.` : '')
143
+ );
144
+ }
145
+
146
+ /** Refuse two instance settings that would read the same `UAG_ENGINE_*` variable. */
147
+ function checkVariables(resolved) {
148
+ const owners = new Map();
149
+ for (const { name, definition } of resolved) {
150
+ for (const key of Object.keys(definition.settings || {})) {
151
+ const variable = settingVariable(name, key);
152
+ const owner = owners.get(variable);
153
+ if (owner && owner.name !== name) {
154
+ throw new EngineConfigError(
155
+ `The engines "${owner.name}" and "${name}" would both read ${variable} (for ${owner.key} and ${key}). Rename one of them.`
156
+ );
157
+ }
158
+ owners.set(variable, { name, key });
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Create one engine from its definition, for an instance name and options.
165
+ * Errors name the instance and its type, and never contain a secret setting.
166
+ */
167
+ async function createEngine(definition, { name, options = {}, env = process.env, config = {} }) {
168
+ const label = `The "${name}" engine (type "${definition.type}")`;
169
+ const settings = resolveSettings(definition, name, options, env);
170
+ const secrets = secretValues(definition, settings, options);
171
+ let engine;
172
+ try {
173
+ engine = await definition.create({
174
+ name,
175
+ settings,
176
+ options,
177
+ config,
178
+ requireSetting: (key) => {
179
+ if (settings[key] === undefined) throw missingSetting(definition, name, key);
180
+ return settings[key];
181
+ },
182
+ });
183
+ }
184
+ catch (err) {
185
+ const message = redact(errorText(err), secrets);
186
+ throw new EngineConfigError(err instanceof EngineConfigError || (err && err.name === 'EngineConfigError') ? message : `${label} failed to start: ${message}`);
187
+ }
188
+ if (!engine || typeof engine !== 'object') {
189
+ throw new EngineConfigError(`${label} did not create an engine.`);
190
+ }
191
+ if (engine.name !== name) {
192
+ throw new EngineConfigError(`${label} created an engine named ${JSON.stringify(engine.name)}; it must use the instance name.`);
193
+ }
194
+ if (!definition.kinds.includes(engine.kind)) {
195
+ throw new EngineConfigError(`${label} created a ${JSON.stringify(engine.kind)} engine; its type declares ${definition.kinds.join(' and ')}.`);
196
+ }
197
+ return engine;
198
+ }
199
+
200
+ /**
201
+ * Load the given instances (from `parseInstances` or `engineInstances`): resolve
202
+ * each type, resolve its settings, and create the engine. Returns
203
+ * `{ engine, type, package, version }` for each, in order, ready for
204
+ * `Formflow.engineRegistry`. Any problem stops loading with the instance named.
205
+ */
206
+ async function loadEngines(instances, { types = {}, paths = [], env = process.env, config = {}, apiVersion = ENGINE_API_VERSION } = {}) {
207
+ const resolved = instances.map((instance) => {
208
+ const found = resolveType(instance.type, { types, paths, apiVersion });
209
+ const name = instance.name || found.definition.type;
210
+ checkName(name);
211
+ return { ...found, name, options: instance.options || {} };
212
+ });
213
+ checkUnique(resolved);
214
+ checkVariables(resolved);
215
+ const loaded = [];
216
+ for (const { definition, name, options, package: pkg, version } of resolved) {
217
+ const engine = await createEngine(definition, { name, options, env, config });
218
+ loaded.push({ engine, type: definition.type, ...(pkg && { package: pkg }), ...(version && { version }) });
219
+ }
220
+ return loaded;
221
+ }
222
+
223
+ module.exports = { enginePaths, packageCandidates, resolveType, createEngine, loadEngines };
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@formio/uag-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0-rc.1",
4
+ "description": "Build engine packages for the Form.io Universal Agent Gateway (UAG): the engine contract, defineEngine, settings and instance resolution, the engine loader, and a conformance check.",
5
+ "keywords": [
6
+ "formio",
7
+ "uag",
8
+ "universal-agent-gateway",
9
+ "ai",
10
+ "agents",
11
+ "engine",
12
+ "sdk"
13
+ ],
14
+ "license": "MIT",
15
+ "main": "index.js",
16
+ "types": "index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./index.d.ts",
20
+ "default": "./index.js"
21
+ },
22
+ "./loader": {
23
+ "types": "./loader.d.ts",
24
+ "default": "./loader.js"
25
+ },
26
+ "./conformance": {
27
+ "types": "./conformance.d.ts",
28
+ "default": "./conformance.js"
29
+ },
30
+ "./protocol": {
31
+ "types": "./protocol.d.ts",
32
+ "default": "./protocol.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "*.js",
38
+ "*.d.ts",
39
+ "README.md"
40
+ ],
41
+ "scripts": {
42
+ "test": "mocha"
43
+ },
44
+ "engines": {
45
+ "node": ">=22.0.0"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/formio/uag.git",
50
+ "directory": "packages/engine-sdk"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "devDependencies": {
56
+ "chai": "^6.3.0",
57
+ "mocha": "^12.0.3"
58
+ }
59
+ }
package/protocol.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { DecisionQuestion, DecisionResponse, DecisionState, ExtractResponse, FieldSpec, ToolDefinition } from './contract';
2
+
3
+ /** The remote engine protocol version this SDK speaks. */
4
+ export declare const PROTOCOL_VERSION: 1;
5
+
6
+ /** The header that carries a scoped token, on research extracts only. */
7
+ export declare const SCOPED_TOKEN_HEADER: 'x-uag-scoped-token';
8
+
9
+ /** The optional header that carries the Agent API request id, on decide and extract. */
10
+ export declare const REQUEST_ID_HEADER: 'x-request-id';
11
+
12
+ /** A request id that is safe to send and log (up to 128 of `[A-Za-z0-9._:-]`), or undefined. */
13
+ export declare function safeRequestId(value: unknown): string | undefined;
14
+
15
+ /** A message in another protocol version, or of the wrong shape. */
16
+ export declare class ProtocolError extends Error {
17
+ status: number;
18
+ constructor(message: string, status?: number);
19
+ }
20
+
21
+ /** One engine an engine server hosts, as `GET /v1/engines` describes it. */
22
+ export type RemoteEngineDescription = {
23
+ name: string;
24
+ kind: 'decision' | 'agent';
25
+ model: string;
26
+ maxOptions?: number;
27
+ toolUse?: boolean;
28
+ };
29
+
30
+ export type RemoteExtractRequest = {
31
+ instructions: string;
32
+ state: DecisionState;
33
+ fields: FieldSpec[];
34
+ schema: Record<string, any>;
35
+ tools?: ToolDefinition[];
36
+ maxIterations?: number;
37
+ };
38
+
39
+ export declare function checkVersion(body: unknown, side?: 'request' | 'response'): void;
40
+ export declare function checkDecideRequest(body: unknown): { state: DecisionState; questions: Record<string, DecisionQuestion> };
41
+ export declare function checkExtractRequest(body: unknown): RemoteExtractRequest;
42
+ export declare function checkDescription(body: unknown): RemoteEngineDescription[];
43
+ export declare function checkDecisionResponse(body: unknown): DecisionResponse;
44
+ export declare function checkExtractResponse(body: unknown): ExtractResponse;
45
+ export declare function errorBody(message: string, expose?: boolean): { protocol: 1; error: string; expose: boolean };
package/protocol.js ADDED
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+ const { ENGINE_NAME } = require('./define');
3
+
4
+ /**
5
+ * The remote engine protocol, version 1: JSON over HTTP between the UAG's
6
+ * `remote` engine type and an engine server.
7
+ *
8
+ * GET /v1/engines -> { protocol, engines: [{ name, kind, model, maxOptions?, toolUse? }] }
9
+ * POST /v1/engines/:name/decide { protocol, state, questions } -> { protocol, model, answers, usage? }
10
+ * POST /v1/engines/:name/extract { protocol, instructions, state, fields, schema, tools?, maxIterations? }
11
+ * -> { protocol, model, values, toolCalls?, usage? }
12
+ * Authorization: Bearer <engine server key>
13
+ * X-UAG-Scoped-Token: <scoped token> research extracts only
14
+ * X-Request-Id: <request id> optional, on decide and extract
15
+ * errors: { protocol, error, expose } with a 4xx or 5xx status
16
+ *
17
+ * The version is in both the path and the body: the path routes future
18
+ * versions side by side, and the body catches a proxy that mixes them up.
19
+ *
20
+ * The request id is the Agent API request's, and the engine server sends it on
21
+ * with research's calls to the UAG's `/mcp`, so logs can group every leg of a
22
+ * run. Neither side sends or logs one that is not a short token (`safeRequestId`).
23
+ */
24
+ const PROTOCOL_VERSION = 1;
25
+ const SCOPED_TOKEN_HEADER = 'x-uag-scoped-token';
26
+ const REQUEST_ID_HEADER = 'x-request-id';
27
+ const REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
28
+
29
+ /** A request id that is safe to send and log: up to 128 letters, digits, `.`, `_`, `:` or `-`. Anything else is undefined. */
30
+ function safeRequestId(value) {
31
+ return typeof value === 'string' && REQUEST_ID.test(value) ? value : undefined;
32
+ }
33
+
34
+ /** A message that breaks the protocol: the wrong version, or the wrong shape. */
35
+ class ProtocolError extends Error {
36
+ constructor(message, status = 400) {
37
+ super(message);
38
+ this.name = 'ProtocolError';
39
+ this.status = status;
40
+ }
41
+ }
42
+
43
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
44
+
45
+ function check(condition, message, status) {
46
+ if (!condition) throw new ProtocolError(message, status);
47
+ }
48
+
49
+ /** Refuse a message in another protocol version, naming both versions. */
50
+ function checkVersion(body, side = 'request') {
51
+ check(isObject(body), `The ${side} is not a JSON object.`);
52
+ const version = body.protocol;
53
+ if (version !== PROTOCOL_VERSION) {
54
+ throw new ProtocolError(
55
+ `This side speaks remote engine protocol ${PROTOCOL_VERSION}; the ${side} used ${version === undefined ? 'no version' : `protocol ${JSON.stringify(version)}`}.`,
56
+ 400
57
+ );
58
+ }
59
+ }
60
+
61
+ function checkState(state) {
62
+ check(isObject(state) && isObject(state.input) && typeof state.today === 'string', '"state" must be an object with "input" and "today".');
63
+ }
64
+
65
+ /** A decide request, as an engine server receives it. */
66
+ function checkDecideRequest(body) {
67
+ checkVersion(body);
68
+ checkState(body.state);
69
+ check(isObject(body.questions), '"questions" must be an object.');
70
+ for (const [id, question] of Object.entries(body.questions)) {
71
+ check(isObject(question) && ['choice', 'score', 'noul'].includes(question.type) && typeof question.instructions === 'string',
72
+ `The question "${id}" is not a choice, score or noul question.`);
73
+ }
74
+ return { state: body.state, questions: body.questions };
75
+ }
76
+
77
+ /** An extract request, as an engine server receives it. */
78
+ function checkExtractRequest(body) {
79
+ checkVersion(body);
80
+ checkState(body.state);
81
+ check(typeof body.instructions === 'string', '"instructions" must be a string.');
82
+ check(Array.isArray(body.fields) && body.fields.every((field) => isObject(field) && typeof field.path === 'string'), '"fields" must be a list of fields with paths.');
83
+ check(isObject(body.schema), '"schema" must be an object.');
84
+ const request = { instructions: body.instructions, state: body.state, fields: body.fields, schema: body.schema };
85
+ if (body.tools !== undefined) {
86
+ check(Array.isArray(body.tools) && body.tools.every((tool) => isObject(tool) && typeof tool.name === 'string' && typeof tool.description === 'string' && isObject(tool.inputSchema)),
87
+ '"tools" must be a list of tool definitions.');
88
+ check(Number.isInteger(body.maxIterations) && body.maxIterations > 0, '"maxIterations" must be a positive whole number with "tools".');
89
+ Object.assign(request, { tools: body.tools, maxIterations: body.maxIterations });
90
+ }
91
+ return request;
92
+ }
93
+
94
+ /** A describe response, as the UAG receives it. */
95
+ function checkDescription(body) {
96
+ checkVersion(body, 'response');
97
+ check(Array.isArray(body.engines), '"engines" must be a list.');
98
+ return body.engines.map((engine) => {
99
+ check(isObject(engine) && typeof engine.name === 'string' && ENGINE_NAME.test(engine.name), 'Each engine needs a valid name.');
100
+ check(['decision', 'agent'].includes(engine.kind), `The engine "${engine.name}" must be a decision or an agent engine.`);
101
+ check(typeof engine.model === 'string' && engine.model, `The engine "${engine.name}" does not name its model.`);
102
+ if (engine.kind === 'decision') {
103
+ check(Number.isInteger(engine.maxOptions) && engine.maxOptions >= 2, `The decision engine "${engine.name}" needs maxOptions.`);
104
+ }
105
+ return {
106
+ name: engine.name,
107
+ kind: engine.kind,
108
+ model: engine.model,
109
+ ...(engine.kind === 'decision' && { maxOptions: engine.maxOptions }),
110
+ ...(engine.kind === 'agent' && { toolUse: engine.toolUse === true }),
111
+ };
112
+ });
113
+ }
114
+
115
+ /** A token count: a whole number, or null when not reported. */
116
+ function tokenCount(value, key) {
117
+ if (value === undefined || value === null) return null;
118
+ check(Number.isInteger(value) && value >= 0, `"usage.${key}" must be a whole number or null.`);
119
+ return value;
120
+ }
121
+
122
+ function checkUsage(usage) {
123
+ if (usage === undefined) return undefined;
124
+ check(isObject(usage), '"usage" must be an object.');
125
+ return { inputTokens: tokenCount(usage.inputTokens, 'inputTokens'), outputTokens: tokenCount(usage.outputTokens, 'outputTokens') };
126
+ }
127
+
128
+ const isProbability = (value) => typeof value === 'number' && value >= 0 && value <= 1;
129
+
130
+ /** One answer's shape (which options and levels were asked is the UAG's to check). */
131
+ function checkAnswer(id, answer) {
132
+ const where = `The answer to "${id}"`;
133
+ check(isObject(answer) && ['choice', 'score', 'noul'].includes(answer.type), `${where} must be a choice, score or noul answer.`);
134
+ if (answer.type === 'noul') {
135
+ check(isProbability(answer.probability), `${where} needs a probability from 0 to 1.`);
136
+ return;
137
+ }
138
+ check(isObject(answer.probabilities) && Object.values(answer.probabilities).every(isProbability), `${where} needs probabilities from 0 to 1.`);
139
+ check(isProbability(answer.confidence), `${where} needs a confidence from 0 to 1.`);
140
+ if (answer.type === 'choice') check(typeof answer.choice === 'string', `${where} needs its choice.`);
141
+ else check(Number.isFinite(answer.score), `${where} needs its score.`);
142
+ }
143
+
144
+ /** A decide response, as the UAG receives it. */
145
+ function checkDecisionResponse(body) {
146
+ checkVersion(body, 'response');
147
+ check(typeof body.model === 'string', 'The response does not name its model.');
148
+ check(isObject(body.answers), '"answers" must be an object.');
149
+ for (const [id, answer] of Object.entries(body.answers)) checkAnswer(id, answer);
150
+ const usage = checkUsage(body.usage);
151
+ return { model: body.model, answers: body.answers, ...(usage && { usage }) };
152
+ }
153
+
154
+ /** An extract response, as the UAG receives it. */
155
+ function checkExtractResponse(body) {
156
+ checkVersion(body, 'response');
157
+ check(typeof body.model === 'string', 'The response does not name its model.');
158
+ check(isObject(body.values), '"values" must be an object.');
159
+ if (body.toolCalls !== undefined) {
160
+ check(Array.isArray(body.toolCalls) && body.toolCalls.every((call) => isObject(call) && typeof call.name === 'string'), '"toolCalls" must be a list of calls.');
161
+ }
162
+ const usage = checkUsage(body.usage);
163
+ return {
164
+ model: body.model,
165
+ values: body.values,
166
+ ...(body.toolCalls && { toolCalls: body.toolCalls.map((call) => ({ name: call.name, isError: call.isError === true })) }),
167
+ ...(usage && { usage }),
168
+ };
169
+ }
170
+
171
+ /** An error response body. `expose` says whether the message may be shown to API callers. */
172
+ function errorBody(message, expose = false) {
173
+ return { protocol: PROTOCOL_VERSION, error: String(message), expose: expose === true };
174
+ }
175
+
176
+ module.exports = {
177
+ PROTOCOL_VERSION,
178
+ SCOPED_TOKEN_HEADER,
179
+ REQUEST_ID_HEADER,
180
+ safeRequestId,
181
+ ProtocolError,
182
+ checkVersion,
183
+ checkDecideRequest,
184
+ checkExtractRequest,
185
+ checkDescription,
186
+ checkDecisionResponse,
187
+ checkExtractResponse,
188
+ errorBody,
189
+ };
package/settings.js ADDED
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ const { EngineConfigError } = require('./errors');
3
+
4
+ /** `apiKey` → `API_KEY`, `baseURL` → `BASE_URL`, `fast-lane` → `FAST_LANE`. */
5
+ function upperSnake(name) {
6
+ return String(name)
7
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
8
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2')
9
+ .replace(/-/g, '_')
10
+ .toUpperCase();
11
+ }
12
+
13
+ /** The instance's own variable for a setting: `UAG_ENGINE_<NAME>_<SETTING>`. */
14
+ function settingVariable(instance, setting) {
15
+ return `UAG_ENGINE_${upperSnake(instance)}_${upperSnake(setting)}`;
16
+ }
17
+
18
+ /**
19
+ * The variables that set a setting, in the order they are read. `env` may be one
20
+ * name or a list: definitions not made with `defineEngine` are not normalised.
21
+ */
22
+ function settingVariables(instance, setting, spec) {
23
+ return [settingVariable(instance, setting), ...[].concat(spec.env || [])];
24
+ }
25
+
26
+ function given(value) {
27
+ return value !== undefined && value !== null && value !== '';
28
+ }
29
+
30
+ function listNames(names) {
31
+ return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}` : names[0];
32
+ }
33
+
34
+ /** Coerce a value read from the environment (always a string) to the setting's type. */
35
+ function coerce(value, spec, describe) {
36
+ const type = spec.type || 'string';
37
+ if (type === 'number') {
38
+ const number = typeof value === 'number' ? value : Number(String(value).trim());
39
+ if (String(value).trim() === '' || !Number.isFinite(number)) {
40
+ throw new EngineConfigError(`${describe()} must be a number${spec.secret ? '' : `, not ${JSON.stringify(value)}`}.`);
41
+ }
42
+ return number;
43
+ }
44
+ if (type === 'boolean') {
45
+ if (typeof value === 'boolean') return value;
46
+ const text = String(value).trim().toLowerCase();
47
+ if (['true', '1', 'yes'].includes(text)) return true;
48
+ if (['false', '0', 'no'].includes(text)) return false;
49
+ throw new EngineConfigError(`${describe()} must be true or false${spec.secret ? '' : `, not ${JSON.stringify(value)}`}.`);
50
+ }
51
+ return typeof value === 'string' ? value : String(value);
52
+ }
53
+
54
+ /**
55
+ * An instance's value for each setting its type declares, from the first of:
56
+ * the instance's options, `UAG_ENGINE_<NAME>_<SETTING>`, the type's own
57
+ * variables, and the default. A required setting with no value is an error
58
+ * naming the instance, the type, the setting and every variable that sets it.
59
+ */
60
+ function resolveSettings(definition, name, options = {}, env = process.env) {
61
+ const settings = {};
62
+ for (const [key, spec] of Object.entries(definition.settings || {})) {
63
+ const describe = () => `The "${name}" engine's ${key} setting`;
64
+ let value = options[key];
65
+ if (!given(value)) {
66
+ const variable = settingVariables(name, key, spec).find((candidate) => given(env[candidate]));
67
+ value = variable ? env[variable] : spec.default;
68
+ }
69
+ if (!given(value)) {
70
+ if (spec.required) {
71
+ throw missingSetting(definition, name, key);
72
+ }
73
+ continue;
74
+ }
75
+ settings[key] = coerce(value, spec, describe);
76
+ }
77
+ return settings;
78
+ }
79
+
80
+ /** The error for a required setting with no value. */
81
+ function missingSetting(definition, name, key) {
82
+ const spec = (definition.settings || {})[key] || {};
83
+ return new EngineConfigError(
84
+ `The "${name}" engine (type "${definition.type}") needs its ${key} setting: set ${listNames(settingVariables(name, key, spec))}.`
85
+ );
86
+ }
87
+
88
+ /** The resolved values of the settings a type marks secret. */
89
+ function secretValues(definition, settings, options = {}) {
90
+ const secrets = [];
91
+ for (const [key, spec] of Object.entries(definition.settings || {})) {
92
+ if (!spec.secret) continue;
93
+ if (given(settings[key])) secrets.push(settings[key]);
94
+ if (given(options[key])) secrets.push(options[key]);
95
+ }
96
+ return secrets;
97
+ }
98
+
99
+ module.exports = { upperSnake, settingVariable, settingVariables, resolveSettings, missingSetting, secretValues };