@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.
Files changed (78) hide show
  1. package/README.md +166 -7
  2. package/dist/api/bindings/cli/apiCli.argv.d.ts +36 -0
  3. package/dist/api/bindings/cli/apiCli.argv.js +78 -0
  4. package/dist/api/bindings/cli/apiCli.binding.d.ts +45 -0
  5. package/dist/api/bindings/cli/apiCli.binding.js +77 -0
  6. package/dist/api/bindings/cli/apiCli.exitCode.d.ts +31 -0
  7. package/dist/api/bindings/cli/apiCli.exitCode.js +43 -0
  8. package/dist/api/bindings/cli/apiCli.values.d.ts +24 -0
  9. package/dist/api/bindings/cli/apiCli.values.js +71 -0
  10. package/dist/api/bindings/cli/index.d.ts +12 -0
  11. package/dist/api/bindings/cli/index.js +9 -0
  12. package/dist/api/bindings/fetch/apiFetch.handler.d.ts +35 -0
  13. package/dist/api/bindings/fetch/apiFetch.handler.js +99 -0
  14. package/dist/api/bindings/fetch/apiFetch.input.d.ts +29 -0
  15. package/dist/api/bindings/fetch/apiFetch.input.js +74 -0
  16. package/dist/api/bindings/fetch/index.d.ts +11 -0
  17. package/dist/api/bindings/fetch/index.js +9 -0
  18. package/dist/api/bindings/index.d.ts +25 -0
  19. package/dist/api/bindings/index.js +18 -0
  20. package/dist/api/bindings/openapi/apiOpenAPI.descriptor.d.ts +37 -0
  21. package/dist/api/bindings/openapi/apiOpenAPI.descriptor.js +67 -0
  22. package/dist/api/bindings/openapi/apiOpenAPI.schemas.d.ts +32 -0
  23. package/dist/api/bindings/openapi/apiOpenAPI.schemas.js +62 -0
  24. package/dist/api/bindings/openapi/index.d.ts +9 -0
  25. package/dist/api/bindings/openapi/index.js +8 -0
  26. package/dist/api/bindings/queue/apiQueue.binding.d.ts +39 -0
  27. package/dist/api/bindings/queue/apiQueue.binding.js +65 -0
  28. package/dist/api/bindings/queue/index.d.ts +7 -0
  29. package/dist/api/bindings/queue/index.js +6 -0
  30. package/dist/api/bindings/route/apiRoute.resolver.d.ts +33 -0
  31. package/dist/api/bindings/route/apiRoute.resolver.js +109 -0
  32. package/dist/api/bindings/route/apiRoute.table.d.ts +53 -0
  33. package/dist/api/bindings/route/apiRoute.table.js +95 -0
  34. package/dist/api/bindings/route/apiRoute.type.d.ts +65 -0
  35. package/dist/api/bindings/route/apiRoute.type.js +8 -0
  36. package/dist/api/bindings/route/index.d.ts +11 -0
  37. package/dist/api/bindings/route/index.js +9 -0
  38. package/dist/api/bindings/rpc/apiRpc.binding.d.ts +46 -0
  39. package/dist/api/bindings/rpc/apiRpc.binding.js +63 -0
  40. package/dist/api/bindings/rpc/apiRpc.errors.d.ts +20 -0
  41. package/dist/api/bindings/rpc/apiRpc.errors.js +64 -0
  42. package/dist/api/bindings/rpc/index.d.ts +9 -0
  43. package/dist/api/bindings/rpc/index.js +8 -0
  44. package/dist/api/bindings/shared/apiBinding.helper.d.ts +44 -0
  45. package/dist/api/bindings/shared/apiBinding.helper.js +66 -0
  46. package/dist/api/bindings/shared/apiBinding.type.d.ts +37 -0
  47. package/dist/api/bindings/shared/apiBinding.type.js +2 -0
  48. package/dist/api/bindings/shared/apiWireResult.helper.d.ts +47 -0
  49. package/dist/api/bindings/shared/apiWireResult.helper.js +38 -0
  50. package/dist/api/bindings/shared/index.d.ts +11 -0
  51. package/dist/api/bindings/shared/index.js +8 -0
  52. package/dist/api/context/context.type.d.ts +4 -4
  53. package/dist/api/context/context.type.js +2 -2
  54. package/dist/api/context/contextKey.type.d.ts +8 -0
  55. package/dist/api/context/contextKey.type.js +2 -0
  56. package/dist/api/executor/executor.core.d.ts +20 -63
  57. package/dist/api/executor/executor.core.js +36 -191
  58. package/dist/api/executor/executor.deadline.d.ts +40 -0
  59. package/dist/api/executor/executor.deadline.js +93 -0
  60. package/dist/api/executor/executor.issues.d.ts +19 -0
  61. package/dist/api/executor/executor.issues.js +46 -0
  62. package/dist/api/executor/executor.normalize.d.ts +16 -0
  63. package/dist/api/executor/executor.normalize.js +45 -0
  64. package/dist/api/executor/executor.type.d.ts +33 -0
  65. package/dist/api/executor/executor.type.js +2 -0
  66. package/dist/api/executor/executor.validation.d.ts +31 -0
  67. package/dist/api/executor/executor.validation.js +58 -0
  68. package/dist/api/executor/index.d.ts +9 -2
  69. package/dist/api/executor/index.js +8 -1
  70. package/dist/api/handler/handler.type.d.ts +7 -4
  71. package/dist/api/interceptors/interceptor.type.d.ts +6 -5
  72. package/dist/api/operation/operation.type.d.ts +18 -1
  73. package/dist/api/operation/operation.type.js +5 -11
  74. package/dist/api/operation/operationSchema.type.d.ts +56 -0
  75. package/dist/api/operation/operationSchema.type.js +2 -0
  76. package/dist/index.d.ts +7 -3
  77. package/dist/index.js +7 -2
  78. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=apiBinding.type.js.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 { APIError } from "../errors/index.js";
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
- * Invokes the operation handler under its timeout and abort signal, and
72
- * validates the handler's output against `operation.output`.
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
- * A response that does not match its declared schema is a server bug,
79
- * not a client mistake, so failures surface as an `APIInternalError`
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 validateOutput;
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, apiSuccess } from "../result/apiResult.type.js";
1
+ import { apiFailure } from "../result/apiResult.type.js";
2
2
  import { resolveOperationTimeout } from "../operation/operation.type.js";
3
- import { APIError, APIInternalError, APITimeoutError, APIValidationError, createAPIError, ErrorCode, isAPIError, } from "../errors/index.js";
4
- import { validateWithSchema } from "../schema/index.js";
5
- import { MAX_INTERCEPTORS, MAX_VALIDATION_ISSUES, MAX_VALIDATION_ISSUE_LENGTH, } from "../constants.js";
6
- /**
7
- * Error normalizer for converting unknown errors into APIError instances.
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: effectiveInput,
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()` actually changes what the
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
- * Invokes the operation handler under its timeout and abort signal, and
118
- * validates the handler's output against `operation.output`.
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(input, context), timeoutMs, operation.name, context.signal);
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
- if (!validation.ok) {
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