@zudojs/api 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -7
- package/dist/api/bindings/cli/apiCli.argv.d.ts +36 -0
- package/dist/api/bindings/cli/apiCli.argv.js +78 -0
- package/dist/api/bindings/cli/apiCli.binding.d.ts +45 -0
- package/dist/api/bindings/cli/apiCli.binding.js +77 -0
- package/dist/api/bindings/cli/apiCli.exitCode.d.ts +31 -0
- package/dist/api/bindings/cli/apiCli.exitCode.js +43 -0
- package/dist/api/bindings/cli/apiCli.values.d.ts +24 -0
- package/dist/api/bindings/cli/apiCli.values.js +71 -0
- package/dist/api/bindings/cli/index.d.ts +12 -0
- package/dist/api/bindings/cli/index.js +9 -0
- package/dist/api/bindings/fetch/apiFetch.handler.d.ts +35 -0
- package/dist/api/bindings/fetch/apiFetch.handler.js +99 -0
- package/dist/api/bindings/fetch/apiFetch.input.d.ts +29 -0
- package/dist/api/bindings/fetch/apiFetch.input.js +74 -0
- package/dist/api/bindings/fetch/index.d.ts +11 -0
- package/dist/api/bindings/fetch/index.js +9 -0
- package/dist/api/bindings/index.d.ts +25 -0
- package/dist/api/bindings/index.js +18 -0
- package/dist/api/bindings/openapi/apiOpenAPI.descriptor.d.ts +37 -0
- package/dist/api/bindings/openapi/apiOpenAPI.descriptor.js +67 -0
- package/dist/api/bindings/openapi/apiOpenAPI.schemas.d.ts +32 -0
- package/dist/api/bindings/openapi/apiOpenAPI.schemas.js +62 -0
- package/dist/api/bindings/openapi/index.d.ts +9 -0
- package/dist/api/bindings/openapi/index.js +8 -0
- package/dist/api/bindings/queue/apiQueue.binding.d.ts +39 -0
- package/dist/api/bindings/queue/apiQueue.binding.js +65 -0
- package/dist/api/bindings/queue/index.d.ts +7 -0
- package/dist/api/bindings/queue/index.js +6 -0
- package/dist/api/bindings/route/apiRoute.resolver.d.ts +33 -0
- package/dist/api/bindings/route/apiRoute.resolver.js +109 -0
- package/dist/api/bindings/route/apiRoute.table.d.ts +53 -0
- package/dist/api/bindings/route/apiRoute.table.js +95 -0
- package/dist/api/bindings/route/apiRoute.type.d.ts +65 -0
- package/dist/api/bindings/route/apiRoute.type.js +8 -0
- package/dist/api/bindings/route/index.d.ts +11 -0
- package/dist/api/bindings/route/index.js +9 -0
- package/dist/api/bindings/rpc/apiRpc.binding.d.ts +46 -0
- package/dist/api/bindings/rpc/apiRpc.binding.js +63 -0
- package/dist/api/bindings/rpc/apiRpc.errors.d.ts +20 -0
- package/dist/api/bindings/rpc/apiRpc.errors.js +64 -0
- package/dist/api/bindings/rpc/index.d.ts +9 -0
- package/dist/api/bindings/rpc/index.js +8 -0
- package/dist/api/bindings/shared/apiBinding.helper.d.ts +44 -0
- package/dist/api/bindings/shared/apiBinding.helper.js +66 -0
- package/dist/api/bindings/shared/apiBinding.type.d.ts +37 -0
- package/dist/api/bindings/shared/apiBinding.type.js +2 -0
- package/dist/api/bindings/shared/apiWireResult.helper.d.ts +47 -0
- package/dist/api/bindings/shared/apiWireResult.helper.js +38 -0
- package/dist/api/bindings/shared/index.d.ts +11 -0
- package/dist/api/bindings/shared/index.js +8 -0
- package/dist/api/context/context.type.d.ts +4 -4
- package/dist/api/context/context.type.js +2 -2
- package/dist/api/context/contextKey.type.d.ts +8 -0
- package/dist/api/context/contextKey.type.js +2 -0
- package/dist/api/executor/executor.core.d.ts +20 -63
- package/dist/api/executor/executor.core.js +36 -191
- package/dist/api/executor/executor.deadline.d.ts +40 -0
- package/dist/api/executor/executor.deadline.js +93 -0
- package/dist/api/executor/executor.issues.d.ts +19 -0
- package/dist/api/executor/executor.issues.js +46 -0
- package/dist/api/executor/executor.normalize.d.ts +16 -0
- package/dist/api/executor/executor.normalize.js +45 -0
- package/dist/api/executor/executor.type.d.ts +33 -0
- package/dist/api/executor/executor.type.js +2 -0
- package/dist/api/executor/executor.validation.d.ts +31 -0
- package/dist/api/executor/executor.validation.js +58 -0
- package/dist/api/executor/index.d.ts +9 -2
- package/dist/api/executor/index.js +8 -1
- package/dist/api/handler/handler.type.d.ts +7 -4
- package/dist/api/interceptors/interceptor.type.d.ts +6 -5
- package/dist/api/operation/operation.type.d.ts +18 -1
- package/dist/api/operation/operation.type.js +5 -11
- package/dist/api/operation/operationSchema.type.d.ts +56 -0
- package/dist/api/operation/operationSchema.type.js +2 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +7 -2
- package/package.json +16 -6
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { findUnsafeKey } from "@zudojs/security";
|
|
2
|
+
import { APIExecutor } from "../../executor/executor.core.js";
|
|
3
|
+
import { normalizeAPIError } from "../../executor/executor.core.js";
|
|
4
|
+
import { APIOperationRegistry } from "../../registry/operationRegistry.core.js";
|
|
5
|
+
import { assertValidOperationShape } from "../../operation/operation.type.js";
|
|
6
|
+
import { apiFailure } from "../../result/apiResult.type.js";
|
|
7
|
+
import { APIValidationError } from "../../errors/index.js";
|
|
8
|
+
import { createAPIContext, isValidRequestId, normalizeRequestId, } from "../../context/context.type.js";
|
|
9
|
+
import { CorrelationIdContextKey, TransportContextKey, } from "../../context/contextKey.type.js";
|
|
10
|
+
/**
|
|
11
|
+
* Lists the operations of a source, validating a plain list the way the
|
|
12
|
+
* registry validates on `register`.
|
|
13
|
+
*
|
|
14
|
+
* @throws {TypeError | RangeError} for a malformed operation or a
|
|
15
|
+
* duplicated name in a list.
|
|
16
|
+
*/
|
|
17
|
+
export function listOperations(source) {
|
|
18
|
+
if (source instanceof APIOperationRegistry) {
|
|
19
|
+
return source.getAll();
|
|
20
|
+
}
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (const operation of source) {
|
|
23
|
+
assertValidOperationShape(operation);
|
|
24
|
+
if (seen.has(operation.name)) {
|
|
25
|
+
throw new RangeError(`Operation "${operation.name}" is listed more than once.`);
|
|
26
|
+
}
|
|
27
|
+
seen.add(operation.name);
|
|
28
|
+
}
|
|
29
|
+
return source;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Creates the function every binding runs calls through, so the context,
|
|
33
|
+
* interceptors, error normalization and internal-error reporting are
|
|
34
|
+
* identical over HTTP, RPC, queues and the CLI. Input carrying a
|
|
35
|
+
* `__proto__`, `constructor` or `prototype` key anywhere is refused with
|
|
36
|
+
* an `APIValidationError` after `state` runs and before the operation
|
|
37
|
+
* does. `run` never throws.
|
|
38
|
+
*/
|
|
39
|
+
export function createOperationRunner(options) {
|
|
40
|
+
const executor = options.executor ?? new APIExecutor();
|
|
41
|
+
return async (call) => {
|
|
42
|
+
const requestId = normalizeRequestId(call.requestId);
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
const state = options.state === undefined ? {} : await options.state(call.source, call.operation);
|
|
46
|
+
const context = createAPIContext(requestId, state, call.signal);
|
|
47
|
+
context.set(TransportContextKey, call.transport);
|
|
48
|
+
if (isValidRequestId(call.correlationId)) {
|
|
49
|
+
context.set(CorrelationIdContextKey, call.correlationId);
|
|
50
|
+
}
|
|
51
|
+
const unsafe = findUnsafeKey(call.input);
|
|
52
|
+
result =
|
|
53
|
+
unsafe === undefined
|
|
54
|
+
? await executor.execute(call.operation, call.input, context)
|
|
55
|
+
: apiFailure(new APIValidationError(`Input contains the forbidden key "${unsafe}".`));
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
result = apiFailure(normalizeAPIError(error, call.operation.name));
|
|
59
|
+
}
|
|
60
|
+
if (!result.ok && !result.error.expose) {
|
|
61
|
+
options.onInternalError?.(result.error, requestId);
|
|
62
|
+
}
|
|
63
|
+
return { result, requestId };
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=apiBinding.helper.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { APIExecutor } from "../../executor/executor.core.js";
|
|
2
|
+
import type { APIOperationRegistry } from "../../registry/operationRegistry.core.js";
|
|
3
|
+
import type { AnyAPIOperation, APIOperation } from "../../operation/operation.type.js";
|
|
4
|
+
import type { APIError } from "../../errors/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* The operations a binding exposes: a registry (freeze it first so the
|
|
7
|
+
* set cannot change underneath a live binding) or a plain list.
|
|
8
|
+
*/
|
|
9
|
+
export type APIOperationSource = APIOperationRegistry | readonly AnyAPIOperation[];
|
|
10
|
+
/**
|
|
11
|
+
* Options every binding accepts.
|
|
12
|
+
*
|
|
13
|
+
* `TSource` is what the transport hands the binding for one call: the
|
|
14
|
+
* `Request` for HTTP, the `RPCContext` for RPC, the `Job` for a queue, the
|
|
15
|
+
* parsed invocation for the CLI.
|
|
16
|
+
*/
|
|
17
|
+
export interface APIBindingOptions<TSource> {
|
|
18
|
+
/**
|
|
19
|
+
* Executor that runs every operation. Configure interceptors on it; they
|
|
20
|
+
* apply identically over every binding. Defaults to `new APIExecutor()`.
|
|
21
|
+
*/
|
|
22
|
+
readonly executor?: APIExecutor;
|
|
23
|
+
/**
|
|
24
|
+
* Builds `context.state` for one call — typically the authenticated
|
|
25
|
+
* principal derived from the transport. Throw an `APIError` (for example
|
|
26
|
+
* `APIAuthenticationError`) to refuse the call with that error; any
|
|
27
|
+
* other thrown value becomes an internal error. Defaults to `{}`.
|
|
28
|
+
*/
|
|
29
|
+
readonly state?: (source: TSource, operation: APIOperation) => unknown;
|
|
30
|
+
/**
|
|
31
|
+
* Receives every failure that was not exposed to the caller (the error
|
|
32
|
+
* still carries its `cause`), with the call's request id — the place to
|
|
33
|
+
* log what the caller was only told was "an internal error".
|
|
34
|
+
*/
|
|
35
|
+
readonly onInternalError?: (error: APIError, requestId: string) => void;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=apiBinding.type.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { APIResult } from "../../result/apiResult.type.js";
|
|
2
|
+
import type { APIError } from "../../errors/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Message sent in place of any error that was not built to be exposed.
|
|
5
|
+
*/
|
|
6
|
+
export declare const API_INTERNAL_ERROR_MESSAGE = "An internal error occurred.";
|
|
7
|
+
/**
|
|
8
|
+
* The client-safe form of an `APIError`, identical across bindings: the
|
|
9
|
+
* HTTP body, the CLI's stderr, and what a queue records all carry it.
|
|
10
|
+
*
|
|
11
|
+
* `message` is the error's own message only when the error is
|
|
12
|
+
* `expose: true`; otherwise it is {@link API_INTERNAL_ERROR_MESSAGE}.
|
|
13
|
+
* `issues` appears only on validation failures and holds the executor's
|
|
14
|
+
* already-redacted `"<path>: invalid"` entries. Stack traces, causes and
|
|
15
|
+
* metadata are never included.
|
|
16
|
+
*/
|
|
17
|
+
export interface APIWireError {
|
|
18
|
+
readonly code: string;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly statusCode: number;
|
|
21
|
+
readonly requestId: string;
|
|
22
|
+
readonly issues?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The client-safe form of an `APIResult`.
|
|
26
|
+
*/
|
|
27
|
+
export type APIWireResult<T = unknown> = {
|
|
28
|
+
readonly ok: true;
|
|
29
|
+
readonly data: T;
|
|
30
|
+
} | {
|
|
31
|
+
readonly ok: false;
|
|
32
|
+
readonly error: APIWireError;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The HTTP-style status of an error: its `statusCode` when that is an
|
|
36
|
+
* integer in 400–599, otherwise 500.
|
|
37
|
+
*/
|
|
38
|
+
export declare function apiErrorStatus(error: APIError): number;
|
|
39
|
+
/**
|
|
40
|
+
* Converts an `APIError` into its client-safe {@link APIWireError}.
|
|
41
|
+
*/
|
|
42
|
+
export declare function toApiWireError(error: APIError, requestId: string): APIWireError;
|
|
43
|
+
/**
|
|
44
|
+
* Converts an executor result into its client-safe {@link APIWireResult}.
|
|
45
|
+
*/
|
|
46
|
+
export declare function toApiWireResult<T>(result: APIResult<T>, requestId: string): APIWireResult<T>;
|
|
47
|
+
//# sourceMappingURL=apiWireResult.helper.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { APIValidationError } from "../../errors/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Message sent in place of any error that was not built to be exposed.
|
|
4
|
+
*/
|
|
5
|
+
export const API_INTERNAL_ERROR_MESSAGE = "An internal error occurred.";
|
|
6
|
+
/**
|
|
7
|
+
* The HTTP-style status of an error: its `statusCode` when that is an
|
|
8
|
+
* integer in 400–599, otherwise 500.
|
|
9
|
+
*/
|
|
10
|
+
export function apiErrorStatus(error) {
|
|
11
|
+
const status = error.statusCode;
|
|
12
|
+
return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Converts an `APIError` into its client-safe {@link APIWireError}.
|
|
16
|
+
*/
|
|
17
|
+
export function toApiWireError(error, requestId) {
|
|
18
|
+
const exposed = error.expose === true;
|
|
19
|
+
const issues = exposed && error instanceof APIValidationError && error.issues.length > 0
|
|
20
|
+
? error.issues
|
|
21
|
+
: undefined;
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
code: String(error.code),
|
|
24
|
+
message: exposed ? error.message : API_INTERNAL_ERROR_MESSAGE,
|
|
25
|
+
statusCode: apiErrorStatus(error),
|
|
26
|
+
requestId,
|
|
27
|
+
...(issues !== undefined ? { issues } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Converts an executor result into its client-safe {@link APIWireResult}.
|
|
32
|
+
*/
|
|
33
|
+
export function toApiWireResult(result, requestId) {
|
|
34
|
+
return result.ok
|
|
35
|
+
? Object.freeze({ ok: true, data: result.data })
|
|
36
|
+
: Object.freeze({ ok: false, error: toApiWireError(result.error, requestId) });
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=apiWireResult.helper.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime shared by every binding: the operation source and options
|
|
3
|
+
* types, the runner that builds the context and executes a call, and the
|
|
4
|
+
* client-safe wire form of results and errors.
|
|
5
|
+
*/
|
|
6
|
+
export type { APIBindingOptions, APIOperationSource } from "./apiBinding.type.js";
|
|
7
|
+
export type { APIBoundCall, APIBoundOutcome } from "./apiBinding.helper.js";
|
|
8
|
+
export { createOperationRunner, listOperations } from "./apiBinding.helper.js";
|
|
9
|
+
export type { APIWireError, APIWireResult } from "./apiWireResult.helper.js";
|
|
10
|
+
export { API_INTERNAL_ERROR_MESSAGE, apiErrorStatus, toApiWireError, toApiWireResult, } from "./apiWireResult.helper.js";
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime shared by every binding: the operation source and options
|
|
3
|
+
* types, the runner that builds the context and executes a call, and the
|
|
4
|
+
* client-safe wire form of results and errors.
|
|
5
|
+
*/
|
|
6
|
+
export { createOperationRunner, listOperations } from "./apiBinding.helper.js";
|
|
7
|
+
export { API_INTERNAL_ERROR_MESSAGE, apiErrorStatus, toApiWireError, toApiWireResult, } from "./apiWireResult.helper.js";
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { APIContextKey } from "./contextKey.type.js";
|
|
2
|
-
import { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, createContextKey } from "./contextKey.type.js";
|
|
1
|
+
import type { APIContextKey, APITransportKind } from "./contextKey.type.js";
|
|
2
|
+
import { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, TransportContextKey, createContextKey } from "./contextKey.type.js";
|
|
3
3
|
/**
|
|
4
4
|
* Generic API context that flows through the execution pipeline.
|
|
5
5
|
*
|
|
@@ -55,6 +55,6 @@ export declare function normalizeRequestId(value: unknown): string;
|
|
|
55
55
|
* {@link normalizeRequestId} for client-supplied values.
|
|
56
56
|
*/
|
|
57
57
|
export declare function createAPIContext<TState = unknown>(requestId: string, state: TState, signal?: AbortSignal): APIContext<TState>;
|
|
58
|
-
export type { APIContextKey };
|
|
59
|
-
export { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, createContextKey, };
|
|
58
|
+
export type { APIContextKey, APITransportKind };
|
|
59
|
+
export { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, TransportContextKey, createContextKey, };
|
|
60
60
|
//# sourceMappingURL=context.type.d.ts.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { MAX_REQUEST_ID_LENGTH } from "../constants.js";
|
|
3
|
-
import { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, createContextKey, } from "./contextKey.type.js";
|
|
3
|
+
import { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, TransportContextKey, createContextKey, } from "./contextKey.type.js";
|
|
4
4
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
|
|
5
5
|
/**
|
|
6
6
|
* Determines whether a value is usable as a request id.
|
|
@@ -102,5 +102,5 @@ function createReadonlyMapView(source) {
|
|
|
102
102
|
};
|
|
103
103
|
return Object.freeze(view);
|
|
104
104
|
}
|
|
105
|
-
export { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, createContextKey, };
|
|
105
|
+
export { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, TransportContextKey, createContextKey, };
|
|
106
106
|
//# sourceMappingURL=context.type.js.map
|
|
@@ -38,4 +38,12 @@ export declare const CorrelationIdContextKey: APIContextKey<string>;
|
|
|
38
38
|
export declare const TenantIdContextKey: APIContextKey<string>;
|
|
39
39
|
export declare const UserIdContextKey: APIContextKey<string>;
|
|
40
40
|
export declare const StartTimeContextKey: APIContextKey<number>;
|
|
41
|
+
/**
|
|
42
|
+
* The binding an operation was invoked through. Set by every built-in
|
|
43
|
+
* binding (`createApiFetchHandler`, the RPC, queue and CLI bindings) under
|
|
44
|
+
* {@link TransportContextKey}, so an interceptor can vary its behaviour by
|
|
45
|
+
* entry point without the handler knowing.
|
|
46
|
+
*/
|
|
47
|
+
export type APITransportKind = "http" | "rpc" | "queue" | "cli";
|
|
48
|
+
export declare const TransportContextKey: APIContextKey<APITransportKind>;
|
|
41
49
|
//# sourceMappingURL=contextKey.type.d.ts.map
|
|
@@ -34,4 +34,6 @@ export const UserIdContextKey =
|
|
|
34
34
|
/* #__PURE__ */ createContextKey("userId");
|
|
35
35
|
export const StartTimeContextKey =
|
|
36
36
|
/* #__PURE__ */ createContextKey("startTime");
|
|
37
|
+
export const TransportContextKey =
|
|
38
|
+
/* #__PURE__ */ createContextKey("transport");
|
|
37
39
|
//# sourceMappingURL=contextKey.type.js.map
|
|
@@ -2,53 +2,10 @@ import type { APIContext } from "../context/context.type.js";
|
|
|
2
2
|
import type { APIResult } from "../result/apiResult.type.js";
|
|
3
3
|
import type { APIOperation } from "../operation/operation.type.js";
|
|
4
4
|
import type { APIInterceptor } from "../interceptors/interceptor.type.js";
|
|
5
|
-
import {
|
|
5
|
+
import type { APIExecutorOptions } from "./executor.type.js";
|
|
6
|
+
export { normalizeAPIError } from "./executor.normalize.js";
|
|
7
|
+
export type { APIExecutorOptions } from "./executor.type.js";
|
|
6
8
|
export type { APIExecutionContext } from "../interceptors/interceptor.type.js";
|
|
7
|
-
/**
|
|
8
|
-
* Error normalizer for converting unknown errors into APIError instances.
|
|
9
|
-
*
|
|
10
|
-
* APIErrors pass through untouched. Everything else is wrapped in an
|
|
11
|
-
* `APIInternalError` (`expose: false`) carrying a generic message; the
|
|
12
|
-
* original error is preserved on `cause` for logging.
|
|
13
|
-
*
|
|
14
|
-
* The wrapper's own message is deliberately *not* a copy of the original.
|
|
15
|
-
* `BaseError.toJSON()` in `@zudojs/errors` 0.1.0 emits `message`, `stack`
|
|
16
|
-
* and the serialized `cause` regardless of `expose`, so a transport doing
|
|
17
|
-
* `res.json(result.error)` would otherwise ship the raw driver message
|
|
18
|
-
* (connection strings, constraint names, file paths, tokens) to a client.
|
|
19
|
-
*/
|
|
20
|
-
export declare function normalizeAPIError(error: unknown, operationName?: string): APIError;
|
|
21
|
-
/**
|
|
22
|
-
* Options for {@link APIExecutor}.
|
|
23
|
-
*/
|
|
24
|
-
export interface APIExecutorOptions {
|
|
25
|
-
/** Interceptor pipeline, outermost first. */
|
|
26
|
-
readonly interceptors?: readonly APIInterceptor[];
|
|
27
|
-
/**
|
|
28
|
-
* Whether raw schema issue messages are copied into the client-facing
|
|
29
|
-
* `APIValidationError` (which is `expose: true`).
|
|
30
|
-
*
|
|
31
|
-
* Default `false`. Schema messages routinely interpolate the received
|
|
32
|
-
* value — Zod's built-in messages do for several checks, and most
|
|
33
|
-
* hand-written `message:` strings do — which would put submitted
|
|
34
|
-
* secrets straight into a 422 body. With the default, clients receive
|
|
35
|
-
* one entry per failing path (`"user.email: invalid"`) naming *where*
|
|
36
|
-
* validation failed but never echoing *what* was submitted.
|
|
37
|
-
*
|
|
38
|
-
* Set to `true` only when every schema in the process is known to
|
|
39
|
-
* produce value-free messages.
|
|
40
|
-
*/
|
|
41
|
-
readonly exposeValidationMessages?: boolean;
|
|
42
|
-
/**
|
|
43
|
-
* Maximum number of validation issues carried on a single
|
|
44
|
-
* `APIValidationError`. Defaults to {@link MAX_VALIDATION_ISSUES}.
|
|
45
|
-
*
|
|
46
|
-
* A schema over a large array emits one issue per failing element, so
|
|
47
|
-
* an uncapped list is an amplification vector: the executor is the
|
|
48
|
-
* layer on the untrusted-input boundary and caps it here.
|
|
49
|
-
*/
|
|
50
|
-
readonly maxValidationIssues?: number;
|
|
51
|
-
}
|
|
52
9
|
/**
|
|
53
10
|
* Executes an API operation through its interceptor pipeline.
|
|
54
11
|
*
|
|
@@ -57,6 +14,9 @@ export interface APIExecutorOptions {
|
|
|
57
14
|
* schema (a Standard Schema or a `safeParse` schema such as
|
|
58
15
|
* `@zudojs/schema`). A declared schema of any other kind fails closed
|
|
59
16
|
* with an `APIInternalError`; it is never skipped.
|
|
17
|
+
*
|
|
18
|
+
* Interceptors wrap everything, input validation included: validation
|
|
19
|
+
* runs innermost, on the input the interceptors pass to the handler.
|
|
60
20
|
*/
|
|
61
21
|
export declare class APIExecutor {
|
|
62
22
|
private readonly interceptors;
|
|
@@ -65,28 +25,25 @@ export declare class APIExecutor {
|
|
|
65
25
|
constructor(optionsOrInterceptors?: readonly APIInterceptor[] | APIExecutorOptions);
|
|
66
26
|
/**
|
|
67
27
|
* Executes an operation with the given input and context.
|
|
28
|
+
*
|
|
29
|
+
* Order: the interceptors run first, outermost first; input validation
|
|
30
|
+
* runs innermost, immediately before the handler, on the input the
|
|
31
|
+
* interceptors finally pass. So an authentication interceptor refuses an
|
|
32
|
+
* anonymous call before its input is inspected (a 401, not a 422 that
|
|
33
|
+
* describes the schema), logging, metrics and rate-limit interceptors see
|
|
34
|
+
* invalid calls too, and an interceptor that replaces `context.input`
|
|
35
|
+
* cannot bypass the schema.
|
|
68
36
|
*/
|
|
69
37
|
execute<TInput = unknown, TOutput = unknown>(operation: APIOperation<TInput, TOutput>, input: TInput, context: APIContext): Promise<APIResult<TOutput>>;
|
|
70
38
|
/**
|
|
71
|
-
*
|
|
72
|
-
* validates the handler's output against
|
|
73
|
-
|
|
74
|
-
private invokeHandler;
|
|
75
|
-
/**
|
|
76
|
-
* Validates handler output against `operation.output`.
|
|
39
|
+
* Validates the input, then invokes the operation handler under its
|
|
40
|
+
* timeout and abort signal, and validates the handler's output against
|
|
41
|
+
* `operation.output`.
|
|
77
42
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* (500, `expose: false`) naming only the failing paths — never the
|
|
81
|
-
* offending values, which are exactly the fields (password hashes,
|
|
82
|
-
* internal audit columns) that should not reach a client.
|
|
43
|
+
* The handler receives a context whose `signal` aborts when the deadline
|
|
44
|
+
* elapses or the caller's signal aborts, so it can stop its work.
|
|
83
45
|
*/
|
|
84
|
-
private
|
|
85
|
-
/**
|
|
86
|
-
* Converts schema issues into the capped, redacted list carried on the
|
|
87
|
-
* client-facing `APIValidationError`.
|
|
88
|
-
*/
|
|
89
|
-
private clientIssues;
|
|
46
|
+
private invokeHandler;
|
|
90
47
|
/**
|
|
91
48
|
* Runs the interceptor pipeline (Koa-style dispatch).
|
|
92
49
|
*
|
|
@@ -1,51 +1,11 @@
|
|
|
1
|
-
import { apiFailure
|
|
1
|
+
import { apiFailure } from "../result/apiResult.type.js";
|
|
2
2
|
import { resolveOperationTimeout } from "../operation/operation.type.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* APIErrors pass through untouched. Everything else is wrapped in an
|
|
10
|
-
* `APIInternalError` (`expose: false`) carrying a generic message; the
|
|
11
|
-
* original error is preserved on `cause` for logging.
|
|
12
|
-
*
|
|
13
|
-
* The wrapper's own message is deliberately *not* a copy of the original.
|
|
14
|
-
* `BaseError.toJSON()` in `@zudojs/errors` 0.1.0 emits `message`, `stack`
|
|
15
|
-
* and the serialized `cause` regardless of `expose`, so a transport doing
|
|
16
|
-
* `res.json(result.error)` would otherwise ship the raw driver message
|
|
17
|
-
* (connection strings, constraint names, file paths, tokens) to a client.
|
|
18
|
-
*/
|
|
19
|
-
export function normalizeAPIError(error, operationName) {
|
|
20
|
-
if (isAPIError(error)) {
|
|
21
|
-
return error;
|
|
22
|
-
}
|
|
23
|
-
const where = operationName !== undefined && operationName !== ""
|
|
24
|
-
? `operation "${operationName}"`
|
|
25
|
-
: "an API operation";
|
|
26
|
-
const wrapped = error instanceof Error
|
|
27
|
-
? new APIInternalError(`An unexpected internal error occurred in ${where}.`)
|
|
28
|
-
: new APIInternalError(`A non-error value (${describeValueType(error)}) was thrown in ${where}.`);
|
|
29
|
-
// `APIInternalError`'s subclass constructor forwards only
|
|
30
|
-
// `{ endpoint, method }`, so `cause` cannot be passed through it.
|
|
31
|
-
// `APIError` / `createAPIError` *do* accept `cause`, but constructing
|
|
32
|
-
// through them would lose the `APIInternalError` class identity that
|
|
33
|
-
// consumers match on. `cause` is a declared writable class field on
|
|
34
|
-
// `BaseError`, so assigning it after construction is equivalent for
|
|
35
|
-
// `toJSON()` and for `error.cause` reads; only the native `[[cause]]`
|
|
36
|
-
// slot differs.
|
|
37
|
-
wrapped.cause = error;
|
|
38
|
-
return wrapped;
|
|
39
|
-
}
|
|
40
|
-
function describeValueType(value) {
|
|
41
|
-
if (value === null) {
|
|
42
|
-
return "null";
|
|
43
|
-
}
|
|
44
|
-
if (Array.isArray(value)) {
|
|
45
|
-
return "array";
|
|
46
|
-
}
|
|
47
|
-
return typeof value;
|
|
48
|
-
}
|
|
3
|
+
import { APIInternalError } from "../errors/index.js";
|
|
4
|
+
import { MAX_INTERCEPTORS, MAX_VALIDATION_ISSUES } from "../constants.js";
|
|
5
|
+
import { abortedError, withContextSignal, withDeadline, } from "./executor.deadline.js";
|
|
6
|
+
import { normalizeAPIError } from "./executor.normalize.js";
|
|
7
|
+
import { validateOperationInput, validateOperationOutput, } from "./executor.validation.js";
|
|
8
|
+
export { normalizeAPIError } from "./executor.normalize.js";
|
|
49
9
|
/**
|
|
50
10
|
* Executes an API operation through its interceptor pipeline.
|
|
51
11
|
*
|
|
@@ -54,6 +14,9 @@ function describeValueType(value) {
|
|
|
54
14
|
* schema (a Standard Schema or a `safeParse` schema such as
|
|
55
15
|
* `@zudojs/schema`). A declared schema of any other kind fails closed
|
|
56
16
|
* with an `APIInternalError`; it is never skipped.
|
|
17
|
+
*
|
|
18
|
+
* Interceptors wrap everything, input validation included: validation
|
|
19
|
+
* runs innermost, on the input the interceptors pass to the handler.
|
|
57
20
|
*/
|
|
58
21
|
export class APIExecutor {
|
|
59
22
|
interceptors;
|
|
@@ -79,32 +42,27 @@ export class APIExecutor {
|
|
|
79
42
|
}
|
|
80
43
|
/**
|
|
81
44
|
* Executes an operation with the given input and context.
|
|
45
|
+
*
|
|
46
|
+
* Order: the interceptors run first, outermost first; input validation
|
|
47
|
+
* runs innermost, immediately before the handler, on the input the
|
|
48
|
+
* interceptors finally pass. So an authentication interceptor refuses an
|
|
49
|
+
* anonymous call before its input is inspected (a 401, not a 422 that
|
|
50
|
+
* describes the schema), logging, metrics and rate-limit interceptors see
|
|
51
|
+
* invalid calls too, and an interceptor that replaces `context.input`
|
|
52
|
+
* cannot bypass the schema.
|
|
82
53
|
*/
|
|
83
54
|
async execute(operation, input, context) {
|
|
84
55
|
if (context.signal?.aborted) {
|
|
85
56
|
return apiFailure(abortedError(operation.name));
|
|
86
57
|
}
|
|
87
|
-
let effectiveInput = input;
|
|
88
|
-
if (operation.input !== undefined) {
|
|
89
|
-
try {
|
|
90
|
-
const validation = await validateWithSchema(operation.input, input);
|
|
91
|
-
if (!validation.ok) {
|
|
92
|
-
return apiFailure(new APIValidationError(`Invalid input for operation "${operation.name}".`, this.clientIssues(validation.issues)));
|
|
93
|
-
}
|
|
94
|
-
effectiveInput = validation.value;
|
|
95
|
-
}
|
|
96
|
-
catch (error) {
|
|
97
|
-
return apiFailure(normalizeAPIError(error, operation.name));
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
58
|
const executionContext = {
|
|
101
59
|
operation,
|
|
102
|
-
input
|
|
60
|
+
input,
|
|
103
61
|
context,
|
|
104
62
|
};
|
|
105
63
|
// Reads `executionContext.input` at call time, so an interceptor that
|
|
106
|
-
// replaces the input before calling `next()`
|
|
107
|
-
// handler receives.
|
|
64
|
+
// replaces the input before calling `next()` changes what is validated
|
|
65
|
+
// and what the handler receives.
|
|
108
66
|
const executeHandler = () => this.invokeHandler(operation, executionContext.input, context);
|
|
109
67
|
try {
|
|
110
68
|
return await this.runPipeline(executionContext, executeHandler);
|
|
@@ -114,66 +72,33 @@ export class APIExecutor {
|
|
|
114
72
|
}
|
|
115
73
|
}
|
|
116
74
|
/**
|
|
117
|
-
*
|
|
118
|
-
* validates the handler's output against
|
|
75
|
+
* Validates the input, then invokes the operation handler under its
|
|
76
|
+
* timeout and abort signal, and validates the handler's output against
|
|
77
|
+
* `operation.output`.
|
|
78
|
+
*
|
|
79
|
+
* The handler receives a context whose `signal` aborts when the deadline
|
|
80
|
+
* elapses or the caller's signal aborts, so it can stop its work.
|
|
119
81
|
*/
|
|
120
82
|
async invokeHandler(operation, input, context) {
|
|
121
83
|
if (context.signal?.aborted) {
|
|
122
84
|
return apiFailure(abortedError(operation.name));
|
|
123
85
|
}
|
|
86
|
+
const validated = await validateOperationInput(operation, input, {
|
|
87
|
+
maxIssues: this.maxValidationIssues,
|
|
88
|
+
exposeMessages: this.exposeValidationMessages,
|
|
89
|
+
});
|
|
90
|
+
if (!validated.ok) {
|
|
91
|
+
return apiFailure(validated.error);
|
|
92
|
+
}
|
|
124
93
|
const timeoutMs = resolveOperationTimeout(operation);
|
|
125
94
|
let output;
|
|
126
95
|
try {
|
|
127
|
-
output = await withDeadline(operation.handler(
|
|
128
|
-
}
|
|
129
|
-
catch (error) {
|
|
130
|
-
return apiFailure(normalizeAPIError(error, operation.name));
|
|
131
|
-
}
|
|
132
|
-
return this.validateOutput(operation, output);
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Validates handler output against `operation.output`.
|
|
136
|
-
*
|
|
137
|
-
* A response that does not match its declared schema is a server bug,
|
|
138
|
-
* not a client mistake, so failures surface as an `APIInternalError`
|
|
139
|
-
* (500, `expose: false`) naming only the failing paths — never the
|
|
140
|
-
* offending values, which are exactly the fields (password hashes,
|
|
141
|
-
* internal audit columns) that should not reach a client.
|
|
142
|
-
*/
|
|
143
|
-
async validateOutput(operation, output) {
|
|
144
|
-
if (operation.output === undefined) {
|
|
145
|
-
return apiSuccess(output);
|
|
146
|
-
}
|
|
147
|
-
let validation;
|
|
148
|
-
try {
|
|
149
|
-
validation = await validateWithSchema(operation.output, output);
|
|
96
|
+
output = await withDeadline((signal) => operation.handler(validated.data, withContextSignal(context, signal)), timeoutMs, operation.name, context.signal);
|
|
150
97
|
}
|
|
151
98
|
catch (error) {
|
|
152
99
|
return apiFailure(normalizeAPIError(error, operation.name));
|
|
153
100
|
}
|
|
154
|
-
|
|
155
|
-
const paths = validation.issues
|
|
156
|
-
.slice(0, this.maxValidationIssues)
|
|
157
|
-
.map(formatIssuePath)
|
|
158
|
-
.join(", ");
|
|
159
|
-
return apiFailure(new APIInternalError(`Invalid output for operation "${operation.name}" at: ${paths}.`));
|
|
160
|
-
}
|
|
161
|
-
return apiSuccess(validation.value);
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Converts schema issues into the capped, redacted list carried on the
|
|
165
|
-
* client-facing `APIValidationError`.
|
|
166
|
-
*/
|
|
167
|
-
clientIssues(issues) {
|
|
168
|
-
const limit = this.maxValidationIssues;
|
|
169
|
-
const shown = issues.slice(0, limit).map((issue) => this.exposeValidationMessages
|
|
170
|
-
? truncate(issue.message, MAX_VALIDATION_ISSUE_LENGTH)
|
|
171
|
-
: `${formatIssuePath(issue)}: invalid`);
|
|
172
|
-
const omitted = issues.length - shown.length;
|
|
173
|
-
if (omitted > 0) {
|
|
174
|
-
shown.push(`… and ${omitted} more issue(s) omitted.`);
|
|
175
|
-
}
|
|
176
|
-
return shown;
|
|
101
|
+
return validateOperationOutput(operation, output, this.maxValidationIssues);
|
|
177
102
|
}
|
|
178
103
|
/**
|
|
179
104
|
* Runs the interceptor pipeline (Koa-style dispatch).
|
|
@@ -208,84 +133,4 @@ export class APIExecutor {
|
|
|
208
133
|
return dispatch(0);
|
|
209
134
|
}
|
|
210
135
|
}
|
|
211
|
-
// ─── Internal helpers ─────────────────────────────────────────────────────
|
|
212
|
-
function truncate(value, max) {
|
|
213
|
-
const text = typeof value === "string" ? value : String(value);
|
|
214
|
-
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Renders a Standard Schema issue path as a dotted string. Path segments
|
|
218
|
-
* are field names, never submitted values, so they are safe to expose.
|
|
219
|
-
*/
|
|
220
|
-
function formatIssuePath(issue) {
|
|
221
|
-
const path = issue.path;
|
|
222
|
-
if (path === undefined || path.length === 0) {
|
|
223
|
-
return "(root)";
|
|
224
|
-
}
|
|
225
|
-
return path
|
|
226
|
-
.map((segment) => {
|
|
227
|
-
const key = typeof segment === "object" && segment !== null && "key" in segment
|
|
228
|
-
? segment.key
|
|
229
|
-
: segment;
|
|
230
|
-
return truncate(String(key), 64);
|
|
231
|
-
})
|
|
232
|
-
.join(".");
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* Error for an execution cancelled by the caller's `AbortSignal`.
|
|
236
|
-
*
|
|
237
|
-
* Carries `ErrorCode.OPERATION_CANCELLED` — branch on that, not on the
|
|
238
|
-
* status code. `statusCode` is 499, an nginx convention ("Client Closed
|
|
239
|
-
* Request") rather than an IANA status; this package is
|
|
240
|
-
* transport-agnostic, so each adapter should map the *code* onto whatever
|
|
241
|
-
* its protocol calls "cancelled" (gRPC `CANCELLED`, a dropped queue
|
|
242
|
-
* message, a non-zero CLI exit) rather than passing 499 to the wire.
|
|
243
|
-
*/
|
|
244
|
-
function abortedError(operationName) {
|
|
245
|
-
return createAPIError(`Operation "${operationName}" was aborted.`, {
|
|
246
|
-
code: ErrorCode.OPERATION_CANCELLED,
|
|
247
|
-
statusCode: 499,
|
|
248
|
-
expose: true,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* Awaits a handler promise, rejecting when the timeout elapses or the
|
|
253
|
-
* abort signal fires. The handler itself keeps running (promises are not
|
|
254
|
-
* cancellable), but the caller stops waiting and resources are released.
|
|
255
|
-
*
|
|
256
|
-
* `timeoutMs` is always a positive integer here — `defineOperation`
|
|
257
|
-
* rejects anything else and `resolveOperationTimeout` substitutes the
|
|
258
|
-
* default for hand-rolled operations — so the deadline can never be
|
|
259
|
-
* silently disabled.
|
|
260
|
-
*/
|
|
261
|
-
function withDeadline(promise, timeoutMs, operationName, signal) {
|
|
262
|
-
return new Promise((resolve, reject) => {
|
|
263
|
-
let timer;
|
|
264
|
-
const cleanup = () => {
|
|
265
|
-
if (timer !== undefined)
|
|
266
|
-
clearTimeout(timer);
|
|
267
|
-
signal?.removeEventListener("abort", onAbort);
|
|
268
|
-
};
|
|
269
|
-
const onAbort = () => {
|
|
270
|
-
cleanup();
|
|
271
|
-
reject(abortedError(operationName));
|
|
272
|
-
};
|
|
273
|
-
timer = setTimeout(() => {
|
|
274
|
-
cleanup();
|
|
275
|
-
reject(new APITimeoutError(timeoutMs));
|
|
276
|
-
}, timeoutMs);
|
|
277
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
278
|
-
// `Promise.resolve` rather than `promise.then`: a hand-rolled operation
|
|
279
|
-
// whose handler returns synchronously is a legal JavaScript caller, and
|
|
280
|
-
// calling `.then` on its plain value failed with "promise.then is not a
|
|
281
|
-
// function" reported as an internal error of the operation.
|
|
282
|
-
Promise.resolve(promise).then((value) => {
|
|
283
|
-
cleanup();
|
|
284
|
-
resolve(value);
|
|
285
|
-
}, (error) => {
|
|
286
|
-
cleanup();
|
|
287
|
-
reject(error);
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
136
|
//# sourceMappingURL=executor.core.js.map
|