@zudojs/api 0.1.0 → 1.0.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/LICENSE +21 -0
- package/README.md +133 -9
- package/dist/api/constants.d.ts +32 -2
- package/dist/api/constants.js +32 -2
- package/dist/api/context/context.type.d.ts +38 -0
- package/dist/api/context/context.type.js +86 -5
- package/dist/api/context/contextKey.type.d.ts +13 -0
- package/dist/api/context/contextKey.type.js +6 -0
- package/dist/api/errors/index.d.ts +1 -1
- package/dist/api/errors/index.js +1 -1
- package/dist/api/executor/executor.core.d.ts +81 -11
- package/dist/api/executor/executor.core.js +267 -29
- package/dist/api/executor/index.d.ts +1 -0
- package/dist/api/handler/handler.type.d.ts +5 -2
- package/dist/api/interceptors/interceptor.type.d.ts +18 -1
- package/dist/api/operation/operation.type.d.ts +90 -0
- package/dist/api/operation/operation.type.js +115 -3
- package/dist/api/registry/operationRegistry.core.d.ts +15 -2
- package/dist/api/registry/operationRegistry.core.js +32 -4
- package/dist/index.d.ts +21 -12
- package/dist/index.js +19 -12
- package/package.json +24 -17
- package/dist/.tsbuildinfo +0 -1
- package/dist/api/constants.d.ts.map +0 -1
- package/dist/api/constants.js.map +0 -1
- package/dist/api/context/context.type.d.ts.map +0 -1
- package/dist/api/context/context.type.js.map +0 -1
- package/dist/api/context/contextKey.type.d.ts.map +0 -1
- package/dist/api/context/contextKey.type.js.map +0 -1
- package/dist/api/errors/index.d.ts.map +0 -1
- package/dist/api/errors/index.js.map +0 -1
- package/dist/api/executor/executor.core.d.ts.map +0 -1
- package/dist/api/executor/executor.core.js.map +0 -1
- package/dist/api/executor/index.d.ts.map +0 -1
- package/dist/api/executor/index.js.map +0 -1
- package/dist/api/handler/handler.type.d.ts.map +0 -1
- package/dist/api/handler/handler.type.js.map +0 -1
- package/dist/api/interceptors/interceptor.type.d.ts.map +0 -1
- package/dist/api/interceptors/interceptor.type.js.map +0 -1
- package/dist/api/operation/operation.type.d.ts.map +0 -1
- package/dist/api/operation/operation.type.js.map +0 -1
- package/dist/api/registry/index.d.ts.map +0 -1
- package/dist/api/registry/index.js.map +0 -1
- package/dist/api/registry/operationRegistry.core.d.ts.map +0 -1
- package/dist/api/registry/operationRegistry.core.js.map +0 -1
- package/dist/api/result/apiResult.type.d.ts.map +0 -1
- package/dist/api/result/apiResult.type.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -1,62 +1,300 @@
|
|
|
1
|
+
import { apiFailure, apiSuccess } from "../result/apiResult.type.js";
|
|
2
|
+
import { resolveOperationTimeout } from "../operation/operation.type.js";
|
|
3
|
+
import { APIError, APIInternalError, APITimeoutError, APIValidationError, createAPIError, ErrorCode, isAPIError, } from "../errors/index.js";
|
|
4
|
+
import { MAX_INTERCEPTORS, MAX_VALIDATION_ISSUES, MAX_VALIDATION_ISSUE_LENGTH, } from "../constants.js";
|
|
1
5
|
/**
|
|
2
|
-
* Error normalizer for converting unknown errors into
|
|
6
|
+
* Error normalizer for converting unknown errors into APIError instances.
|
|
7
|
+
*
|
|
8
|
+
* APIErrors pass through untouched. Everything else is wrapped in an
|
|
9
|
+
* `APIInternalError` (`expose: false`) carrying a generic message; the
|
|
10
|
+
* original error is preserved on `cause` for logging.
|
|
11
|
+
*
|
|
12
|
+
* The wrapper's own message is deliberately *not* a copy of the original.
|
|
13
|
+
* `BaseError.toJSON()` in `@zudojs/errors` 0.1.0 emits `message`, `stack`
|
|
14
|
+
* and the serialized `cause` regardless of `expose`, so a transport doing
|
|
15
|
+
* `res.json(result.error)` would otherwise ship the raw driver message
|
|
16
|
+
* (connection strings, constraint names, file paths, tokens) to a client.
|
|
3
17
|
*/
|
|
4
18
|
export function normalizeAPIError(error, operationName) {
|
|
5
|
-
if (error
|
|
19
|
+
if (isAPIError(error)) {
|
|
6
20
|
return error;
|
|
7
21
|
}
|
|
8
|
-
|
|
22
|
+
const where = operationName !== undefined && operationName !== ""
|
|
23
|
+
? `operation "${operationName}"`
|
|
24
|
+
: "an API operation";
|
|
25
|
+
const wrapped = error instanceof Error
|
|
26
|
+
? new APIInternalError(`An unexpected internal error occurred in ${where}.`)
|
|
27
|
+
: new APIInternalError(`A non-error value (${describeValueType(error)}) was thrown in ${where}.`);
|
|
28
|
+
// `APIInternalError`'s subclass constructor forwards only
|
|
29
|
+
// `{ endpoint, method }`, so `cause` cannot be passed through it.
|
|
30
|
+
// `APIError` / `createAPIError` *do* accept `cause`, but constructing
|
|
31
|
+
// through them would lose the `APIInternalError` class identity that
|
|
32
|
+
// consumers match on. `cause` is a declared writable class field on
|
|
33
|
+
// `BaseError`, so assigning it after construction is equivalent for
|
|
34
|
+
// `toJSON()` and for `error.cause` reads; only the native `[[cause]]`
|
|
35
|
+
// slot differs.
|
|
36
|
+
wrapped.cause = error;
|
|
37
|
+
return wrapped;
|
|
38
|
+
}
|
|
39
|
+
function describeValueType(value) {
|
|
40
|
+
if (value === null) {
|
|
41
|
+
return "null";
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
return "array";
|
|
45
|
+
}
|
|
46
|
+
return typeof value;
|
|
47
|
+
}
|
|
48
|
+
function isStandardSchema(value) {
|
|
49
|
+
return (typeof value === "object" &&
|
|
50
|
+
value !== null &&
|
|
51
|
+
typeof value["~standard"]
|
|
52
|
+
?.validate === "function");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A schema result counts as a failure only when it carries at least one
|
|
56
|
+
* issue. Some adapters always populate `issues` and return an empty array
|
|
57
|
+
* on success; treating that as a failure produces a 422 with an empty
|
|
58
|
+
* issue list and no way to learn what was wrong.
|
|
59
|
+
*/
|
|
60
|
+
function hasIssues(result) {
|
|
61
|
+
return Array.isArray(result.issues) && result.issues.length > 0;
|
|
9
62
|
}
|
|
10
63
|
/**
|
|
11
64
|
* Executes an API operation through its interceptor pipeline.
|
|
65
|
+
*
|
|
66
|
+
* Enforces the operation timeout, honors the context AbortSignal, and
|
|
67
|
+
* validates input and output when the operation's `input` / `output` is a
|
|
68
|
+
* Standard Schema.
|
|
12
69
|
*/
|
|
13
70
|
export class APIExecutor {
|
|
14
71
|
interceptors;
|
|
15
|
-
|
|
72
|
+
exposeValidationMessages;
|
|
73
|
+
maxValidationIssues;
|
|
74
|
+
constructor(optionsOrInterceptors = {}) {
|
|
75
|
+
const options = Array.isArray(optionsOrInterceptors)
|
|
76
|
+
? { interceptors: optionsOrInterceptors }
|
|
77
|
+
: optionsOrInterceptors;
|
|
78
|
+
const interceptors = options.interceptors ?? [];
|
|
79
|
+
if (interceptors.length > MAX_INTERCEPTORS) {
|
|
80
|
+
throw new RangeError(`Interceptor pipeline exceeds MAX_INTERCEPTORS (${MAX_INTERCEPTORS}).`);
|
|
81
|
+
}
|
|
82
|
+
const maxValidationIssues = options.maxValidationIssues ?? MAX_VALIDATION_ISSUES;
|
|
83
|
+
if (!Number.isInteger(maxValidationIssues) ||
|
|
84
|
+
maxValidationIssues < 1 ||
|
|
85
|
+
maxValidationIssues > MAX_VALIDATION_ISSUES) {
|
|
86
|
+
throw new RangeError(`maxValidationIssues must be an integer between 1 and ${MAX_VALIDATION_ISSUES}, received ${String(maxValidationIssues)}.`);
|
|
87
|
+
}
|
|
16
88
|
this.interceptors = Object.freeze([...interceptors]);
|
|
89
|
+
this.exposeValidationMessages = options.exposeValidationMessages === true;
|
|
90
|
+
this.maxValidationIssues = maxValidationIssues;
|
|
17
91
|
}
|
|
18
92
|
/**
|
|
19
93
|
* Executes an operation with the given input and context.
|
|
20
94
|
*/
|
|
21
95
|
async execute(operation, input, context) {
|
|
22
|
-
|
|
23
|
-
operation
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const executeNext = async () => {
|
|
96
|
+
if (context.signal?.aborted) {
|
|
97
|
+
return apiFailure(abortedError(operation.name));
|
|
98
|
+
}
|
|
99
|
+
let effectiveInput = input;
|
|
100
|
+
if (isStandardSchema(operation.input)) {
|
|
28
101
|
try {
|
|
29
|
-
const
|
|
30
|
-
|
|
102
|
+
const validation = await operation.input["~standard"].validate(input);
|
|
103
|
+
if (hasIssues(validation)) {
|
|
104
|
+
return apiFailure(new APIValidationError(`Invalid input for operation "${operation.name}".`, this.clientIssues(validation.issues)));
|
|
105
|
+
}
|
|
106
|
+
effectiveInput = validation.value;
|
|
31
107
|
}
|
|
32
108
|
catch (error) {
|
|
33
|
-
return
|
|
34
|
-
ok: false,
|
|
35
|
-
error: normalizeAPIError(error, operation.name),
|
|
36
|
-
};
|
|
109
|
+
return apiFailure(normalizeAPIError(error, operation.name));
|
|
37
110
|
}
|
|
111
|
+
}
|
|
112
|
+
const executionContext = {
|
|
113
|
+
operation,
|
|
114
|
+
input: effectiveInput,
|
|
115
|
+
context,
|
|
38
116
|
};
|
|
39
|
-
|
|
117
|
+
// Reads `executionContext.input` at call time, so an interceptor that
|
|
118
|
+
// replaces the input before calling `next()` actually changes what the
|
|
119
|
+
// handler receives.
|
|
120
|
+
const executeHandler = () => this.invokeHandler(operation, executionContext.input, context);
|
|
121
|
+
try {
|
|
122
|
+
return await this.runPipeline(executionContext, executeHandler);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return apiFailure(normalizeAPIError(error, operation.name));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Invokes the operation handler under its timeout and abort signal, and
|
|
130
|
+
* validates the handler's output when `operation.output` is a Standard
|
|
131
|
+
* Schema.
|
|
132
|
+
*/
|
|
133
|
+
async invokeHandler(operation, input, context) {
|
|
134
|
+
if (context.signal?.aborted) {
|
|
135
|
+
return apiFailure(abortedError(operation.name));
|
|
136
|
+
}
|
|
137
|
+
const timeoutMs = resolveOperationTimeout(operation);
|
|
138
|
+
let output;
|
|
139
|
+
try {
|
|
140
|
+
output = await withDeadline(operation.handler(input, context), timeoutMs, operation.name, context.signal);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return apiFailure(normalizeAPIError(error, operation.name));
|
|
144
|
+
}
|
|
145
|
+
return this.validateOutput(operation, output);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Validates handler output against `operation.output`.
|
|
149
|
+
*
|
|
150
|
+
* A response that does not match its declared schema is a server bug,
|
|
151
|
+
* not a client mistake, so failures surface as an `APIInternalError`
|
|
152
|
+
* (500, `expose: false`) naming only the failing paths — never the
|
|
153
|
+
* offending values, which are exactly the fields (password hashes,
|
|
154
|
+
* internal audit columns) that should not reach a client.
|
|
155
|
+
*/
|
|
156
|
+
async validateOutput(operation, output) {
|
|
157
|
+
if (!isStandardSchema(operation.output)) {
|
|
158
|
+
return apiSuccess(output);
|
|
159
|
+
}
|
|
160
|
+
let validation;
|
|
161
|
+
try {
|
|
162
|
+
validation = await operation.output["~standard"].validate(output);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
return apiFailure(normalizeAPIError(error, operation.name));
|
|
166
|
+
}
|
|
167
|
+
if (hasIssues(validation)) {
|
|
168
|
+
const paths = validation.issues
|
|
169
|
+
.slice(0, this.maxValidationIssues)
|
|
170
|
+
.map(formatIssuePath)
|
|
171
|
+
.join(", ");
|
|
172
|
+
return apiFailure(new APIInternalError(`Invalid output for operation "${operation.name}" at: ${paths}.`));
|
|
173
|
+
}
|
|
174
|
+
return apiSuccess(validation.value);
|
|
40
175
|
}
|
|
41
176
|
/**
|
|
42
|
-
*
|
|
177
|
+
* Converts schema issues into the capped, redacted list carried on the
|
|
178
|
+
* client-facing `APIValidationError`.
|
|
43
179
|
*/
|
|
44
|
-
|
|
180
|
+
clientIssues(issues) {
|
|
181
|
+
const limit = this.maxValidationIssues;
|
|
182
|
+
const shown = issues.slice(0, limit).map((issue) => this.exposeValidationMessages
|
|
183
|
+
? truncate(issue.message, MAX_VALIDATION_ISSUE_LENGTH)
|
|
184
|
+
: `${formatIssuePath(issue)}: invalid`);
|
|
185
|
+
const omitted = issues.length - shown.length;
|
|
186
|
+
if (omitted > 0) {
|
|
187
|
+
shown.push(`… and ${omitted} more issue(s) omitted.`);
|
|
188
|
+
}
|
|
189
|
+
return shown;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Runs the interceptor pipeline (Koa-style dispatch).
|
|
193
|
+
*
|
|
194
|
+
* Each interceptor's `next()` may be awaited at most once; a second
|
|
195
|
+
* call rejects instead of silently re-executing the handler while
|
|
196
|
+
* bypassing downstream interceptors.
|
|
197
|
+
*
|
|
198
|
+
* `context.result` is assigned as each level resolves, so an
|
|
199
|
+
* interceptor reading it after `await next()` sees exactly what the
|
|
200
|
+
* level below returned — including when a downstream interceptor
|
|
201
|
+
* short-circuits without calling `next()`.
|
|
202
|
+
*/
|
|
203
|
+
runPipeline(context, terminal) {
|
|
45
204
|
const interceptors = this.interceptors;
|
|
46
|
-
let
|
|
47
|
-
const
|
|
205
|
+
let lastDispatched = -1;
|
|
206
|
+
const dispatch = async (index) => {
|
|
207
|
+
if (index <= lastDispatched) {
|
|
208
|
+
throw new APIInternalError("next() called multiple times in interceptor pipeline.");
|
|
209
|
+
}
|
|
210
|
+
lastDispatched = index;
|
|
48
211
|
if (index >= interceptors.length) {
|
|
49
|
-
|
|
212
|
+
const result = await terminal();
|
|
213
|
+
context.result = result;
|
|
214
|
+
return result;
|
|
50
215
|
}
|
|
51
216
|
const interceptor = interceptors[index];
|
|
52
|
-
index
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
return interceptor.intercept(context, runNext);
|
|
217
|
+
const result = await interceptor.intercept(context, () => dispatch(index + 1));
|
|
218
|
+
context.result = result;
|
|
219
|
+
return result;
|
|
57
220
|
};
|
|
58
|
-
|
|
59
|
-
return result;
|
|
221
|
+
return dispatch(0);
|
|
60
222
|
}
|
|
61
223
|
}
|
|
224
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────
|
|
225
|
+
function truncate(value, max) {
|
|
226
|
+
const text = typeof value === "string" ? value : String(value);
|
|
227
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Renders a Standard Schema issue path as a dotted string. Path segments
|
|
231
|
+
* are field names, never submitted values, so they are safe to expose.
|
|
232
|
+
*/
|
|
233
|
+
function formatIssuePath(issue) {
|
|
234
|
+
const path = issue.path;
|
|
235
|
+
if (path === undefined || path.length === 0) {
|
|
236
|
+
return "(root)";
|
|
237
|
+
}
|
|
238
|
+
return path
|
|
239
|
+
.map((segment) => {
|
|
240
|
+
const key = typeof segment === "object" && segment !== null && "key" in segment
|
|
241
|
+
? segment.key
|
|
242
|
+
: segment;
|
|
243
|
+
return truncate(String(key), 64);
|
|
244
|
+
})
|
|
245
|
+
.join(".");
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Error for an execution cancelled by the caller's `AbortSignal`.
|
|
249
|
+
*
|
|
250
|
+
* Carries `ErrorCode.OPERATION_CANCELLED` — branch on that, not on the
|
|
251
|
+
* status code. `statusCode` is 499, an nginx convention ("Client Closed
|
|
252
|
+
* Request") rather than an IANA status; this package is
|
|
253
|
+
* transport-agnostic, so each adapter should map the *code* onto whatever
|
|
254
|
+
* its protocol calls "cancelled" (gRPC `CANCELLED`, a dropped queue
|
|
255
|
+
* message, a non-zero CLI exit) rather than passing 499 to the wire.
|
|
256
|
+
*/
|
|
257
|
+
function abortedError(operationName) {
|
|
258
|
+
return createAPIError(`Operation "${operationName}" was aborted.`, {
|
|
259
|
+
code: ErrorCode.OPERATION_CANCELLED,
|
|
260
|
+
statusCode: 499,
|
|
261
|
+
expose: true,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Awaits a handler promise, rejecting when the timeout elapses or the
|
|
266
|
+
* abort signal fires. The handler itself keeps running (promises are not
|
|
267
|
+
* cancellable), but the caller stops waiting and resources are released.
|
|
268
|
+
*
|
|
269
|
+
* `timeoutMs` is always a positive integer here — `defineOperation`
|
|
270
|
+
* rejects anything else and `resolveOperationTimeout` substitutes the
|
|
271
|
+
* default for hand-rolled operations — so the deadline can never be
|
|
272
|
+
* silently disabled.
|
|
273
|
+
*/
|
|
274
|
+
function withDeadline(promise, timeoutMs, operationName, signal) {
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
let timer;
|
|
277
|
+
const cleanup = () => {
|
|
278
|
+
if (timer !== undefined)
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
signal?.removeEventListener("abort", onAbort);
|
|
281
|
+
};
|
|
282
|
+
const onAbort = () => {
|
|
283
|
+
cleanup();
|
|
284
|
+
reject(abortedError(operationName));
|
|
285
|
+
};
|
|
286
|
+
timer = setTimeout(() => {
|
|
287
|
+
cleanup();
|
|
288
|
+
reject(new APITimeoutError(timeoutMs));
|
|
289
|
+
}, timeoutMs);
|
|
290
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
291
|
+
promise.then((value) => {
|
|
292
|
+
cleanup();
|
|
293
|
+
resolve(value);
|
|
294
|
+
}, (error) => {
|
|
295
|
+
cleanup();
|
|
296
|
+
reject(error);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
}
|
|
62
300
|
//# sourceMappingURL=executor.core.js.map
|
|
@@ -2,8 +2,11 @@ import type { APIContext } from "../context/context.type.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* Handler for an API operation.
|
|
4
4
|
*
|
|
5
|
-
* Receives
|
|
6
|
-
*
|
|
5
|
+
* Receives the operation input and the execution context, and returns
|
|
6
|
+
* the operation output. Input is validated by the executor only when the
|
|
7
|
+
* operation's `input` is a Standard Schema (https://standardschema.dev,
|
|
8
|
+
* implemented by Zod, Valibot, ArkType, …); otherwise it is passed
|
|
9
|
+
* through as-is and the handler must validate it itself.
|
|
7
10
|
*/
|
|
8
11
|
export type APIHandler<TInput = unknown, TOutput = unknown> = (input: TInput, context: APIContext) => Promise<TOutput>;
|
|
9
12
|
//# sourceMappingURL=handler.type.d.ts.map
|
|
@@ -6,8 +6,25 @@ import type { APIOperation } from "../operation/operation.type.js";
|
|
|
6
6
|
*/
|
|
7
7
|
export interface APIExecutionContext<TInput = unknown, TOutput = unknown> {
|
|
8
8
|
readonly operation: APIOperation<TInput, TOutput>;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The input the handler will receive.
|
|
11
|
+
*
|
|
12
|
+
* Writable on purpose: an interceptor may replace it before calling
|
|
13
|
+
* `next()` (to sanitize, scope to a tenant, or apply a default) and the
|
|
14
|
+
* handler receives the replacement. The executor reads this field at
|
|
15
|
+
* handler-invocation time, so a replacement made by any interceptor in
|
|
16
|
+
* the chain takes effect.
|
|
17
|
+
*/
|
|
18
|
+
input: TInput;
|
|
10
19
|
readonly context: APIContext;
|
|
20
|
+
/**
|
|
21
|
+
* The result produced by the level below this one.
|
|
22
|
+
*
|
|
23
|
+
* The executor assigns it as each pipeline level resolves, so after
|
|
24
|
+
* `await next()` this holds exactly what `next()` returned — including
|
|
25
|
+
* when a downstream interceptor short-circuits without running the
|
|
26
|
+
* handler. It is `undefined` before `next()` has resolved.
|
|
27
|
+
*/
|
|
11
28
|
readonly result?: APIResult<TOutput>;
|
|
12
29
|
}
|
|
13
30
|
/**
|
|
@@ -7,6 +7,10 @@ export interface APIOperationMetadata {
|
|
|
7
7
|
readonly tags?: readonly string[];
|
|
8
8
|
readonly deprecated?: boolean;
|
|
9
9
|
readonly version?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Operation timeout in milliseconds. Superseded by
|
|
12
|
+
* {@link APIOperation.timeout} when both are present.
|
|
13
|
+
*/
|
|
10
14
|
readonly timeout?: number;
|
|
11
15
|
readonly idempotent?: boolean;
|
|
12
16
|
}
|
|
@@ -18,25 +22,111 @@ export interface APIOperationMetadata {
|
|
|
18
22
|
*/
|
|
19
23
|
export interface APIOperation<TInput = unknown, TOutput = unknown> {
|
|
20
24
|
readonly name: string;
|
|
25
|
+
/**
|
|
26
|
+
* Input schema. When this is a Standard Schema
|
|
27
|
+
* (https://standardschema.dev), the executor validates input against it
|
|
28
|
+
* before invoking the handler; other values are documentation-only.
|
|
29
|
+
*/
|
|
21
30
|
readonly input?: unknown;
|
|
31
|
+
/**
|
|
32
|
+
* Output schema. When this is a Standard Schema
|
|
33
|
+
* (https://standardschema.dev), the executor validates the handler's
|
|
34
|
+
* return value against it and returns the validated (possibly
|
|
35
|
+
* transformed) value as the result data; a handler returning the wrong
|
|
36
|
+
* shape fails with an `APIInternalError` (`expose: false`) rather than
|
|
37
|
+
* leaking through to the transport. Other values are
|
|
38
|
+
* documentation-only.
|
|
39
|
+
*/
|
|
22
40
|
readonly output?: unknown;
|
|
23
41
|
readonly handler: APIHandler<TInput, TOutput>;
|
|
24
42
|
readonly metadata?: APIOperationMetadata;
|
|
43
|
+
/**
|
|
44
|
+
* Operation timeout in milliseconds. Must be a positive, finite integer
|
|
45
|
+
* no greater than {@link MAX_OPERATION_TIMEOUT}. There is no way to
|
|
46
|
+
* disable the deadline: `0` and negative values are rejected by
|
|
47
|
+
* {@link defineOperation} rather than silently running unbounded.
|
|
48
|
+
*/
|
|
25
49
|
readonly timeout?: number;
|
|
26
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* An operation of any input/output type.
|
|
53
|
+
*
|
|
54
|
+
* `APIOperation<unknown, unknown>` is *not* a supertype of a typed
|
|
55
|
+
* operation — a handler taking `{ id: string }` cannot be called with an
|
|
56
|
+
* `unknown` input — so APIs that merely store or catalogue operations
|
|
57
|
+
* (the registry) accept this instead.
|
|
58
|
+
*/
|
|
59
|
+
export type AnyAPIOperation = APIOperation<never, unknown>;
|
|
27
60
|
/**
|
|
28
61
|
* Options for defining an API operation.
|
|
29
62
|
*/
|
|
30
63
|
export interface DefineOperationOptions<TInput = unknown, TOutput = unknown> {
|
|
31
64
|
readonly name: string;
|
|
65
|
+
/** @see {@link APIOperation.input} */
|
|
32
66
|
readonly input?: unknown;
|
|
67
|
+
/** @see {@link APIOperation.output} */
|
|
33
68
|
readonly output?: unknown;
|
|
34
69
|
readonly handler: APIHandler<TInput, TOutput>;
|
|
35
70
|
readonly metadata?: APIOperationMetadata;
|
|
71
|
+
/** @see {@link APIOperation.timeout} */
|
|
36
72
|
readonly timeout?: number;
|
|
37
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Validates an operation timeout.
|
|
76
|
+
*
|
|
77
|
+
* @throws {TypeError} if the value is not a number.
|
|
78
|
+
* @throws {RangeError} if the value is not a positive, finite integer of
|
|
79
|
+
* at most {@link MAX_OPERATION_TIMEOUT} milliseconds.
|
|
80
|
+
*/
|
|
81
|
+
export declare function assertValidTimeout(timeout: unknown, label: string): number;
|
|
82
|
+
/**
|
|
83
|
+
* Resolves the effective timeout for an operation.
|
|
84
|
+
*
|
|
85
|
+
* This is the single owner of the precedence rule
|
|
86
|
+
* (`timeout` > `metadata.timeout` > {@link DEFAULT_OPERATION_TIMEOUT});
|
|
87
|
+
* `defineOperation` and the executor both go through it.
|
|
88
|
+
*
|
|
89
|
+
* Unusable values are skipped rather than disabling the deadline: the
|
|
90
|
+
* first usable candidate wins, and {@link DEFAULT_OPERATION_TIMEOUT}
|
|
91
|
+
* applies when none is. `defineOperation` rejects unusable values
|
|
92
|
+
* outright, so that fallback only applies to hand-rolled `APIOperation`
|
|
93
|
+
* objects.
|
|
94
|
+
*/
|
|
95
|
+
export declare function resolveOperationTimeout(source: {
|
|
96
|
+
readonly timeout?: number;
|
|
97
|
+
readonly metadata?: {
|
|
98
|
+
readonly timeout?: number;
|
|
99
|
+
};
|
|
100
|
+
}): number;
|
|
101
|
+
/**
|
|
102
|
+
* Validates the identity-bearing fields of an operation.
|
|
103
|
+
*
|
|
104
|
+
* Called by `defineOperation` and re-checked by
|
|
105
|
+
* `APIOperationRegistry.register`, since `APIOperation` is a bare
|
|
106
|
+
* interface that callers can satisfy without `defineOperation`.
|
|
107
|
+
*
|
|
108
|
+
* @throws {TypeError} if `name` or `handler` has the wrong type.
|
|
109
|
+
* @throws {RangeError} if `name` is empty, over-long, or contains
|
|
110
|
+
* characters outside `[A-Za-z0-9._:/-]`.
|
|
111
|
+
*/
|
|
112
|
+
export declare function assertValidOperationShape(operation: {
|
|
113
|
+
readonly name?: unknown;
|
|
114
|
+
readonly handler?: unknown;
|
|
115
|
+
}): void;
|
|
116
|
+
/**
|
|
117
|
+
* Deeply freezes an operation's metadata so a registered operation cannot
|
|
118
|
+
* be rewritten process-wide through `metadata.tags` or `metadata.timeout`.
|
|
119
|
+
*/
|
|
120
|
+
export declare function freezeOperationMetadata(metadata: APIOperationMetadata | undefined): APIOperationMetadata | undefined;
|
|
38
121
|
/**
|
|
39
122
|
* Creates a new API operation definition.
|
|
123
|
+
*
|
|
124
|
+
* Validates the definition eagerly — a bad name, a missing handler, or an
|
|
125
|
+
* unusable timeout fails here, at startup, rather than on the first
|
|
126
|
+
* request that reaches the operation.
|
|
127
|
+
*
|
|
128
|
+
* @throws {TypeError} if `name` or `handler` has the wrong type.
|
|
129
|
+
* @throws {RangeError} if `name` or a supplied `timeout` is out of range.
|
|
40
130
|
*/
|
|
41
131
|
export declare function defineOperation<TInput = unknown, TOutput = unknown>(options: DefineOperationOptions<TInput, TOutput>): APIOperation<TInput, TOutput>;
|
|
42
132
|
//# sourceMappingURL=operation.type.d.ts.map
|
|
@@ -1,11 +1,123 @@
|
|
|
1
|
-
import { DEFAULT_OPERATION_TIMEOUT } from "../constants.js";
|
|
1
|
+
import { DEFAULT_OPERATION_TIMEOUT, MAX_OPERATION_NAME_LENGTH, MAX_OPERATION_TIMEOUT, } from "../constants.js";
|
|
2
|
+
const OPERATION_NAME_PATTERN = /^[A-Za-z0-9._:/-]+$/;
|
|
3
|
+
/**
|
|
4
|
+
* Validates an operation timeout.
|
|
5
|
+
*
|
|
6
|
+
* @throws {TypeError} if the value is not a number.
|
|
7
|
+
* @throws {RangeError} if the value is not a positive, finite integer of
|
|
8
|
+
* at most {@link MAX_OPERATION_TIMEOUT} milliseconds.
|
|
9
|
+
*/
|
|
10
|
+
export function assertValidTimeout(timeout, label) {
|
|
11
|
+
if (typeof timeout !== "number") {
|
|
12
|
+
throw new TypeError(`${label} must be a number, received ${typeof timeout}.`);
|
|
13
|
+
}
|
|
14
|
+
if (!Number.isFinite(timeout) || !Number.isInteger(timeout)) {
|
|
15
|
+
throw new RangeError(`${label} must be a finite integer number of milliseconds, received ${String(timeout)}.`);
|
|
16
|
+
}
|
|
17
|
+
if (timeout <= 0) {
|
|
18
|
+
throw new RangeError(`${label} must be greater than 0 ms, received ${timeout}. The deadline cannot be disabled.`);
|
|
19
|
+
}
|
|
20
|
+
if (timeout > MAX_OPERATION_TIMEOUT) {
|
|
21
|
+
throw new RangeError(`${label} must be at most ${MAX_OPERATION_TIMEOUT} ms, received ${timeout}.`);
|
|
22
|
+
}
|
|
23
|
+
return timeout;
|
|
24
|
+
}
|
|
25
|
+
function isUsableTimeout(timeout) {
|
|
26
|
+
return (typeof timeout === "number" &&
|
|
27
|
+
Number.isInteger(timeout) &&
|
|
28
|
+
timeout > 0 &&
|
|
29
|
+
timeout <= MAX_OPERATION_TIMEOUT);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolves the effective timeout for an operation.
|
|
33
|
+
*
|
|
34
|
+
* This is the single owner of the precedence rule
|
|
35
|
+
* (`timeout` > `metadata.timeout` > {@link DEFAULT_OPERATION_TIMEOUT});
|
|
36
|
+
* `defineOperation` and the executor both go through it.
|
|
37
|
+
*
|
|
38
|
+
* Unusable values are skipped rather than disabling the deadline: the
|
|
39
|
+
* first usable candidate wins, and {@link DEFAULT_OPERATION_TIMEOUT}
|
|
40
|
+
* applies when none is. `defineOperation` rejects unusable values
|
|
41
|
+
* outright, so that fallback only applies to hand-rolled `APIOperation`
|
|
42
|
+
* objects.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveOperationTimeout(source) {
|
|
45
|
+
for (const candidate of [source.timeout, source.metadata?.timeout]) {
|
|
46
|
+
if (isUsableTimeout(candidate)) {
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return DEFAULT_OPERATION_TIMEOUT;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Validates the identity-bearing fields of an operation.
|
|
54
|
+
*
|
|
55
|
+
* Called by `defineOperation` and re-checked by
|
|
56
|
+
* `APIOperationRegistry.register`, since `APIOperation` is a bare
|
|
57
|
+
* interface that callers can satisfy without `defineOperation`.
|
|
58
|
+
*
|
|
59
|
+
* @throws {TypeError} if `name` or `handler` has the wrong type.
|
|
60
|
+
* @throws {RangeError} if `name` is empty, over-long, or contains
|
|
61
|
+
* characters outside `[A-Za-z0-9._:/-]`.
|
|
62
|
+
*/
|
|
63
|
+
export function assertValidOperationShape(operation) {
|
|
64
|
+
const { name, handler } = operation;
|
|
65
|
+
if (typeof name !== "string") {
|
|
66
|
+
throw new TypeError(`Operation name must be a string, received ${typeof name}.`);
|
|
67
|
+
}
|
|
68
|
+
if (name.length === 0) {
|
|
69
|
+
throw new RangeError("Operation name must not be empty.");
|
|
70
|
+
}
|
|
71
|
+
if (name.length > MAX_OPERATION_NAME_LENGTH) {
|
|
72
|
+
throw new RangeError(`Operation name must be at most ${MAX_OPERATION_NAME_LENGTH} characters, received ${name.length}.`);
|
|
73
|
+
}
|
|
74
|
+
if (!OPERATION_NAME_PATTERN.test(name)) {
|
|
75
|
+
throw new RangeError(`Operation name "${name}" contains characters outside ${OPERATION_NAME_PATTERN.source}.`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof handler !== "function") {
|
|
78
|
+
throw new TypeError(`Operation "${name}" must have a handler function, received ${typeof handler}.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Deeply freezes an operation's metadata so a registered operation cannot
|
|
83
|
+
* be rewritten process-wide through `metadata.tags` or `metadata.timeout`.
|
|
84
|
+
*/
|
|
85
|
+
export function freezeOperationMetadata(metadata) {
|
|
86
|
+
if (metadata === undefined) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
if (Array.isArray(metadata.tags)) {
|
|
90
|
+
Object.freeze(metadata.tags);
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze(metadata);
|
|
93
|
+
}
|
|
2
94
|
/**
|
|
3
95
|
* Creates a new API operation definition.
|
|
96
|
+
*
|
|
97
|
+
* Validates the definition eagerly — a bad name, a missing handler, or an
|
|
98
|
+
* unusable timeout fails here, at startup, rather than on the first
|
|
99
|
+
* request that reaches the operation.
|
|
100
|
+
*
|
|
101
|
+
* @throws {TypeError} if `name` or `handler` has the wrong type.
|
|
102
|
+
* @throws {RangeError} if `name` or a supplied `timeout` is out of range.
|
|
4
103
|
*/
|
|
5
104
|
export function defineOperation(options) {
|
|
105
|
+
assertValidOperationShape(options);
|
|
106
|
+
if (options.timeout !== undefined) {
|
|
107
|
+
assertValidTimeout(options.timeout, "Operation timeout");
|
|
108
|
+
}
|
|
109
|
+
if (options.metadata?.timeout !== undefined) {
|
|
110
|
+
assertValidTimeout(options.metadata.timeout, "Operation metadata.timeout");
|
|
111
|
+
}
|
|
112
|
+
// Explicit field list rather than `...options`: an operation carries
|
|
113
|
+
// exactly the contract fields, never arbitrary extra properties.
|
|
6
114
|
const operation = {
|
|
7
|
-
|
|
8
|
-
|
|
115
|
+
name: options.name,
|
|
116
|
+
input: options.input,
|
|
117
|
+
output: options.output,
|
|
118
|
+
handler: options.handler,
|
|
119
|
+
metadata: freezeOperationMetadata(options.metadata),
|
|
120
|
+
timeout: resolveOperationTimeout(options),
|
|
9
121
|
};
|
|
10
122
|
return Object.freeze(operation);
|
|
11
123
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import type { APIOperation } from "../operation/operation.type.js";
|
|
1
|
+
import type { AnyAPIOperation, APIOperation } from "../operation/operation.type.js";
|
|
2
2
|
/**
|
|
3
3
|
* Registry for API operations.
|
|
4
4
|
*
|
|
5
5
|
* Enforces uniqueness and provides O(1) lookup by operation name.
|
|
6
|
+
*
|
|
7
|
+
* Every failure leaving this class is an `APIError`, so a transport can
|
|
8
|
+
* map it by `statusCode` / `code` without special-casing the registry.
|
|
6
9
|
*/
|
|
7
10
|
export declare class APIOperationRegistry {
|
|
8
11
|
private readonly operations;
|
|
@@ -10,9 +13,15 @@ export declare class APIOperationRegistry {
|
|
|
10
13
|
/**
|
|
11
14
|
* Registers an operation.
|
|
12
15
|
*
|
|
16
|
+
* The operation and its metadata are frozen on registration, so a
|
|
17
|
+
* registered operation cannot be rewritten through `metadata.tags` or
|
|
18
|
+
* `metadata.timeout` after the fact.
|
|
19
|
+
*
|
|
13
20
|
* @throws {APIDuplicateOperationError} if an operation with the same name is already registered.
|
|
21
|
+
* @throws {APIError} if the registry is frozen.
|
|
22
|
+
* @throws {TypeError | RangeError} if the operation's name or handler is invalid.
|
|
14
23
|
*/
|
|
15
|
-
register(operation:
|
|
24
|
+
register(operation: AnyAPIOperation): void;
|
|
16
25
|
/**
|
|
17
26
|
* Retrieves an operation by name.
|
|
18
27
|
*/
|
|
@@ -23,6 +32,8 @@ export declare class APIOperationRegistry {
|
|
|
23
32
|
has(name: string): boolean;
|
|
24
33
|
/**
|
|
25
34
|
* Retrieves an operation by name or throws.
|
|
35
|
+
*
|
|
36
|
+
* @throws {APIOperationNotFoundError} (404) if no operation is registered under `name`.
|
|
26
37
|
*/
|
|
27
38
|
require(name: string): APIOperation;
|
|
28
39
|
/**
|
|
@@ -35,6 +46,8 @@ export declare class APIOperationRegistry {
|
|
|
35
46
|
findByTag(tag: string): readonly APIOperation[];
|
|
36
47
|
/**
|
|
37
48
|
* Unregisters an operation.
|
|
49
|
+
*
|
|
50
|
+
* @throws {APIError} if the registry is frozen.
|
|
38
51
|
*/
|
|
39
52
|
unregister(name: string): boolean;
|
|
40
53
|
/**
|