@xeplr/actions 1.4.7 → 1.5.0
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/README.md +38 -3
- package/lib/builtins/file/delete.js +43 -0
- package/lib/builtins/http-request.js +271 -3
- package/lib/builtins/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -128,7 +128,9 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
128
128
|
| `fileList` | `file-list` | The files in a folder, optionally matching `pattern` (`*.csv`, `Bank-2026-??.pdf`), sorted by name or date. `{ files[{ name, path, size, modifiedAt }], count }`. "Is it there?" is the exact name and `count`. See [File steps](#file-steps-and-the-folders-they-may-use). |
|
|
129
129
|
| `fileRead` | `file-read` | A CSV, text, Excel, JSON or NDJSON file as rows (format from the extension). `{ rows, count, columns, truncated }`, at most `maxRows` (50,000). |
|
|
130
130
|
| `fileMove` | `file-move` | Move or copy a file into a folder (made if missing) or to a new name; `ifExists` as every saving step. `{ path, name, moved, skipped }`. |
|
|
131
|
-
| `
|
|
131
|
+
| `fileDelete` | `file-delete` | Delete one file, **permanently** (no trash; `file-move` to a "processed" folder is the reversible one). Never a folder. `ifMissing`: `fail` (default) or `skip`. `{ path, deleted, skipped }`. |
|
|
132
|
+
| `httpRequest` | `http-request` | Call another system's API: `method`, `path`, `query`, `body`, `headers`, signing in with a saved connection. `{ status, ok, body, headers }`. See [Calling an API](#calling-an-api-http-request). |
|
|
133
|
+
| `spawnProgram`, `dbQuery` | — | **Placeholders.** Empty modules with no `name` or `execute`; `register()` rejects them. |
|
|
132
134
|
|
|
133
135
|
### Connections
|
|
134
136
|
|
|
@@ -248,9 +250,42 @@ A job or a workflow step stores `connectionInfoId`, never a password. `resolveCo
|
|
|
248
250
|
- **`resolver(id, { field, inputs })`** is the host's: a login, or `{ dbType, connection }`. It sees all the inputs, for a companion id such as BI's `dbInfoId`.
|
|
249
251
|
- **An id that resolves to nothing is refused** (`CONNECTION_NOT_FOUND`), never replaced by a literal `connection`. An id with no resolver configured: `NO_CONNECTION_RESOLVER`.
|
|
250
252
|
|
|
253
|
+
## Calling an API: `http-request`
|
|
254
|
+
|
|
255
|
+
```js
|
|
256
|
+
await runAction({ name: 'http-request', input: {
|
|
257
|
+
connectionInfoId: 'bank-api', // resolved by the host to `connection`
|
|
258
|
+
method: 'GET', path: '/statements',
|
|
259
|
+
query: { from: '2026-09-01', to: '2026-09-30' }
|
|
260
|
+
} })
|
|
261
|
+
// → { status: 200, ok: true, body: { rows: [...] }, headers: { 'content-type': … } }
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**The connection** (what a saved API connection resolves to, so no key is ever in a workflow):
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
{ baseUrl: 'https://api.bank.example/v2', headers?: { 'X-Tenant': 'acme' }, auth }
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
| `auth.type` | fields | sends |
|
|
271
|
+
|---|---|---|
|
|
272
|
+
| `none` | | nothing |
|
|
273
|
+
| `apiKey` | `in` (`header` / `query`), `name`, `value` | `X-API-Key: …`, or `?key=…` |
|
|
274
|
+
| `bearer` | `token` | `Authorization: Bearer …` |
|
|
275
|
+
| `basic` | `username`, `password` | `Authorization: Basic …` |
|
|
276
|
+
| `login` | `url` (path or address), `method` (POST), `body`, `tokenPath` (`data.token`), `expiresInPath`, `header`, `prefix` | calls the login first, then `Authorization: Bearer <token>` |
|
|
277
|
+
| `oauth2` | `tokenUrl`, `clientId`, `clientSecret`, `scope`, `audience` | client credentials; then `Authorization: Bearer <access_token>` |
|
|
278
|
+
|
|
279
|
+
- **A token from `login` or `oauth2` is kept in this process** until it runs out (`expires_in` / `expiresInPath`, a minute early; 10 minutes when none is given). A 401 throws it away and the call is tried once more with a fresh one.
|
|
280
|
+
- **Retries:** a network failure, 429 or 5xx is tried again (`retries`, 2), waiting `retryAfterMs` (1 s) and doubling, or what `Retry-After` says. A 4xx other than 401 / 429 is not retried.
|
|
281
|
+
- **A status that is not 2xx fails the step** with `HTTP_<status>` and the start of the answer. `failOnError: false`: it succeeds, so the arrows can branch on `output.status`.
|
|
282
|
+
- **A saved connection signs in to its own address only:** a whole URL on another host is refused (`URL_NOT_ON_CONNECTION`), so the key cannot be sent elsewhere. With no connection, `path` may be a whole `https://` address.
|
|
283
|
+
- **Bodies:** JSON by default; `bodyType: 'form'` or `'text'`. The answer is parsed as JSON when it is JSON, else text.
|
|
284
|
+
- **Not yet:** paging through many pages of results.
|
|
285
|
+
|
|
251
286
|
## File steps, and the folders they may use
|
|
252
287
|
|
|
253
|
-
`file-list`, `file-read` and `file-
|
|
288
|
+
`file-list`, `file-read`, `file-move` and `file-delete` take a path from whoever wrote the workflow, so they work **only inside the folders the install names** (`lib/fileRoots.js`):
|
|
254
289
|
|
|
255
290
|
| | |
|
|
256
291
|
|---|---|
|
|
@@ -477,7 +512,7 @@ await actions.runAction(actions.builtins.dbPush, input, { metaStore: xcfg.metaSt
|
|
|
477
512
|
| name | required? | meaning |
|
|
478
513
|
|---|---|---|
|
|
479
514
|
| `XEPLR_ACTIONS_TMP_DIR` | no | Where `db-fetch` and `email-read` spool NDJSON, email downloads go (unless `outDir`), and the uploader spools batches. Default `<os.tmpdir()>/xeplr-actions`. Files are not cleaned up by `db-fetch` or `email-read`. |
|
|
480
|
-
| `XEPLR_FILE_ROOTS` | for `file-list`, `file-read`, `file-move` outside the temp folder | Absolute folders, comma-separated, that file steps may use. See [File steps](#file-steps-and-the-folders-they-may-use). |
|
|
515
|
+
| `XEPLR_FILE_ROOTS` | for `file-list`, `file-read`, `file-move`, `file-delete` outside the temp folder | Absolute folders, comma-separated, that file steps may use. See [File steps](#file-steps-and-the-folders-they-may-use). |
|
|
481
516
|
| `XCFG_DB_NAME` | for `attachConfig` | The `xeplr_configs` database name. In `configRequiredEnv`. |
|
|
482
517
|
| `XCFG_DB_CONNECTION_INFO_ENCRYPTED` | no | Encrypted server login for `attachConfig`; overrides `XEPLR_DB_CONNECTION`. |
|
|
483
518
|
| `XEPLR_DB_CONNECTION` | for `attachConfig`, unless the above is set | Shared encrypted server login. |
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// file-delete action — remove one file.
|
|
2
|
+
//
|
|
3
|
+
// A file a workflow has finished with: the bank file read and posted, so the
|
|
4
|
+
// next check of the folder does not find it again. PERMANENT — there is no
|
|
5
|
+
// trash to take it back from; file-move into a "processed" folder is the one
|
|
6
|
+
// that can be undone. Only inside the folders file steps may use
|
|
7
|
+
// (lib/fileRoots.js), and only a file, never a folder.
|
|
8
|
+
|
|
9
|
+
var fs = require('fs');
|
|
10
|
+
var fileRoots = require('../../fileRoots');
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
name: 'file-delete',
|
|
14
|
+
description: 'Delete one file, permanently (file-move to a "processed" folder is the reversible one). Returns { path, deleted, skipped }.',
|
|
15
|
+
requires: [],
|
|
16
|
+
inputSchema: [
|
|
17
|
+
{ name: 'path', type: 'string', required: true, order: 1,
|
|
18
|
+
description: 'The file, by absolute path, e.g. {item.path}.' },
|
|
19
|
+
{ name: 'ifMissing', type: 'string', default: 'fail', options: ['fail', 'skip'], order: 2,
|
|
20
|
+
description: 'When there is no such file. fail: the step fails. skip: it goes on, deleted false.' }
|
|
21
|
+
],
|
|
22
|
+
outputSchema: [
|
|
23
|
+
{ name: 'path', type: 'string' }, { name: 'deleted', type: 'boolean' }, { name: 'skipped', type: 'boolean' }
|
|
24
|
+
],
|
|
25
|
+
|
|
26
|
+
execute: async function(ctx) {
|
|
27
|
+
var input = ctx.input || {};
|
|
28
|
+
var file = fileRoots.allowed(input.path, 'The file to delete');
|
|
29
|
+
if (!fs.existsSync(file)) {
|
|
30
|
+
if (input.ifMissing === 'skip') return { path: file, deleted: false, skipped: true };
|
|
31
|
+
var missing = new Error('"' + input.path + '" is not a file that exists.');
|
|
32
|
+
missing.code = 'FILE_NOT_FOUND';
|
|
33
|
+
throw missing;
|
|
34
|
+
}
|
|
35
|
+
if (!fs.statSync(file).isFile()) {
|
|
36
|
+
var folder = new Error('"' + input.path + '" is a folder; file-delete removes one file.');
|
|
37
|
+
folder.code = 'NOT_A_FILE';
|
|
38
|
+
throw folder;
|
|
39
|
+
}
|
|
40
|
+
fs.unlinkSync(file);
|
|
41
|
+
return { path: file, deleted: true, skipped: false };
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -1,3 +1,271 @@
|
|
|
1
|
-
// http-request action —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// http-request action — call another system's API.
|
|
2
|
+
//
|
|
3
|
+
// One call: a method, a path on the API, query values, headers and a body,
|
|
4
|
+
// and the answer back as { status, ok, body, headers }. The body is parsed
|
|
5
|
+
// JSON when the answer says it is JSON, else text.
|
|
6
|
+
//
|
|
7
|
+
// WHERE AND AS WHOM come from a saved connection (`connectionInfoId`), which
|
|
8
|
+
// the host turns into `connection` just before the call (resolveConnections):
|
|
9
|
+
//
|
|
10
|
+
// { baseUrl: 'https://api.bank.example/v2',
|
|
11
|
+
// headers?: { 'X-Tenant': 'acme' },
|
|
12
|
+
// auth: { type: 'none' }
|
|
13
|
+
// | { type: 'apiKey', in: 'header' | 'query', name: 'X-API-Key', value }
|
|
14
|
+
// | { type: 'bearer', token }
|
|
15
|
+
// | { type: 'basic', username, password }
|
|
16
|
+
// | { type: 'login', url, method?, body?, tokenPath, header?, prefix?, expiresInPath? }
|
|
17
|
+
// | { type: 'oauth2', tokenUrl, clientId, clientSecret, scope?, audience? } }
|
|
18
|
+
//
|
|
19
|
+
// so a workflow never holds a key or a password, and a step's recorded input
|
|
20
|
+
// holds only the id. Without a saved connection, `path` may be a whole
|
|
21
|
+
// address (a public API that needs no sign-in).
|
|
22
|
+
//
|
|
23
|
+
// A TOKEN FROM A LOGIN CALL OR OAUTH is fetched once and kept in this process
|
|
24
|
+
// until it runs out (or, with no expiry given, for 10 minutes). A 401 throws
|
|
25
|
+
// it away and the call is tried once more with a fresh one.
|
|
26
|
+
//
|
|
27
|
+
// RETRIES: a network failure, 429 or 5xx is tried again (`retries`, default
|
|
28
|
+
// 2), waiting longer each time, or as long as Retry-After asks. A 4xx other
|
|
29
|
+
// than 401/429 is the API refusing the request and is not retried.
|
|
30
|
+
//
|
|
31
|
+
// A STATUS THAT IS NOT 2xx fails the step (`failOnError`, default true),
|
|
32
|
+
// saying the status and the start of the answer. Off: the step succeeds and
|
|
33
|
+
// the arrows can branch on output.status.
|
|
34
|
+
|
|
35
|
+
var crypto = require('crypto');
|
|
36
|
+
|
|
37
|
+
var TOKEN_DEFAULT_MS = 10 * 60 * 1000;
|
|
38
|
+
var tokens = {}; // key → { value, until }
|
|
39
|
+
|
|
40
|
+
function httpError(message, code, extra) {
|
|
41
|
+
var err = new Error(message);
|
|
42
|
+
err.code = code;
|
|
43
|
+
Object.assign(err, extra || {});
|
|
44
|
+
return err;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); }
|
|
48
|
+
|
|
49
|
+
/** A value at a dotted path in an object: 'data.token' → obj.data.token. */
|
|
50
|
+
function at(obj, path) {
|
|
51
|
+
return String(path || '').split('.').filter(Boolean).reduce(function(o, k) { return o == null ? undefined : o[k]; }, obj);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The full address: a path is joined to the connection's base; a whole URL must stay on it. */
|
|
55
|
+
function addressOf(conn, input) {
|
|
56
|
+
var raw = String(input.path || '').trim();
|
|
57
|
+
var base = conn && conn.baseUrl ? String(conn.baseUrl).replace(/\/+$/, '') : '';
|
|
58
|
+
var url;
|
|
59
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
60
|
+
url = new URL(raw);
|
|
61
|
+
// A saved connection signs in to ITS address only: a whole URL elsewhere
|
|
62
|
+
// would send the key to whoever is there.
|
|
63
|
+
if (base && url.origin !== new URL(base).origin) {
|
|
64
|
+
throw httpError('"' + raw + '" is not on this connection\'s address (' + base + '). Give a path, e.g. /statements.', 'URL_NOT_ON_CONNECTION');
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
if (!base) throw httpError('Give a whole address (https://…), or pick a saved connection and give a path.', 'URL_REQUIRED');
|
|
68
|
+
url = new URL(base + (raw ? '/' + raw.replace(/^\/+/, '') : ''));
|
|
69
|
+
}
|
|
70
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw httpError('Only http and https addresses.', 'URL_REQUIRED');
|
|
71
|
+
Object.keys(input.query || {}).forEach(function(k) {
|
|
72
|
+
var v = input.query[k];
|
|
73
|
+
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
74
|
+
});
|
|
75
|
+
return url;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A body as fetch sends it, and its content type. */
|
|
79
|
+
function bodyOf(input) {
|
|
80
|
+
if (input.body === undefined || input.body === null || input.body === '') return { body: undefined };
|
|
81
|
+
var kind = input.bodyType || (typeof input.body === 'string' ? 'text' : 'json');
|
|
82
|
+
if (kind === 'json') return { body: typeof input.body === 'string' ? input.body : JSON.stringify(input.body), type: 'application/json' };
|
|
83
|
+
if (kind === 'form') {
|
|
84
|
+
var form = new URLSearchParams();
|
|
85
|
+
Object.keys(input.body || {}).forEach(function(k) { form.set(k, String(input.body[k])); });
|
|
86
|
+
return { body: form.toString(), type: 'application/x-www-form-urlencoded' };
|
|
87
|
+
}
|
|
88
|
+
return { body: String(input.body), type: 'text/plain' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readBody(res) {
|
|
92
|
+
var text = await res.text();
|
|
93
|
+
var type = res.headers.get('content-type') || '';
|
|
94
|
+
if (/json/i.test(type) || /^\s*[\[{]/.test(text)) {
|
|
95
|
+
try { return JSON.parse(text); } catch (e) { /* not JSON after all */ }
|
|
96
|
+
}
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cacheKey(conn) {
|
|
101
|
+
return crypto.createHash('sha256').update(JSON.stringify([conn.baseUrl, conn.auth])).digest('hex');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A token from the connection's login call or OAuth, kept until it runs out. */
|
|
105
|
+
async function tokenFor(conn, timeoutMs) {
|
|
106
|
+
var key = cacheKey(conn);
|
|
107
|
+
var kept = tokens[key];
|
|
108
|
+
if (kept && kept.until > Date.now()) return kept.value;
|
|
109
|
+
var auth = conn.auth;
|
|
110
|
+
var res, answer, token, expiresIn;
|
|
111
|
+
if (auth.type === 'oauth2') {
|
|
112
|
+
var form = new URLSearchParams({ grant_type: 'client_credentials', client_id: auth.clientId || '', client_secret: auth.clientSecret || '' });
|
|
113
|
+
if (auth.scope) form.set('scope', auth.scope);
|
|
114
|
+
if (auth.audience) form.set('audience', auth.audience);
|
|
115
|
+
res = await timed(auth.tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: form.toString() }, timeoutMs);
|
|
116
|
+
answer = await readBody(res);
|
|
117
|
+
token = answer && answer.access_token;
|
|
118
|
+
expiresIn = answer && answer.expires_in;
|
|
119
|
+
} else {
|
|
120
|
+
var loginUrl = /^https?:\/\//i.test(auth.url || '') ? auth.url : String(conn.baseUrl || '').replace(/\/+$/, '') + '/' + String(auth.url || '').replace(/^\/+/, '');
|
|
121
|
+
var b = auth.body === undefined ? undefined : (typeof auth.body === 'string' ? auth.body : JSON.stringify(auth.body));
|
|
122
|
+
res = await timed(loginUrl, { method: auth.method || 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: b }, timeoutMs);
|
|
123
|
+
answer = await readBody(res);
|
|
124
|
+
token = at(answer, auth.tokenPath || 'token');
|
|
125
|
+
expiresIn = auth.expiresInPath ? at(answer, auth.expiresInPath) : undefined;
|
|
126
|
+
}
|
|
127
|
+
if (!res.ok || !token) {
|
|
128
|
+
tokens[key] = undefined;
|
|
129
|
+
throw httpError('Signing in to ' + (conn.baseUrl || 'the API') + ' failed: ' + res.status +
|
|
130
|
+
(token ? '' : ', and no token in the answer' + (auth.type === 'login' ? ' at "' + (auth.tokenPath || 'token') + '"' : '')) + '.', 'SIGN_IN_FAILED', { status: res.status });
|
|
131
|
+
}
|
|
132
|
+
// A minute early, so a token never runs out mid-call.
|
|
133
|
+
var lifeMs = Number(expiresIn) > 0 ? Math.max(0, Number(expiresIn) * 1000 - 60000) : TOKEN_DEFAULT_MS;
|
|
134
|
+
tokens[key] = { value: String(token), until: Date.now() + lifeMs };
|
|
135
|
+
return String(token);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function timed(url, init, timeoutMs) {
|
|
139
|
+
var controller = new AbortController();
|
|
140
|
+
var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
|
|
141
|
+
if (timer.unref) timer.unref();
|
|
142
|
+
try {
|
|
143
|
+
return await fetch(url, Object.assign({}, init, { signal: controller.signal }));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
var why = err.name === 'AbortError' ? 'no answer within ' + timeoutMs + ' ms' : (err.cause && err.cause.code) || err.message;
|
|
146
|
+
throw httpError('Could not reach ' + String(url).split('?')[0] + ': ' + why + '.', 'UNREACHABLE', { transient: true });
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Headers for one call: the connection's, the sign-in's, then the step's own. */
|
|
153
|
+
async function headersFor(conn, input, url, timeoutMs) {
|
|
154
|
+
var h = { accept: 'application/json, text/plain;q=0.9, */*;q=0.8' };
|
|
155
|
+
Object.assign(h, (conn && conn.headers) || {});
|
|
156
|
+
var auth = (conn && conn.auth) || { type: 'none' };
|
|
157
|
+
if (auth.type === 'apiKey') {
|
|
158
|
+
if (auth.in === 'query') url.searchParams.set(auth.name || 'api_key', auth.value || '');
|
|
159
|
+
else h[auth.name || 'X-API-Key'] = auth.value || '';
|
|
160
|
+
} else if (auth.type === 'bearer') {
|
|
161
|
+
h.authorization = 'Bearer ' + (auth.token || '');
|
|
162
|
+
} else if (auth.type === 'basic') {
|
|
163
|
+
h.authorization = 'Basic ' + Buffer.from((auth.username || '') + ':' + (auth.password || '')).toString('base64');
|
|
164
|
+
} else if (auth.type === 'login' || auth.type === 'oauth2') {
|
|
165
|
+
var token = await tokenFor(conn, timeoutMs);
|
|
166
|
+
var name = auth.header || 'authorization';
|
|
167
|
+
h[name] = (auth.prefix === undefined ? 'Bearer ' : auth.prefix) + token;
|
|
168
|
+
} else if (auth.type && auth.type !== 'none') {
|
|
169
|
+
throw httpError('"' + auth.type + '" is not a way of signing in. Kinds: none, apiKey, bearer, basic, login, oauth2.', 'AUTH_UNKNOWN');
|
|
170
|
+
}
|
|
171
|
+
Object.assign(h, input.headers || {});
|
|
172
|
+
return h;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** How long to wait before try `n`: Retry-After when the API says, else 1s, 2s, 4s… */
|
|
176
|
+
function waitFor(res, n, baseMs) {
|
|
177
|
+
var after = res && res.headers && res.headers.get('retry-after');
|
|
178
|
+
if (after && !isNaN(Number(after))) return Math.min(60000, Number(after) * 1000);
|
|
179
|
+
return baseMs * Math.pow(2, n - 1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
name: 'http-request',
|
|
184
|
+
description: 'Call another system\'s API: a method, a path, query values, headers and a body. ' +
|
|
185
|
+
'Signs in with a saved connection. Returns { status, ok, body, headers }.',
|
|
186
|
+
requires: [],
|
|
187
|
+
inputSchema: [
|
|
188
|
+
{ name: 'connectionInfoId', type: 'string', order: 1, optionsFrom: 'apis',
|
|
189
|
+
description: 'A saved API connection: its address and how to sign in. Resolved on the server.' },
|
|
190
|
+
{ name: 'connection', type: 'object', order: 2, system: true,
|
|
191
|
+
description: 'Filled from the saved connection: { baseUrl, headers?, auth }.' },
|
|
192
|
+
{ name: 'method', type: 'string', default: 'GET', order: 3, options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
193
|
+
description: 'GET reads; POST sends something new; PUT / PATCH change; DELETE removes.' },
|
|
194
|
+
{ name: 'path', type: 'string', order: 4,
|
|
195
|
+
description: 'Where on the API, e.g. /statements. Or a whole address (https://…) with no saved connection.' },
|
|
196
|
+
{ name: 'query', type: 'object', order: 5,
|
|
197
|
+
description: 'Values for the address, e.g. { "from": "{trigger.window.from|date:YYYY-MM-DD}" }.' },
|
|
198
|
+
{ name: 'body', type: 'object', order: 6,
|
|
199
|
+
description: 'What to send with POST / PUT / PATCH, e.g. { "rows": "{steps.match.output.rows}" }.' },
|
|
200
|
+
{ name: 'bodyType', type: 'string', default: 'json', order: 7, group: 'Advanced', options: ['json', 'form', 'text'],
|
|
201
|
+
description: 'How the body is sent: json, a form, or plain text.' },
|
|
202
|
+
{ name: 'headers', type: 'object', order: 8, group: 'Advanced',
|
|
203
|
+
description: 'Extra headers for this call. The sign-in is added from the connection.' },
|
|
204
|
+
{ name: 'failOnError', type: 'boolean', default: true, order: 9, group: 'Advanced',
|
|
205
|
+
description: 'On: a status that is not 2xx fails the step. Off: the step succeeds and the arrows branch on output.status.' },
|
|
206
|
+
{ name: 'retries', type: 'number', default: 2, order: 10, group: 'Advanced',
|
|
207
|
+
description: 'Tries again on a network failure, 429 or 5xx, waiting longer each time.' },
|
|
208
|
+
{ name: 'retryAfterMs', type: 'number', default: 1000, order: 11, group: 'Advanced',
|
|
209
|
+
description: 'The first wait between tries; it doubles each time, unless the API says Retry-After.' },
|
|
210
|
+
{ name: 'timeoutMs', type: 'number', default: 30000, order: 12, group: 'Advanced',
|
|
211
|
+
description: 'How long to wait for an answer.' }
|
|
212
|
+
],
|
|
213
|
+
outputSchema: [
|
|
214
|
+
{ name: 'status', type: 'number', description: 'The HTTP status, e.g. 200.' },
|
|
215
|
+
{ name: 'ok', type: 'boolean', description: 'Whether it was 2xx.' },
|
|
216
|
+
{ name: 'body', type: 'object', description: 'The answer: parsed JSON, or text.' },
|
|
217
|
+
{ name: 'headers', type: 'object', description: 'The answer\'s content-type, and any rate-limit or paging headers.' }
|
|
218
|
+
],
|
|
219
|
+
|
|
220
|
+
execute: async function(ctx) {
|
|
221
|
+
var input = ctx.input || {};
|
|
222
|
+
var conn = input.connection || null;
|
|
223
|
+
if (input.connectionInfoId && !conn) throw httpError('The saved connection was not resolved.', 'CONNECTION_NOT_FOUND');
|
|
224
|
+
var method = String(input.method || 'GET').toUpperCase();
|
|
225
|
+
var timeoutMs = Math.max(1000, Number(input.timeoutMs) || 30000);
|
|
226
|
+
var retries = Math.max(0, Math.min(10, input.retries == null ? 2 : Number(input.retries)));
|
|
227
|
+
var baseWait = Math.max(0, input.retryAfterMs == null ? 1000 : Number(input.retryAfterMs));
|
|
228
|
+
var sent = method === 'GET' || method === 'DELETE' ? { body: undefined } : bodyOf(input);
|
|
229
|
+
|
|
230
|
+
var refreshed = false;
|
|
231
|
+
var res;
|
|
232
|
+
for (var attempt = 0; ; attempt++) {
|
|
233
|
+
var url = addressOf(conn, input);
|
|
234
|
+
var headers = await headersFor(conn, input, url, timeoutMs);
|
|
235
|
+
if (sent.type && !Object.keys(headers).some(function(k) { return k.toLowerCase() === 'content-type'; })) headers['content-type'] = sent.type;
|
|
236
|
+
try {
|
|
237
|
+
res = await timed(url.toString(), { method: method, headers: headers, body: sent.body }, timeoutMs);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err.transient && attempt < retries) { await sleep(baseWait * Math.pow(2, attempt)); continue; }
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
// A kept token the API no longer takes: throw it away and sign in again, once.
|
|
243
|
+
if (res.status === 401 && conn && conn.auth && (conn.auth.type === 'login' || conn.auth.type === 'oauth2') && !refreshed) {
|
|
244
|
+
tokens[cacheKey(conn)] = undefined;
|
|
245
|
+
refreshed = true;
|
|
246
|
+
attempt--;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
250
|
+
await res.text().catch(function() {});
|
|
251
|
+
await sleep(waitFor(res, attempt + 1, baseWait));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
var body = await readBody(res);
|
|
258
|
+
var kept = {};
|
|
259
|
+
res.headers.forEach(function(v, k) {
|
|
260
|
+
if (k === 'content-type' || k === 'link' || /^x-(rate|ratelimit|total|page|next)/i.test(k) || /^ratelimit/i.test(k)) kept[k] = v;
|
|
261
|
+
});
|
|
262
|
+
if (!res.ok && input.failOnError !== false) {
|
|
263
|
+
var said = typeof body === 'string' ? body : JSON.stringify(body);
|
|
264
|
+
throw httpError(method + ' ' + url.pathname + ' answered ' + res.status + (said ? ': ' + said.slice(0, 300) : '') + '.', 'HTTP_' + res.status, { status: res.status });
|
|
265
|
+
}
|
|
266
|
+
return { status: res.status, ok: res.ok, body: body, headers: kept };
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
// For tests: forget every kept token.
|
|
270
|
+
_forgetTokens: function() { tokens = {}; }
|
|
271
|
+
};
|
package/lib/builtins/index.js
CHANGED
|
@@ -48,6 +48,7 @@ module.exports = {
|
|
|
48
48
|
// File actions — factory-shaped for the same reason (meta store binding).
|
|
49
49
|
fileUpload: require('./file/upload'),
|
|
50
50
|
fileMove: require('./file/move'),
|
|
51
|
+
fileDelete: require('./file/delete'),
|
|
51
52
|
fileList: require('./file/list'),
|
|
52
53
|
fileRead: require('./file/read'),
|
|
53
54
|
|
package/package.json
CHANGED