@tikoci/centrs 0.1.6 → 0.1.8-next.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/CHANGELOG.md +342 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +10 -6
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +135 -42
- package/commands/api/examples.md +275 -19
- package/commands/devices/README.md +21 -4
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +60 -18
- package/commands/execute/examples.md +224 -0
- package/commands/explain/README.md +42 -5
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +442 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +29 -21
- package/docs/CONSTITUTION.md +41 -16
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +603 -194
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/devices.ts +16 -3
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +216 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +70 -1
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +65 -27
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +67 -1
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +148 -37
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +14 -9
- package/src/execute.ts +128 -116
- package/src/explain/catalog.ts +64 -16
- package/src/explain/menus.ts +27 -6
- package/src/explain.ts +6 -6
- package/src/index.ts +31 -1
- package/src/mcp/tools.ts +10 -15
- package/src/protocols/adapter.ts +235 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +12 -7
- package/src/protocols/native-api.ts +252 -73
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +84 -18
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +741 -109
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
package/src/api.ts
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The structured middle of the verb trichotomy (see `commands/api/README.md`):
|
|
5
5
|
* one command per operation (no code blocks), structured input **and** output,
|
|
6
|
-
* validated through `/console/inspect
|
|
6
|
+
* validated through `/console/inspect` (or live `:parse` for `/execute` scripts),
|
|
7
|
+
* runnable over REST or the native API. The
|
|
7
8
|
* orchestrator is transport-agnostic — it normalizes the endpoint, resolves the
|
|
8
|
-
* method→verb map, runs the
|
|
9
|
+
* method→verb map, runs the validation and write-confirmation gates, then
|
|
9
10
|
* hands a normalized {@link ProtocolApiRequest} to the adapter. REST vs native
|
|
10
11
|
* mechanics (id-in-URL vs `=.id=`, `.query` vs `?`-words) live in the adapter.
|
|
11
12
|
*
|
|
12
13
|
* Grounded RouterOS facts this depends on live in `commands/api/AGENTS.md`
|
|
13
|
-
* (CHR 7.23.1
|
|
14
|
-
*
|
|
14
|
+
* (CHR 7.23.1 and command-stream examples). Streaming and fan-out reuse this
|
|
15
|
+
* structured validation, confirmation, and result-envelope surface.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import type {
|
|
@@ -26,16 +27,31 @@ import type {
|
|
|
26
27
|
ValidationTrace,
|
|
27
28
|
Warning,
|
|
28
29
|
} from "./core/envelope.ts";
|
|
29
|
-
import { buildTip } from "./core/envelope.ts";
|
|
30
|
+
import { buildTip, notReachedReason } from "./core/envelope.ts";
|
|
30
31
|
import {
|
|
31
32
|
inspectArgumentNames,
|
|
32
33
|
inspectChildrenOrEmpty,
|
|
34
|
+
inspectWhereBooleans,
|
|
33
35
|
isCommandNode,
|
|
34
36
|
pathTokens,
|
|
35
37
|
} from "./core/inspect.ts";
|
|
38
|
+
import {
|
|
39
|
+
compileQueryWords,
|
|
40
|
+
type ParsedQuery,
|
|
41
|
+
parseQueries,
|
|
42
|
+
} from "./core/query.ts";
|
|
43
|
+
import { mapRouterOsResultError } from "./core/routeros-errors.ts";
|
|
36
44
|
import { toYaml } from "./core/yaml.ts";
|
|
37
|
-
import {
|
|
38
|
-
|
|
45
|
+
import {
|
|
46
|
+
CentrsError,
|
|
47
|
+
formatCentrsErrorLine,
|
|
48
|
+
formatCentrsErrorText,
|
|
49
|
+
serializeCentrsError,
|
|
50
|
+
} from "./errors.ts";
|
|
51
|
+
import {
|
|
52
|
+
promptForWriteConfirmation,
|
|
53
|
+
validateRouterOsScript,
|
|
54
|
+
} from "./execute.ts";
|
|
39
55
|
import {
|
|
40
56
|
assertOfflineSyntax,
|
|
41
57
|
isOfflineGateRejection,
|
|
@@ -46,6 +62,8 @@ import {
|
|
|
46
62
|
createProtocolAdapter,
|
|
47
63
|
type ProtocolAdapter,
|
|
48
64
|
type ProtocolApiRequest,
|
|
65
|
+
type ProtocolApiResult,
|
|
66
|
+
type ProtocolStreamReply,
|
|
49
67
|
plannedProtocols,
|
|
50
68
|
type RouterOsProtocol,
|
|
51
69
|
} from "./protocols/index.ts";
|
|
@@ -70,7 +88,7 @@ import {
|
|
|
70
88
|
toCoreSource,
|
|
71
89
|
} from "./resolver/index.ts";
|
|
72
90
|
|
|
73
|
-
export const apiOutputFormats = ["json", "yaml", "text"] as const;
|
|
91
|
+
export const apiOutputFormats = ["json", "ndjson", "yaml", "text"] as const;
|
|
74
92
|
export type ApiOutputFormat = (typeof apiOutputFormats)[number];
|
|
75
93
|
|
|
76
94
|
/** HTTP methods `api` honors literally against RouterOS's REST verb map. */
|
|
@@ -95,7 +113,7 @@ export interface ApiRequest {
|
|
|
95
113
|
data?: string;
|
|
96
114
|
/** `--input` raw JSON body (file/stdin content already read). Mutually exclusive with `fields` / `data`. */
|
|
97
115
|
inputBody?: string;
|
|
98
|
-
/** `--query` / `--filter` expressions
|
|
116
|
+
/** `--query` / `--filter` expressions, written as after `print where` (`src/core/query.ts`); several are AND-ed. */
|
|
99
117
|
query?: readonly string[];
|
|
100
118
|
/** `--raw-query` verbatim RouterOS query words (no leading `?`). */
|
|
101
119
|
rawQuery?: readonly string[];
|
|
@@ -107,11 +125,15 @@ export interface ApiRequest {
|
|
|
107
125
|
* error contract (#154).
|
|
108
126
|
*/
|
|
109
127
|
raw?: boolean;
|
|
110
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
128
|
+
/**
|
|
129
|
+
* `--stream`: deliver the literal command's native replies incrementally.
|
|
130
|
+
* It never changes the command: a GET streams one print. To follow changes,
|
|
131
|
+
* request the menu's `listen` (`ip/address/listen`), which implies it.
|
|
132
|
+
*/
|
|
133
|
+
stream?: boolean;
|
|
134
|
+
/** `--duration`: bound a native reply stream. */
|
|
113
135
|
duration?: string;
|
|
114
|
-
/** `--count`: bound a
|
|
136
|
+
/** `--count`: bound a native reply stream to N rows. */
|
|
115
137
|
count?: number;
|
|
116
138
|
via?: string;
|
|
117
139
|
host?: string;
|
|
@@ -154,7 +176,7 @@ export interface NormalizedApiEndpoint {
|
|
|
154
176
|
path: string;
|
|
155
177
|
/** Trailing `*id` segment, when the endpoint addressed one row. */
|
|
156
178
|
id?: string;
|
|
157
|
-
/** True when the endpoint's terminal segment was `listen
|
|
179
|
+
/** True when the endpoint's terminal segment was `listen`: the menu's `listen` command, which implies `--stream`. */
|
|
158
180
|
listen: boolean;
|
|
159
181
|
}
|
|
160
182
|
|
|
@@ -167,7 +189,10 @@ export interface ApiRequestSummary {
|
|
|
167
189
|
/** `null` when the method was invalid and no verb could be mapped (error path). */
|
|
168
190
|
verb: ApiVerb | null;
|
|
169
191
|
write: boolean;
|
|
192
|
+
/** The request is the menu's `listen` command (a `/listen` endpoint). */
|
|
170
193
|
listen: boolean;
|
|
194
|
+
/** Replies are delivered incrementally (`--stream`, or implied by `listen`). */
|
|
195
|
+
stream: boolean;
|
|
171
196
|
yes: boolean;
|
|
172
197
|
validate: boolean;
|
|
173
198
|
raw: boolean;
|
|
@@ -176,20 +201,41 @@ export interface ApiRequestSummary {
|
|
|
176
201
|
proplist?: readonly string[];
|
|
177
202
|
}
|
|
178
203
|
|
|
179
|
-
/** Why
|
|
204
|
+
/** Why an incremental native command stream ended. */
|
|
180
205
|
export type ApiStreamStopReason =
|
|
206
|
+
| "completed"
|
|
207
|
+
| "routeros-error"
|
|
181
208
|
| "count-reached"
|
|
182
209
|
| "duration-elapsed"
|
|
183
210
|
| "interrupted"
|
|
184
211
|
| "transport-error";
|
|
185
212
|
|
|
186
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Per-line marker on `--stream` NDJSON output: a leading `notice`, one wire
|
|
215
|
+
* reply, or the terminating summary. A `notice` (`data: null`, not counted)
|
|
216
|
+
* comes first only when the request has advice in `tips` — what RouterOS will
|
|
217
|
+
* not report for it — so even a watch that never gets a reply sees it. A frame's `reply` is `re` (a row; `data` is the row) or
|
|
218
|
+
* `empty` (RouterOS `!empty`: zero rows; `data` is `null`). `afterStop` marks a
|
|
219
|
+
* reply that arrived after centrs stopped the stream locally, such as the
|
|
220
|
+
* `!empty` a cancelled listen sends; it is not evidence of an empty table.
|
|
221
|
+
*/
|
|
187
222
|
export type ApiStreamMeta =
|
|
188
|
-
| { kind: "
|
|
223
|
+
| { kind: "notice" }
|
|
224
|
+
| {
|
|
225
|
+
kind: "frame";
|
|
226
|
+
index: number;
|
|
227
|
+
reply: "re" | "empty";
|
|
228
|
+
afterStop?: true;
|
|
229
|
+
}
|
|
189
230
|
| {
|
|
190
231
|
kind: "summary";
|
|
191
232
|
stopReason: ApiStreamStopReason;
|
|
233
|
+
/** Every reply line: `rows + empty`. */
|
|
192
234
|
frames: number;
|
|
235
|
+
/** `!re` rows. `--count` counts these. */
|
|
236
|
+
rows: number;
|
|
237
|
+
/** `!empty` replies. */
|
|
238
|
+
empty: number;
|
|
193
239
|
durationMs: number;
|
|
194
240
|
};
|
|
195
241
|
|
|
@@ -197,7 +243,11 @@ export type ApiStreamMeta =
|
|
|
197
243
|
export interface ApiStreamSummary {
|
|
198
244
|
stopReason: ApiStreamStopReason;
|
|
199
245
|
frames: number;
|
|
246
|
+
rows: number;
|
|
247
|
+
empty: number;
|
|
200
248
|
durationMs: number;
|
|
249
|
+
/** Attributes from a received terminal `!done` (for example `ret`). */
|
|
250
|
+
done?: Record<string, string>;
|
|
201
251
|
}
|
|
202
252
|
|
|
203
253
|
export interface ApiOperationMeta {
|
|
@@ -227,14 +277,25 @@ export interface ResolvedApiRequest {
|
|
|
227
277
|
verb: ApiVerb;
|
|
228
278
|
/** A `/execute` script-run (CLI string, not a path) — inspect gate is not-applicable. */
|
|
229
279
|
scriptMode: boolean;
|
|
280
|
+
/** The menu's `listen` command was requested (a `/listen` endpoint). */
|
|
230
281
|
listen: boolean;
|
|
282
|
+
/** Deliver replies incrementally (`--stream`, or implied by `listen`). */
|
|
283
|
+
stream: boolean;
|
|
231
284
|
/** Resolved JSON body (from `-f` / `-d` / `--input`). */
|
|
232
285
|
body: Record<string, string>;
|
|
233
|
-
/**
|
|
286
|
+
/**
|
|
287
|
+
* REST `.query` words (no `?` prefix), structured then raw, in order. A bare
|
|
288
|
+
* `--query` name reads "is set" here until validation asks the device which
|
|
289
|
+
* bare names are booleans ({@link withQueryBooleans}).
|
|
290
|
+
*/
|
|
234
291
|
query: readonly string[];
|
|
292
|
+
/** The parsed `--query` expressions, when any were given. */
|
|
293
|
+
structuredQuery?: ParsedQuery;
|
|
294
|
+
/** `--raw-query` words, verbatim. */
|
|
295
|
+
rawQuery: readonly string[];
|
|
235
296
|
proplist: readonly string[];
|
|
236
297
|
raw: boolean;
|
|
237
|
-
/** `--count`: stop a `--stream` after N
|
|
298
|
+
/** `--count`: stop a `--stream` after N `!re` rows (`!empty` does not count). */
|
|
238
299
|
count?: number;
|
|
239
300
|
/** `--duration` parsed to ms: stop a `--stream` after this wall-clock window. */
|
|
240
301
|
durationMs?: number;
|
|
@@ -289,10 +350,16 @@ export async function apiEnvelope(
|
|
|
289
350
|
}
|
|
290
351
|
|
|
291
352
|
export async function runResolvedApi(
|
|
292
|
-
|
|
353
|
+
request: ResolvedApiRequest,
|
|
293
354
|
trace: ValidationTrace = {},
|
|
294
355
|
): Promise<ApiSuccessEnvelope> {
|
|
295
|
-
|
|
356
|
+
let resolved = request;
|
|
357
|
+
assertStreamCapability(resolved);
|
|
358
|
+
// Validate the complete request shape before constructing a backend or
|
|
359
|
+
// running either validation stage. In particular, `/execute` must reject
|
|
360
|
+
// extra body fields deterministically instead of letting live `:parse`
|
|
361
|
+
// surface an unrelated transport or authentication failure first.
|
|
362
|
+
const protocolRequest = buildProtocolApiRequest(resolved);
|
|
296
363
|
const backend = createProtocolAdapter({
|
|
297
364
|
protocol: resolved.via.value,
|
|
298
365
|
host: resolved.target.host,
|
|
@@ -309,18 +376,28 @@ export async function runResolvedApi(
|
|
|
309
376
|
let validation: EnvelopeValidationMeta;
|
|
310
377
|
let tips: Tip[] = [];
|
|
311
378
|
try {
|
|
379
|
+
// Recorded the moment the gate settles, so a failure in the boolean probe
|
|
380
|
+
// or the request below reports the gate that passed rather than a guess.
|
|
312
381
|
if (resolved.validate.value) {
|
|
313
382
|
const result = await validateApiRequest(resolved, backend, trace);
|
|
314
383
|
validation = result.validation;
|
|
384
|
+
trace.validation = validation;
|
|
315
385
|
tips = result.tips;
|
|
386
|
+
resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
|
|
316
387
|
} else {
|
|
317
388
|
validation = disabledApiValidationMeta(resolved);
|
|
389
|
+
trace.validation = validation;
|
|
318
390
|
}
|
|
319
|
-
// Recorded BEFORE the request, so a transport drop or RouterOS fault below
|
|
320
|
-
// reports the gate that actually settled rather than a guess.
|
|
321
|
-
trace.validation = validation;
|
|
322
391
|
|
|
323
|
-
const result = await backend.apiRequest(
|
|
392
|
+
const result = await backend.apiRequest(
|
|
393
|
+
resolved === request
|
|
394
|
+
? protocolRequest
|
|
395
|
+
: buildProtocolApiRequest(resolved),
|
|
396
|
+
);
|
|
397
|
+
const routerOsFailure = apiRouterOsFailureFromResult(resolved, result);
|
|
398
|
+
if (routerOsFailure) {
|
|
399
|
+
throw routerOsFailure;
|
|
400
|
+
}
|
|
324
401
|
return {
|
|
325
402
|
ok: true,
|
|
326
403
|
data: result.data,
|
|
@@ -334,27 +411,58 @@ export async function runResolvedApi(
|
|
|
334
411
|
}
|
|
335
412
|
|
|
336
413
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
414
|
+
* A `/execute` run that RouterOS *transported* successfully but *rejected* at
|
|
415
|
+
* runtime.
|
|
416
|
+
*
|
|
417
|
+
* `as-string` makes the console's own text the reply body, so a rejected
|
|
418
|
+
* script comes back as an ordinary 200/`!done` carrying `no such item`. Both
|
|
419
|
+
* adapters surface that text as `data`, so without this the caller-addressed
|
|
420
|
+
* `/execute` would report a failure as `ok: true` — exactly the shape
|
|
421
|
+
* `execute` normalizes in `routerOsFailureFromResult`. Structured path
|
|
422
|
+
* requests are untouched: their faults already arrive as HTTP >=400 or a
|
|
423
|
+
* `!trap`, and their `data` is a record, not console text.
|
|
346
424
|
*/
|
|
347
|
-
|
|
425
|
+
function apiRouterOsFailureFromResult(
|
|
426
|
+
resolved: ResolvedApiRequest,
|
|
427
|
+
result: ProtocolApiResult,
|
|
428
|
+
): CentrsError | undefined {
|
|
429
|
+
if (!resolved.scriptMode || typeof result.data !== "string") {
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
const via = resolved.via.value;
|
|
433
|
+
return mapRouterOsResultError(result.data, {
|
|
434
|
+
transport:
|
|
435
|
+
via === "rest-api" || via === "mac-telnet" || via === "ssh"
|
|
436
|
+
? via
|
|
437
|
+
: "native-api",
|
|
438
|
+
context: { via },
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The `--stream` path: an async generator yielding one {@link ApiEnvelope} per
|
|
444
|
+
* native reply (`!re` row or `!empty`) to the literal command, then a
|
|
445
|
+
* terminating summary envelope (`data` = {@link ApiStreamSummary}). It never
|
|
446
|
+
* rewrites the command; a `<menu>/listen` endpoint is how to follow changes.
|
|
447
|
+
* Streaming is native-api only — a `rest-api` (or otherwise unresolved)
|
|
448
|
+
* transport yields a single error envelope. `--count`/`--duration` and
|
|
449
|
+
* `externalSignal` (Ctrl-C) bound it. Errors before the first frame
|
|
450
|
+
* (resolution, validation, confirmation, capability) yield one error envelope
|
|
451
|
+
* and stop — the CLI keys its exit code on that first envelope. `onListening`
|
|
452
|
+
* fires once the command is on the wire (a real barrier for callers that must
|
|
453
|
+
* act only after that point).
|
|
454
|
+
*/
|
|
455
|
+
export async function* apiStream(
|
|
348
456
|
request: ApiRequest,
|
|
349
457
|
env: Record<string, string | undefined> = Bun.env,
|
|
350
458
|
externalSignal?: AbortSignal,
|
|
351
459
|
onListening?: () => void,
|
|
352
460
|
): AsyncGenerator<ApiEnvelope, void, void> {
|
|
353
|
-
// `
|
|
354
|
-
// request: otherwise a caller that omits it
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
const streamRequest: ApiRequest = { ...request,
|
|
461
|
+
// `apiStream` is inherently the streaming surface, so force `stream` on the
|
|
462
|
+
// request: otherwise a caller that omits it would resolve `via` to the
|
|
463
|
+
// rest-api default and be rejected. An explicit `--via rest-api` still
|
|
464
|
+
// surfaces capability-unsupported below.
|
|
465
|
+
const streamRequest: ApiRequest = { ...request, stream: true };
|
|
358
466
|
let resolved: ResolvedApiRequest | undefined;
|
|
359
467
|
try {
|
|
360
468
|
resolved = await resolveApiRequest(streamRequest, env);
|
|
@@ -362,15 +470,16 @@ export async function* apiListen(
|
|
|
362
470
|
if (resolved.via.value !== "native-api") {
|
|
363
471
|
throw new CentrsError({
|
|
364
472
|
code: "transport/capability-unsupported",
|
|
365
|
-
summary: "
|
|
473
|
+
summary: "Incremental `--stream` replies require native-api.",
|
|
366
474
|
remediation:
|
|
367
|
-
"
|
|
368
|
-
context: { via: resolved.via.value, capability: "
|
|
475
|
+
"Use `--via native-api`, or drop `--stream` for a bounded one-shot response.",
|
|
476
|
+
context: { via: resolved.via.value, capability: "stream" },
|
|
369
477
|
});
|
|
370
478
|
}
|
|
371
479
|
} catch (error) {
|
|
480
|
+
// Every failure here comes before stage 1, so an empty trace says so.
|
|
372
481
|
yield resolved
|
|
373
|
-
? buildApiErrorEnvelopeFromResolved(resolved, error)
|
|
482
|
+
? buildApiErrorEnvelopeFromResolved(resolved, error, {})
|
|
374
483
|
: buildApiErrorEnvelope(streamRequest, error, env);
|
|
375
484
|
return;
|
|
376
485
|
}
|
|
@@ -378,35 +487,62 @@ export async function* apiListen(
|
|
|
378
487
|
}
|
|
379
488
|
|
|
380
489
|
async function* streamResolvedApi(
|
|
381
|
-
|
|
490
|
+
request: ResolvedApiRequest,
|
|
382
491
|
externalSignal?: AbortSignal,
|
|
383
492
|
onListening?: () => void,
|
|
384
493
|
): AsyncGenerator<ApiEnvelope, void, void> {
|
|
385
|
-
const backend = adapterForResolved(
|
|
494
|
+
const backend = adapterForResolved(request);
|
|
386
495
|
|
|
496
|
+
let resolved = request;
|
|
387
497
|
let validation: EnvelopeValidationMeta;
|
|
498
|
+
const trace: ValidationTrace = {};
|
|
388
499
|
try {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
500
|
+
if (resolved.validate.value) {
|
|
501
|
+
const result = await validateApiRequest(resolved, backend, trace);
|
|
502
|
+
validation = result.validation;
|
|
503
|
+
// The boolean probe below runs after the gate passed (GH#435).
|
|
504
|
+
trace.validation = validation;
|
|
505
|
+
resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
|
|
506
|
+
} else {
|
|
507
|
+
validation = disabledValidationMeta();
|
|
508
|
+
}
|
|
392
509
|
} catch (error) {
|
|
393
510
|
await backend.close();
|
|
394
|
-
yield buildApiErrorEnvelopeFromResolved(resolved, error);
|
|
511
|
+
yield buildApiErrorEnvelopeFromResolved(resolved, error, trace);
|
|
395
512
|
return;
|
|
396
513
|
}
|
|
397
514
|
|
|
515
|
+
// Request-shape advice is known before the first reply: deliver it first,
|
|
516
|
+
// and keep it on whichever terminal summary follows (#409 review).
|
|
517
|
+
const tips = streamTips(resolved);
|
|
398
518
|
const controller = new AbortController();
|
|
399
519
|
const startedAt = Date.now();
|
|
400
|
-
|
|
520
|
+
const counts = { frames: 0, rows: 0, empty: 0 };
|
|
401
521
|
let stopReason: ApiStreamStopReason | undefined;
|
|
522
|
+
let cancelUnacknowledged = false;
|
|
523
|
+
let done: Record<string, string> | undefined;
|
|
524
|
+
const cancellationWarnings = (): Warning[] =>
|
|
525
|
+
cancelUnacknowledged
|
|
526
|
+
? [
|
|
527
|
+
{
|
|
528
|
+
code: "transport/cancel-unacknowledged",
|
|
529
|
+
message: `RouterOS did not acknowledge /cancel within ${resolved.timeoutMs.value}ms; centrs closed the session locally.`,
|
|
530
|
+
context: { timeoutMs: resolved.timeoutMs.value },
|
|
531
|
+
},
|
|
532
|
+
]
|
|
533
|
+
: [];
|
|
402
534
|
let durationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
535
|
+
// The first stop cause wins: the cancel grace window keeps the loop alive
|
|
536
|
+
// after a stop, so a later timer or frame must not relabel it.
|
|
403
537
|
const onExternalAbort = (): void => {
|
|
538
|
+
if (done !== undefined) return;
|
|
404
539
|
stopReason ??= "interrupted";
|
|
540
|
+
clearTimeout(durationTimer);
|
|
405
541
|
controller.abort();
|
|
406
542
|
};
|
|
407
543
|
if (resolved.durationMs !== undefined) {
|
|
408
544
|
durationTimer = setTimeout(() => {
|
|
409
|
-
stopReason
|
|
545
|
+
stopReason ??= "duration-elapsed";
|
|
410
546
|
controller.abort();
|
|
411
547
|
}, resolved.durationMs);
|
|
412
548
|
}
|
|
@@ -419,39 +555,69 @@ async function* streamResolvedApi(
|
|
|
419
555
|
}
|
|
420
556
|
|
|
421
557
|
try {
|
|
558
|
+
// A consumer may return immediately after this notice; keep it inside
|
|
559
|
+
// the session cleanup scope, just like every wire reply.
|
|
560
|
+
if (tips.length > 0) {
|
|
561
|
+
yield streamNoticeEnvelope(resolved, validation, tips);
|
|
562
|
+
}
|
|
422
563
|
const protocolRequest = buildProtocolApiRequest(resolved);
|
|
423
|
-
for await (const
|
|
564
|
+
for await (const reply of backend.stream(protocolRequest, {
|
|
424
565
|
signal: controller.signal,
|
|
425
566
|
onListening,
|
|
567
|
+
onCancelUnacknowledged: () => {
|
|
568
|
+
cancelUnacknowledged = true;
|
|
569
|
+
},
|
|
570
|
+
onDone: (attributes) => {
|
|
571
|
+
done = attributes;
|
|
572
|
+
clearTimeout(durationTimer);
|
|
573
|
+
},
|
|
426
574
|
})) {
|
|
427
|
-
frames += 1;
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
575
|
+
counts.frames += 1;
|
|
576
|
+
if (reply.type === "empty") counts.empty += 1;
|
|
577
|
+
else counts.rows += 1;
|
|
578
|
+
yield streamFrameEnvelope(resolved, validation, reply, counts.frames);
|
|
579
|
+
// `--count` counts rows only: an empty tick must never use up the
|
|
580
|
+
// count before the data it is waiting for arrives.
|
|
581
|
+
if (
|
|
582
|
+
reply.type === "re" &&
|
|
583
|
+
resolved.count !== undefined &&
|
|
584
|
+
counts.rows >= resolved.count
|
|
585
|
+
) {
|
|
586
|
+
stopReason ??= "count-reached";
|
|
431
587
|
break;
|
|
432
588
|
}
|
|
433
589
|
}
|
|
590
|
+
const routerOsFailure = apiRouterOsFailureFromResult(resolved, {
|
|
591
|
+
data: done?.["ret"],
|
|
592
|
+
});
|
|
593
|
+
if (routerOsFailure) throw routerOsFailure;
|
|
434
594
|
yield streamSummaryEnvelope(
|
|
435
595
|
resolved,
|
|
436
596
|
validation,
|
|
437
|
-
stopReason ?? "
|
|
438
|
-
|
|
597
|
+
stopReason ?? "completed",
|
|
598
|
+
counts,
|
|
439
599
|
Date.now() - startedAt,
|
|
600
|
+
tips,
|
|
601
|
+
cancellationWarnings(),
|
|
602
|
+
done,
|
|
440
603
|
);
|
|
441
604
|
} catch (error) {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
605
|
+
const envelope = buildApiErrorEnvelopeFromResolved(resolved, error);
|
|
606
|
+
envelope.meta.validation = validation;
|
|
607
|
+
envelope.warnings = [...envelope.warnings, ...cancellationWarnings()];
|
|
608
|
+
envelope.tips = [...envelope.tips, ...tips];
|
|
609
|
+
if (envelope.meta.operation) {
|
|
610
|
+
envelope.meta.operation.stream = {
|
|
611
|
+
kind: "summary",
|
|
612
|
+
stopReason: envelope.error.code.startsWith("routeros/")
|
|
613
|
+
? "routeros-error"
|
|
614
|
+
: "transport-error",
|
|
615
|
+
...counts,
|
|
616
|
+
durationMs: Date.now() - startedAt,
|
|
617
|
+
};
|
|
618
|
+
envelope.meta.operation.objectCount = counts.rows;
|
|
619
|
+
}
|
|
620
|
+
yield envelope;
|
|
455
621
|
} finally {
|
|
456
622
|
if (durationTimer !== undefined) {
|
|
457
623
|
clearTimeout(durationTimer);
|
|
@@ -464,52 +630,114 @@ async function* streamResolvedApi(
|
|
|
464
630
|
function streamFrameEnvelope(
|
|
465
631
|
resolved: ResolvedApiRequest,
|
|
466
632
|
validation: EnvelopeValidationMeta,
|
|
467
|
-
|
|
633
|
+
reply: ProtocolStreamReply,
|
|
468
634
|
index: number,
|
|
469
635
|
): ApiSuccessEnvelope {
|
|
470
|
-
const
|
|
471
|
-
meta
|
|
472
|
-
|
|
636
|
+
const data = reply.type === "empty" ? null : reply.attributes;
|
|
637
|
+
const meta = metaFromResolved(resolved, validation, data);
|
|
638
|
+
meta.operation.stream = {
|
|
639
|
+
kind: "frame",
|
|
640
|
+
index,
|
|
641
|
+
reply: reply.type,
|
|
642
|
+
...(reply.afterStop ? { afterStop: true as const } : {}),
|
|
643
|
+
};
|
|
644
|
+
return { ok: true, data, warnings: [], tips: [], meta };
|
|
473
645
|
}
|
|
474
646
|
|
|
475
|
-
|
|
476
|
-
* A mid-stream failure rendered as a stream frame: the error envelope carries
|
|
477
|
-
* `meta.operation.stream.kind="frame"` (index after the last good frame) so
|
|
478
|
-
* stream consumers can place it on the timeline rather than mistaking it for a
|
|
479
|
-
* non-stream envelope.
|
|
480
|
-
*/
|
|
481
|
-
function streamErrorFrameEnvelope(
|
|
647
|
+
function streamNoticeEnvelope(
|
|
482
648
|
resolved: ResolvedApiRequest,
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
):
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
return envelope;
|
|
649
|
+
validation: EnvelopeValidationMeta,
|
|
650
|
+
tips: Tip[],
|
|
651
|
+
): ApiSuccessEnvelope {
|
|
652
|
+
const meta = metaFromResolved(resolved, validation, null);
|
|
653
|
+
meta.operation.stream = { kind: "notice" };
|
|
654
|
+
return { ok: true, data: null, warnings: [], tips, meta };
|
|
491
655
|
}
|
|
492
656
|
|
|
493
657
|
function streamSummaryEnvelope(
|
|
494
658
|
resolved: ResolvedApiRequest,
|
|
495
659
|
validation: EnvelopeValidationMeta,
|
|
496
660
|
stopReason: ApiStreamStopReason,
|
|
497
|
-
frames: number,
|
|
661
|
+
counts: { frames: number; rows: number; empty: number },
|
|
498
662
|
durationMs: number,
|
|
663
|
+
tips: Tip[],
|
|
664
|
+
streamWarnings: readonly Warning[] = [],
|
|
665
|
+
done?: Record<string, string>,
|
|
499
666
|
): ApiSuccessEnvelope {
|
|
500
|
-
const summary: ApiStreamSummary = {
|
|
667
|
+
const summary: ApiStreamSummary = {
|
|
668
|
+
stopReason,
|
|
669
|
+
...counts,
|
|
670
|
+
durationMs,
|
|
671
|
+
...(done && Object.keys(done).length > 0 ? { done } : {}),
|
|
672
|
+
};
|
|
501
673
|
const meta = metaFromResolved(resolved, validation, summary);
|
|
502
|
-
meta.operation.objectCount =
|
|
503
|
-
meta.operation.stream = {
|
|
674
|
+
meta.operation.objectCount = counts.rows;
|
|
675
|
+
meta.operation.stream = {
|
|
676
|
+
kind: "summary",
|
|
677
|
+
stopReason,
|
|
678
|
+
...counts,
|
|
679
|
+
durationMs,
|
|
680
|
+
};
|
|
504
681
|
return {
|
|
505
682
|
ok: true,
|
|
506
683
|
data: summary,
|
|
507
|
-
warnings: [...resolved.warnings],
|
|
508
|
-
tips
|
|
684
|
+
warnings: [...resolved.warnings, ...streamWarnings],
|
|
685
|
+
tips,
|
|
509
686
|
meta,
|
|
510
687
|
};
|
|
511
688
|
}
|
|
512
689
|
|
|
690
|
+
/**
|
|
691
|
+
* `api` sends the command as typed, so what RouterOS will not report is said
|
|
692
|
+
* as a tip, not fixed by rewriting the request (#396 decisions). Grounded on
|
|
693
|
+
* CHR 7.23.7 + 7.24.5: a GET stream is one finite print; a filtered change feed
|
|
694
|
+
* drops rows leaving the filter and every delete except under an `?.id=`
|
|
695
|
+
* query; a `.proplist` without `.id,.dead` strips both from a delete frame,
|
|
696
|
+
* even under `?.id=` (it arrives as an `!re` with no attributes).
|
|
697
|
+
*/
|
|
698
|
+
function streamTips(resolved: ResolvedApiRequest): Tip[] {
|
|
699
|
+
const tips: Tip[] = [];
|
|
700
|
+
const menu = resolved.id ? `${resolved.path}/${resolved.id}` : resolved.path;
|
|
701
|
+
if (resolved.verb === "print" && !resolved.listen) {
|
|
702
|
+
tips.push(
|
|
703
|
+
buildTip(
|
|
704
|
+
"tip/stream-print",
|
|
705
|
+
`GET ${menu} --stream streamed one print; it does not follow changes.`,
|
|
706
|
+
`To follow changes, request the menu's listen: \`centrs api <router> ${menu}/listen\` (or POST \`${resolved.path}/print -f follow-only=${resolved.id ? ` --query .id=${resolved.id}` : ""}\`).`,
|
|
707
|
+
),
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const follows =
|
|
711
|
+
resolved.listen ||
|
|
712
|
+
"follow-only" in resolved.body ||
|
|
713
|
+
"follow" in resolved.body;
|
|
714
|
+
if (!follows) return tips;
|
|
715
|
+
const filtered = resolved.query.some((word) => !word.startsWith(".id="));
|
|
716
|
+
if (filtered) {
|
|
717
|
+
tips.push(
|
|
718
|
+
buildTip(
|
|
719
|
+
"tip/filtered-follow",
|
|
720
|
+
"RouterOS ran this change feed with a query, so it reports neither rows that leave the filter nor deletes.",
|
|
721
|
+
"Follow the unfiltered menu and filter on the client, or address one row (`<menu>/*id/listen`), whose delete is reported.",
|
|
722
|
+
),
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
const projection = resolved.proplist;
|
|
726
|
+
if (
|
|
727
|
+
projection.length > 0 &&
|
|
728
|
+
!(projection.includes(".id") && projection.includes(".dead"))
|
|
729
|
+
) {
|
|
730
|
+
tips.push(
|
|
731
|
+
buildTip(
|
|
732
|
+
"tip/follow-proplist",
|
|
733
|
+
"This --proplist omits `.id` or `.dead`, which RouterOS then strips from delete frames: a delete can arrive as a row with no attributes.",
|
|
734
|
+
"Add `.id,.dead` to --proplist to see deletes.",
|
|
735
|
+
),
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return tips;
|
|
739
|
+
}
|
|
740
|
+
|
|
513
741
|
function adapterForResolved(resolved: ResolvedApiRequest): ProtocolAdapter {
|
|
514
742
|
return createProtocolAdapter({
|
|
515
743
|
protocol: resolved.via.value,
|
|
@@ -555,7 +783,9 @@ export async function resolveApiRequest(
|
|
|
555
783
|
const normalized = normalizeApiEndpoint(request.endpoint);
|
|
556
784
|
const method = parseApiMethod(request.method);
|
|
557
785
|
const verb = mapMethodToVerb(method);
|
|
558
|
-
const listen =
|
|
786
|
+
const listen = normalized.listen;
|
|
787
|
+
// A listen never completes, so it is only ever streamed.
|
|
788
|
+
const stream = (request.stream ?? false) || listen;
|
|
559
789
|
// `/execute` (root, single token) run via POST is the script surface — a CLI
|
|
560
790
|
// string, not a menu path. A GET, a nested path, or any other menu that merely
|
|
561
791
|
// ends in `execute` stays a normal path request validated through inspect.
|
|
@@ -574,10 +804,59 @@ export async function resolveApiRequest(
|
|
|
574
804
|
context: { method, path: normalized.path },
|
|
575
805
|
});
|
|
576
806
|
}
|
|
807
|
+
// PUT/POST map no row id onto the wire: native would drop it silently and
|
|
808
|
+
// REST would fold it into `.query`, so the transports would disagree.
|
|
809
|
+
if ((verb === "add" || verb === "run") && normalized.id !== undefined) {
|
|
810
|
+
throw new CentrsError({
|
|
811
|
+
code: "input/invalid-path",
|
|
812
|
+
summary: `A ${method} request does not address a row by id (${normalized.id}).`,
|
|
813
|
+
remediation:
|
|
814
|
+
"Drop the trailing id; use PATCH/DELETE for one row, or pass `.id` as a command field such as `-f .id=*1`.",
|
|
815
|
+
context: { method, path: normalized.path, id: normalized.id },
|
|
816
|
+
});
|
|
817
|
+
}
|
|
577
818
|
const raw = request.raw ?? false;
|
|
578
819
|
const body = buildApiBody(request);
|
|
579
|
-
const
|
|
820
|
+
const structuredQuery =
|
|
821
|
+
(request.query?.length ?? 0) > 0
|
|
822
|
+
? parseQueries(request.query ?? [])
|
|
823
|
+
: undefined;
|
|
824
|
+
const rawQuery = [...(request.rawQuery ?? [])];
|
|
825
|
+
const query = [
|
|
826
|
+
...(structuredQuery ? compileQueryWords(structuredQuery, new Set()) : []),
|
|
827
|
+
...rawQuery,
|
|
828
|
+
];
|
|
580
829
|
const proplist = buildApiProplist(request);
|
|
830
|
+
if (
|
|
831
|
+
((verb !== "print" && verb !== "run") || scriptMode) &&
|
|
832
|
+
(query.length > 0 || proplist.length > 0)
|
|
833
|
+
) {
|
|
834
|
+
throw new CentrsError({
|
|
835
|
+
code: "usage/conflicting-flags",
|
|
836
|
+
summary:
|
|
837
|
+
"Query and projection flags require a GET or a structured POST command.",
|
|
838
|
+
remediation:
|
|
839
|
+
"Remove --query/--raw-query/--proplist from mutation and script requests; address PATCH/DELETE by row id.",
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
if (listen && method !== "GET") {
|
|
843
|
+
throw new CentrsError({
|
|
844
|
+
code: "usage/conflicting-flags",
|
|
845
|
+
summary: "An explicit /listen endpoint requires GET.",
|
|
846
|
+
remediation:
|
|
847
|
+
"Drop -X to follow that menu, or POST the menu's print with `-f follow-only=` to pass print arguments.",
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
// A GET maps to print, which takes no body here; refuse the fields rather
|
|
851
|
+
// than drop them from a stream.
|
|
852
|
+
if (stream && verb === "print" && Object.keys(body).length > 0) {
|
|
853
|
+
throw new CentrsError({
|
|
854
|
+
code: "usage/conflicting-flags",
|
|
855
|
+
summary: "A streamed GET does not accept command fields.",
|
|
856
|
+
remediation:
|
|
857
|
+
"Use -X POST with the full command path to stream command attributes, such as /system/resource/print -f interval=1.",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
581
860
|
|
|
582
861
|
// A quickchr target is a named-live-provider: host/port/auth come from the
|
|
583
862
|
// live descriptor, bypassing CDB, `__default__`, and MAC resolution.
|
|
@@ -600,7 +879,7 @@ export async function resolveApiRequest(
|
|
|
600
879
|
env,
|
|
601
880
|
config,
|
|
602
881
|
)));
|
|
603
|
-
const via = resolveApiProtocol(request, env,
|
|
882
|
+
const via = resolveApiProtocol(request, env, stream, cdbResolution, config);
|
|
604
883
|
const format = resolveApiFormat(request, env, config);
|
|
605
884
|
// `--raw` is a PRECEDENCE LAYER, not an override (#154). It ranks below an
|
|
606
885
|
// explicit `--validate` and above every ambient source, so:
|
|
@@ -636,6 +915,19 @@ export async function resolveApiRequest(
|
|
|
636
915
|
cdbResolution?.overrides.validate,
|
|
637
916
|
config,
|
|
638
917
|
);
|
|
918
|
+
if (!validate.value && (structuredQuery?.bareNames.length ?? 0) > 0) {
|
|
919
|
+
throw new CentrsError({
|
|
920
|
+
code: "input/invalid-query",
|
|
921
|
+
summary: `A bare name in --query (${structuredQuery?.bareNames.join(", ")}) needs validation to learn whether it is a boolean.`,
|
|
922
|
+
remediation:
|
|
923
|
+
'Drop `--validate=false`/`--raw`, or spell the word out: `--query disabled=yes` for a boolean, `--raw-query comment` for "is set".',
|
|
924
|
+
context: {
|
|
925
|
+
query: request.query,
|
|
926
|
+
bareNames: structuredQuery?.bareNames,
|
|
927
|
+
validate: validate.source,
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
}
|
|
639
931
|
const timeoutMs = resolveApiTimeout(
|
|
640
932
|
request.timeout,
|
|
641
933
|
env,
|
|
@@ -716,8 +1008,11 @@ export async function resolveApiRequest(
|
|
|
716
1008
|
verb,
|
|
717
1009
|
scriptMode,
|
|
718
1010
|
listen,
|
|
1011
|
+
stream,
|
|
719
1012
|
body,
|
|
720
1013
|
query,
|
|
1014
|
+
structuredQuery,
|
|
1015
|
+
rawQuery,
|
|
721
1016
|
proplist,
|
|
722
1017
|
raw,
|
|
723
1018
|
count: request.count,
|
|
@@ -856,38 +1151,36 @@ function parseJsonBody(raw: string, flag: string): Record<string, string> {
|
|
|
856
1151
|
}
|
|
857
1152
|
|
|
858
1153
|
/**
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
* bare `name` (has-property).
|
|
1154
|
+
* REST `.query` words (no `?` prefix) for a request summary: structured
|
|
1155
|
+
* `--query` words first (`src/core/query.ts`, bare names as "is set"), then
|
|
1156
|
+
* verbatim `--raw-query` words. Throws `input/invalid-query` on a bad
|
|
1157
|
+
* expression.
|
|
864
1158
|
*/
|
|
865
1159
|
export function buildApiQuery(request: ApiRequest): string[] {
|
|
866
|
-
const
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
words.push(raw);
|
|
872
|
-
}
|
|
873
|
-
return words;
|
|
1160
|
+
const structured =
|
|
1161
|
+
(request.query?.length ?? 0) > 0
|
|
1162
|
+
? compileQueryWords(parseQueries(request.query ?? []), new Set())
|
|
1163
|
+
: [];
|
|
1164
|
+
return [...structured, ...(request.rawQuery ?? [])];
|
|
874
1165
|
}
|
|
875
1166
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1167
|
+
/**
|
|
1168
|
+
* The resolved request with its `--query` bare names compiled by type: the
|
|
1169
|
+
* device says which are booleans (`disabled` → `disabled=yes`); the rest mean
|
|
1170
|
+
* "is set". Unchanged when there are no bare names.
|
|
1171
|
+
*/
|
|
1172
|
+
export async function withQueryBooleans(
|
|
1173
|
+
resolved: ResolvedApiRequest,
|
|
1174
|
+
backend: ProtocolAdapter,
|
|
1175
|
+
menu: string,
|
|
1176
|
+
): Promise<ResolvedApiRequest> {
|
|
1177
|
+
const parsed = resolved.structuredQuery;
|
|
1178
|
+
if (!parsed || parsed.bareNames.length === 0) return resolved;
|
|
1179
|
+
const booleans = await inspectWhereBooleans(backend, menu, parsed.bareNames);
|
|
1180
|
+
return {
|
|
1181
|
+
...resolved,
|
|
1182
|
+
query: [...compileQueryWords(parsed, booleans), ...resolved.rawQuery],
|
|
1183
|
+
};
|
|
891
1184
|
}
|
|
892
1185
|
|
|
893
1186
|
function buildApiProplist(request: ApiRequest): string[] {
|
|
@@ -913,6 +1206,9 @@ export function buildProtocolApiRequest(
|
|
|
913
1206
|
if (resolved.id) {
|
|
914
1207
|
request.id = resolved.id;
|
|
915
1208
|
}
|
|
1209
|
+
if (resolved.listen) {
|
|
1210
|
+
request.listen = true;
|
|
1211
|
+
}
|
|
916
1212
|
if (resolved.scriptMode) {
|
|
917
1213
|
const { script, ...extra } = resolved.body;
|
|
918
1214
|
if (typeof script !== "string" || script.trim().length === 0) {
|
|
@@ -944,7 +1240,7 @@ export function buildProtocolApiRequest(
|
|
|
944
1240
|
request.attributes = resolved.body;
|
|
945
1241
|
}
|
|
946
1242
|
}
|
|
947
|
-
if (resolved.verb === "print") {
|
|
1243
|
+
if (resolved.verb === "print" || resolved.verb === "run") {
|
|
948
1244
|
if (resolved.query.length > 0) {
|
|
949
1245
|
request.query = resolved.query;
|
|
950
1246
|
}
|
|
@@ -955,12 +1251,15 @@ export function buildProtocolApiRequest(
|
|
|
955
1251
|
return request;
|
|
956
1252
|
}
|
|
957
1253
|
|
|
958
|
-
/** The api device-stage validator
|
|
1254
|
+
/** The api device-stage validator for structured path input. */
|
|
959
1255
|
const API_DEVICE_STAGE_SOURCE = "/console/inspect request=child";
|
|
1256
|
+
const API_SCRIPT_DEVICE_STAGE_SOURCE = ":put [:parse]";
|
|
960
1257
|
|
|
961
1258
|
interface ApiValidationResult {
|
|
962
1259
|
validation: EnvelopeValidationMeta;
|
|
963
1260
|
tips: Tip[];
|
|
1261
|
+
/** The menu a `--query` bare name is probed in: the path's parent only when inspect confirmed its last segment is a command. */
|
|
1262
|
+
queryMenu: string;
|
|
964
1263
|
}
|
|
965
1264
|
|
|
966
1265
|
/**
|
|
@@ -976,11 +1275,9 @@ async function validateApiRequest(
|
|
|
976
1275
|
trace: ValidationTrace = {},
|
|
977
1276
|
): Promise<ApiValidationResult> {
|
|
978
1277
|
if (resolved.scriptMode) {
|
|
979
|
-
// `/execute` carries a CLI string, so
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
// The offline analyzer is the whole gate here: a syntax fault is now a
|
|
983
|
-
// byte span and no round trip, where before it reached the device.
|
|
1278
|
+
// `/execute` carries a CLI string, so use the same offline -> live `:parse`
|
|
1279
|
+
// pipeline as `execute`. `/console/inspect` remains inapplicable because
|
|
1280
|
+
// there is no structured menu path/body to inspect.
|
|
984
1281
|
const script = scriptOf(resolved);
|
|
985
1282
|
const offline = script
|
|
986
1283
|
? assertOfflineSyntax(script, {
|
|
@@ -989,6 +1286,9 @@ async function validateApiRequest(
|
|
|
989
1286
|
})
|
|
990
1287
|
: undefined;
|
|
991
1288
|
trace.offlineVerdict = offline?.verdict;
|
|
1289
|
+
if (script !== undefined) {
|
|
1290
|
+
await validateRouterOsScript(script, backend, resolved.via.value);
|
|
1291
|
+
}
|
|
992
1292
|
const offlineReason =
|
|
993
1293
|
offline === undefined
|
|
994
1294
|
? "no `script` field to analyze"
|
|
@@ -998,9 +1298,12 @@ async function validateApiRequest(
|
|
|
998
1298
|
return {
|
|
999
1299
|
validation: {
|
|
1000
1300
|
enabled: true,
|
|
1001
|
-
source:
|
|
1301
|
+
source:
|
|
1302
|
+
offline === undefined
|
|
1303
|
+
? "not-applicable"
|
|
1304
|
+
: API_SCRIPT_DEVICE_STAGE_SOURCE,
|
|
1002
1305
|
result: "passed",
|
|
1003
|
-
syntax:
|
|
1306
|
+
syntax: script !== undefined,
|
|
1004
1307
|
semantic: "not-applicable",
|
|
1005
1308
|
stages: [
|
|
1006
1309
|
{
|
|
@@ -1011,14 +1314,16 @@ async function validateApiRequest(
|
|
|
1011
1314
|
},
|
|
1012
1315
|
{
|
|
1013
1316
|
stage: "device",
|
|
1014
|
-
source:
|
|
1015
|
-
result: "skipped",
|
|
1016
|
-
|
|
1017
|
-
|
|
1317
|
+
source: API_SCRIPT_DEVICE_STAGE_SOURCE,
|
|
1318
|
+
result: script === undefined ? "skipped" : "passed",
|
|
1319
|
+
...(script === undefined
|
|
1320
|
+
? { reason: "no `script` field to parse" }
|
|
1321
|
+
: {}),
|
|
1018
1322
|
},
|
|
1019
1323
|
],
|
|
1020
1324
|
},
|
|
1021
1325
|
tips: [],
|
|
1326
|
+
queryMenu: resolved.path,
|
|
1022
1327
|
};
|
|
1023
1328
|
}
|
|
1024
1329
|
|
|
@@ -1091,6 +1396,7 @@ async function validateApiRequest(
|
|
|
1091
1396
|
semantic: true,
|
|
1092
1397
|
},
|
|
1093
1398
|
tips,
|
|
1399
|
+
queryMenu: isCommand ? `/${parent.join("/")}` : resolved.path,
|
|
1094
1400
|
};
|
|
1095
1401
|
}
|
|
1096
1402
|
|
|
@@ -1135,6 +1441,7 @@ async function validateApiRequest(
|
|
|
1135
1441
|
availableAttributes: available,
|
|
1136
1442
|
},
|
|
1137
1443
|
tips,
|
|
1444
|
+
queryMenu: resolved.path,
|
|
1138
1445
|
};
|
|
1139
1446
|
}
|
|
1140
1447
|
|
|
@@ -1147,6 +1454,7 @@ async function validateApiRequest(
|
|
|
1147
1454
|
semantic: true,
|
|
1148
1455
|
},
|
|
1149
1456
|
tips,
|
|
1457
|
+
queryMenu: resolved.path,
|
|
1150
1458
|
};
|
|
1151
1459
|
}
|
|
1152
1460
|
|
|
@@ -1208,38 +1516,39 @@ async function assertApiWriteConfirmed(
|
|
|
1208
1516
|
});
|
|
1209
1517
|
}
|
|
1210
1518
|
|
|
1211
|
-
function
|
|
1212
|
-
if (!resolved.
|
|
1519
|
+
function assertStreamCapability(resolved: ResolvedApiRequest): void {
|
|
1520
|
+
if (!resolved.stream) {
|
|
1213
1521
|
return;
|
|
1214
1522
|
}
|
|
1215
1523
|
if (resolved.via.value === "rest-api") {
|
|
1216
1524
|
throw new CentrsError({
|
|
1217
1525
|
code: "transport/capability-unsupported",
|
|
1218
|
-
summary: "
|
|
1526
|
+
summary: "Incremental `--stream` replies require native-api.",
|
|
1219
1527
|
remediation:
|
|
1220
|
-
"
|
|
1221
|
-
context: { via: resolved.via.value, capability: "
|
|
1528
|
+
"Use `--via native-api`, or drop `--stream` for a bounded one-shot response.",
|
|
1529
|
+
context: { via: resolved.via.value, capability: "stream" },
|
|
1222
1530
|
});
|
|
1223
1531
|
}
|
|
1224
1532
|
throw new CentrsError({
|
|
1225
1533
|
code: "input/invalid-command",
|
|
1226
|
-
summary:
|
|
1534
|
+
summary:
|
|
1535
|
+
"`--stream` yields incremental replies instead of a one-shot `api` result.",
|
|
1227
1536
|
remediation:
|
|
1228
|
-
"Consume the stream via `
|
|
1229
|
-
context: { via: resolved.via.value, capability: "
|
|
1537
|
+
"Consume the stream via `apiStream()` (library) or `centrs api … --stream` (CLI); both yield an NDJSON envelope per reply frame plus a final summary.",
|
|
1538
|
+
context: { via: resolved.via.value, capability: "stream" },
|
|
1230
1539
|
});
|
|
1231
1540
|
}
|
|
1232
1541
|
|
|
1233
1542
|
function resolveApiProtocol(
|
|
1234
1543
|
request: ApiRequest,
|
|
1235
1544
|
env: Record<string, string | undefined>,
|
|
1236
|
-
|
|
1545
|
+
stream: boolean,
|
|
1237
1546
|
cdb?: CdbResolution,
|
|
1238
1547
|
config: Record<string, string | undefined> = {},
|
|
1239
1548
|
): ResolvedSetting<RouterOsProtocol> {
|
|
1240
|
-
// A
|
|
1241
|
-
// REST stays the default for one-shot calls.
|
|
1242
|
-
const defaultVia: RouterOsProtocol =
|
|
1549
|
+
// A stream (`--stream` or a `/listen` endpoint) infers native-api when
|
|
1550
|
+
// `--via` is unset; REST stays the default for one-shot calls.
|
|
1551
|
+
const defaultVia: RouterOsProtocol = stream ? "native-api" : "rest-api";
|
|
1243
1552
|
const via = resolveStringSetting(
|
|
1244
1553
|
request.via,
|
|
1245
1554
|
env,
|
|
@@ -1383,7 +1692,9 @@ export function buildApiErrorEnvelope(
|
|
|
1383
1692
|
write: parsedMethod
|
|
1384
1693
|
? isApiMutating(parsedMethod, reportedPath)
|
|
1385
1694
|
: false,
|
|
1386
|
-
listen: request.
|
|
1695
|
+
listen: safeNormalizeListen(request.endpoint),
|
|
1696
|
+
stream:
|
|
1697
|
+
(request.stream ?? false) || safeNormalizeListen(request.endpoint),
|
|
1387
1698
|
yes: request.yes ?? false,
|
|
1388
1699
|
validate: summarizedValidate(request),
|
|
1389
1700
|
raw: request.raw ?? false,
|
|
@@ -1441,27 +1752,57 @@ function failedApiValidationMeta(
|
|
|
1441
1752
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
1442
1753
|
{
|
|
1443
1754
|
stage: "device",
|
|
1444
|
-
source:
|
|
1755
|
+
source: apiDeviceStageSource(resolved),
|
|
1445
1756
|
result: "skipped",
|
|
1446
1757
|
reason: "offline analysis rejected the script; no connection opened",
|
|
1447
1758
|
},
|
|
1448
1759
|
],
|
|
1449
1760
|
};
|
|
1450
1761
|
}
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
|
|
1762
|
+
// Only a `validation/*` error identifies the device stage as the rejecter.
|
|
1763
|
+
// Everything downstream of a passed gate — a RouterOS fault on the request,
|
|
1764
|
+
// a transport drop — is served by `trace.validation` before this function is
|
|
1765
|
+
// ever reached.
|
|
1766
|
+
if (error.code.startsWith("validation/")) {
|
|
1767
|
+
return {
|
|
1768
|
+
enabled: true,
|
|
1769
|
+
source: apiDeviceStageSource(resolved),
|
|
1770
|
+
result: "failed",
|
|
1771
|
+
syntax: false,
|
|
1772
|
+
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1773
|
+
stages: [offlineApiStage(resolved, trace), deviceStageFailed(resolved)],
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
// Anything else stopped the gate before it produced a verdict: a transport or
|
|
1777
|
+
// auth failure, a device error that is not a gate answer, or a refusal before
|
|
1778
|
+
// the gate ran (write confirmation, a non-native `--stream`). No `result`, no
|
|
1779
|
+
// `syntax` / `semantic` — no stage said either (GH#435). Without a trace a
|
|
1780
|
+
// script request cannot say whether stage 1 ran, so that stage is left out.
|
|
1781
|
+
const reason = notReachedReason(error.code);
|
|
1782
|
+
const source = apiDeviceStageSource(resolved);
|
|
1783
|
+
const device: EnvelopeValidationStage = {
|
|
1784
|
+
stage: "device",
|
|
1785
|
+
source,
|
|
1786
|
+
result: "skipped",
|
|
1787
|
+
reason,
|
|
1788
|
+
};
|
|
1789
|
+
if (resolved.scriptMode && !trace) {
|
|
1790
|
+
return { enabled: true, source, stages: [device] };
|
|
1791
|
+
}
|
|
1456
1792
|
return {
|
|
1457
1793
|
enabled: true,
|
|
1458
|
-
source
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1794
|
+
source,
|
|
1795
|
+
stages: [
|
|
1796
|
+
resolved.scriptMode && trace?.offlineVerdict === undefined
|
|
1797
|
+
? {
|
|
1798
|
+
stage: "offline",
|
|
1799
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1800
|
+
result: "skipped",
|
|
1801
|
+
reason,
|
|
1802
|
+
}
|
|
1803
|
+
: offlineApiStage(resolved, trace),
|
|
1804
|
+
device,
|
|
1805
|
+
],
|
|
1465
1806
|
};
|
|
1466
1807
|
}
|
|
1467
1808
|
|
|
@@ -1494,10 +1835,18 @@ function offlineApiStage(
|
|
|
1494
1835
|
};
|
|
1495
1836
|
}
|
|
1496
1837
|
|
|
1497
|
-
function
|
|
1838
|
+
function apiDeviceStageSource(resolved: ResolvedApiRequest): string {
|
|
1839
|
+
return resolved.scriptMode
|
|
1840
|
+
? API_SCRIPT_DEVICE_STAGE_SOURCE
|
|
1841
|
+
: API_DEVICE_STAGE_SOURCE;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function deviceStageFailed(
|
|
1845
|
+
resolved: ResolvedApiRequest,
|
|
1846
|
+
): EnvelopeValidationStage {
|
|
1498
1847
|
return {
|
|
1499
1848
|
stage: "device",
|
|
1500
|
-
source:
|
|
1849
|
+
source: apiDeviceStageSource(resolved),
|
|
1501
1850
|
result: "failed",
|
|
1502
1851
|
};
|
|
1503
1852
|
}
|
|
@@ -1518,7 +1867,11 @@ function disabledApiValidationMeta(
|
|
|
1518
1867
|
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1519
1868
|
stages: [
|
|
1520
1869
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
1521
|
-
{
|
|
1870
|
+
{
|
|
1871
|
+
stage: "device",
|
|
1872
|
+
source: apiDeviceStageSource(resolved),
|
|
1873
|
+
result: "skipped",
|
|
1874
|
+
},
|
|
1522
1875
|
],
|
|
1523
1876
|
};
|
|
1524
1877
|
}
|
|
@@ -1551,12 +1904,16 @@ export function renderApiEnvelope(
|
|
|
1551
1904
|
switch (format) {
|
|
1552
1905
|
case "json":
|
|
1553
1906
|
return JSON.stringify(envelope, null, 2);
|
|
1907
|
+
// One envelope, one compact line: the same line shape as a stream, so a
|
|
1908
|
+
// one-shot read and a `--stream` are both read line by line.
|
|
1909
|
+
case "ndjson":
|
|
1910
|
+
return JSON.stringify(envelope);
|
|
1554
1911
|
case "yaml":
|
|
1555
1912
|
return toYaml(envelope);
|
|
1556
1913
|
case "text":
|
|
1557
1914
|
return envelope.ok
|
|
1558
1915
|
? renderApiSuccessText(envelope, options)
|
|
1559
|
-
:
|
|
1916
|
+
: formatCentrsErrorText(envelope.error, options);
|
|
1560
1917
|
default:
|
|
1561
1918
|
return exhaustiveFormat(format);
|
|
1562
1919
|
}
|
|
@@ -1586,20 +1943,27 @@ export function renderApiStreamLine(
|
|
|
1586
1943
|
}
|
|
1587
1944
|
// A streamed error must stay one line too — never the verbose blank-line +
|
|
1588
1945
|
// pretty-printed context block that `renderApiErrorText` emits.
|
|
1589
|
-
|
|
1590
|
-
return error.remediation
|
|
1591
|
-
? `[${error.code}] ${error.summary} — Fix: ${error.remediation}`
|
|
1592
|
-
: `[${error.code}] ${error.summary}`;
|
|
1946
|
+
return formatCentrsErrorLine(envelope.error);
|
|
1593
1947
|
}
|
|
1594
|
-
// json and yaml
|
|
1595
|
-
// (yaml falls back to JSON — a
|
|
1948
|
+
// ndjson, json and yaml all stream as NDJSON: one compact envelope object
|
|
1949
|
+
// per line. (json keeps this for compatibility; yaml falls back to JSON — a
|
|
1950
|
+
// multi-line YAML doc can't be one stream line.)
|
|
1596
1951
|
return JSON.stringify(envelope);
|
|
1597
1952
|
}
|
|
1598
1953
|
|
|
1599
1954
|
function renderApiStreamFrameText(envelope: ApiSuccessEnvelope): string {
|
|
1600
1955
|
const stream = envelope.meta.operation?.stream;
|
|
1601
1956
|
if (stream?.kind === "summary") {
|
|
1602
|
-
|
|
1957
|
+
const empty = stream.empty > 0 ? `, ${stream.empty} empty` : "";
|
|
1958
|
+
return `— ${stream.stopReason}: ${stream.rows} row(s)${empty} in ${stream.durationMs}ms`;
|
|
1959
|
+
}
|
|
1960
|
+
if (stream?.kind === "notice") {
|
|
1961
|
+
return envelope.tips
|
|
1962
|
+
.map((tip) => `tip: ${tip.message}${tip.fix ? ` ${tip.fix}` : ""}`)
|
|
1963
|
+
.join("\n");
|
|
1964
|
+
}
|
|
1965
|
+
if (stream?.kind === "frame" && stream.reply === "empty") {
|
|
1966
|
+
return `${stream.index}\t!empty`;
|
|
1603
1967
|
}
|
|
1604
1968
|
const index = stream?.kind === "frame" ? stream.index : 0;
|
|
1605
1969
|
return `${index}\t${JSON.stringify(envelope.data)}`;
|
|
@@ -1641,21 +2005,12 @@ function renderApiSuccessText(
|
|
|
1641
2005
|
return lines.join("\n");
|
|
1642
2006
|
}
|
|
1643
2007
|
|
|
1644
|
-
function renderApiErrorText(
|
|
1645
|
-
envelope: ApiErrorEnvelope,
|
|
1646
|
-
options: { verbose?: boolean },
|
|
1647
|
-
): string {
|
|
1648
|
-
const lines = [`[${envelope.error.code}] ${envelope.error.summary}`];
|
|
1649
|
-
if (envelope.error.remediation) {
|
|
1650
|
-
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
1651
|
-
}
|
|
1652
|
-
if (options.verbose && envelope.error.context) {
|
|
1653
|
-
lines.push("", JSON.stringify(envelope.error.context, null, 2));
|
|
1654
|
-
}
|
|
1655
|
-
return lines.join("\n");
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
2008
|
export function validateApiRequestShape(request: ApiRequest): void {
|
|
2009
|
+
// `listen` used to mean "stream" AND rewrote a GET into a change feed (#402).
|
|
2010
|
+
// Refuse it rather than let an untyped caller silently get a finite print.
|
|
2011
|
+
if ("listen" in request) {
|
|
2012
|
+
throw removedListenError(request.endpoint, "library");
|
|
2013
|
+
}
|
|
1659
2014
|
if (!request.endpoint || request.endpoint.trim().length === 0) {
|
|
1660
2015
|
throw new CentrsError({
|
|
1661
2016
|
code: "input/invalid-command",
|
|
@@ -1675,6 +2030,31 @@ export function validateApiRequestShape(request: ApiRequest): void {
|
|
|
1675
2030
|
}
|
|
1676
2031
|
}
|
|
1677
2032
|
|
|
2033
|
+
/**
|
|
2034
|
+
* `--listen` / `ApiRequest.listen` were removed in #402: they meant "stream"
|
|
2035
|
+
* and also turned a GET into a change feed, which a literal `api` must not do.
|
|
2036
|
+
* The error names the exact replacement instead of guessing which was meant.
|
|
2037
|
+
*/
|
|
2038
|
+
export function removedListenError(
|
|
2039
|
+
endpoint: string,
|
|
2040
|
+
surface: "cli" | "library",
|
|
2041
|
+
replacement?: string,
|
|
2042
|
+
): CentrsError {
|
|
2043
|
+
const menu = safeNormalizeMenu(endpoint);
|
|
2044
|
+
return new CentrsError({
|
|
2045
|
+
code: "usage/removed-flag",
|
|
2046
|
+
summary:
|
|
2047
|
+
surface === "cli"
|
|
2048
|
+
? "`--listen` was removed: `api` no longer rewrites a request into a listen."
|
|
2049
|
+
: "`ApiRequest.listen` was removed: `api` no longer rewrites a request into a listen.",
|
|
2050
|
+
remediation:
|
|
2051
|
+
surface === "cli"
|
|
2052
|
+
? `Run: ${replacement ?? `centrs api <router> ${menu}/listen`}${replacement?.includes("<") ? " (fill each <placeholder> in from your command)" : ""}. A \`/listen\` endpoint follows changes; \`--stream\` streams any other command as typed.`
|
|
2053
|
+
: `To follow changes, use \`apiStream()\` with endpoint \`${menu}/listen\`; to stream another command as typed, set \`stream: true\`.`,
|
|
2054
|
+
context: { flag: surface === "cli" ? "--listen" : "listen", endpoint },
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1678
2058
|
export function parseApiMethod(method: string | undefined): ApiMethod {
|
|
1679
2059
|
if (method === undefined) {
|
|
1680
2060
|
return "GET";
|
|
@@ -1702,7 +2082,7 @@ export function apiRequestSummaryFromRequest(
|
|
|
1702
2082
|
): ApiRequestSummary {
|
|
1703
2083
|
const normalized = normalizeApiEndpoint(request.endpoint);
|
|
1704
2084
|
const parsed = tryParseApiMethod(request.method);
|
|
1705
|
-
const listen =
|
|
2085
|
+
const listen = normalized.listen;
|
|
1706
2086
|
return {
|
|
1707
2087
|
endpoint: request.endpoint,
|
|
1708
2088
|
path: normalized.path,
|
|
@@ -1711,15 +2091,25 @@ export function apiRequestSummaryFromRequest(
|
|
|
1711
2091
|
verb: parsed ? mapMethodToVerb(parsed) : null,
|
|
1712
2092
|
write: parsed ? isApiMutating(parsed, normalized.path) : false,
|
|
1713
2093
|
listen,
|
|
2094
|
+
stream: (request.stream ?? false) || listen,
|
|
1714
2095
|
yes: request.yes ?? false,
|
|
1715
2096
|
validate: summarizedValidate(request),
|
|
1716
2097
|
raw: request.raw ?? false,
|
|
1717
2098
|
format: resolveErrorFormat(request, env),
|
|
1718
|
-
query:
|
|
2099
|
+
query: summaryQuery(request),
|
|
1719
2100
|
proplist: buildApiProplist(request),
|
|
1720
2101
|
};
|
|
1721
2102
|
}
|
|
1722
2103
|
|
|
2104
|
+
/** {@link buildApiQuery} for an error summary, which must not throw on the expression it reports. */
|
|
2105
|
+
function summaryQuery(request: ApiRequest): string[] | undefined {
|
|
2106
|
+
try {
|
|
2107
|
+
return buildApiQuery(request);
|
|
2108
|
+
} catch {
|
|
2109
|
+
return undefined;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
1723
2113
|
/** Parse a method without throwing: a valid {@link ApiMethod}, or `undefined` when invalid. */
|
|
1724
2114
|
function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
|
|
1725
2115
|
if (method === undefined) {
|
|
@@ -1731,6 +2121,24 @@ function tryParseApiMethod(method: string | undefined): ApiMethod | undefined {
|
|
|
1731
2121
|
: undefined;
|
|
1732
2122
|
}
|
|
1733
2123
|
|
|
2124
|
+
/** The endpoint's path plus any row id (`/ip/address/*1`), without a `/listen`. */
|
|
2125
|
+
function safeNormalizeMenu(endpoint: string): string {
|
|
2126
|
+
try {
|
|
2127
|
+
const { path, id } = normalizeApiEndpoint(endpoint);
|
|
2128
|
+
return id ? `${path}/${id}` : path;
|
|
2129
|
+
} catch {
|
|
2130
|
+
return endpoint;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
function safeNormalizeListen(endpoint: string): boolean {
|
|
2135
|
+
try {
|
|
2136
|
+
return normalizeApiEndpoint(endpoint).listen;
|
|
2137
|
+
} catch {
|
|
2138
|
+
return false;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
1734
2142
|
function safeNormalizePath(endpoint: string): string {
|
|
1735
2143
|
try {
|
|
1736
2144
|
return normalizeApiEndpoint(endpoint).path;
|
|
@@ -1826,6 +2234,7 @@ function apiRequestSummary(resolved: ResolvedApiRequest): ApiRequestSummary {
|
|
|
1826
2234
|
verb: resolved.verb,
|
|
1827
2235
|
write: isApiMutating(resolved.method, resolved.path),
|
|
1828
2236
|
listen: resolved.listen,
|
|
2237
|
+
stream: resolved.stream,
|
|
1829
2238
|
yes: resolved.yes,
|
|
1830
2239
|
validate: resolved.validate.value,
|
|
1831
2240
|
raw: resolved.raw,
|