@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zudojs Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zudojs/api
|
|
2
2
|
|
|
3
|
-
Higher-level API layer — operation definitions, execution context, interceptors,
|
|
3
|
+
Higher-level API layer — operation definitions, execution context, interceptors, and a transport-agnostic executor. Sits above `@zudojs/http` and `@zudojs/cqrs`.
|
|
4
4
|
|
|
5
5
|
## When to use
|
|
6
6
|
|
|
@@ -20,36 +20,160 @@ npm install @zudojs/api
|
|
|
20
20
|
|
|
21
21
|
```typescript
|
|
22
22
|
import {
|
|
23
|
+
// operations
|
|
23
24
|
defineOperation,
|
|
24
|
-
|
|
25
|
+
resolveOperationTimeout,
|
|
25
26
|
APIOperationRegistry,
|
|
27
|
+
// execution
|
|
28
|
+
APIExecutor,
|
|
26
29
|
createNoopInterceptor,
|
|
27
30
|
normalizeAPIError,
|
|
31
|
+
// context
|
|
32
|
+
createAPIContext,
|
|
33
|
+
createContextKey,
|
|
34
|
+
normalizeRequestId,
|
|
35
|
+
isValidRequestId,
|
|
36
|
+
RequestIdContextKey,
|
|
37
|
+
CorrelationIdContextKey,
|
|
38
|
+
TenantIdContextKey,
|
|
39
|
+
UserIdContextKey,
|
|
40
|
+
StartTimeContextKey,
|
|
41
|
+
// results
|
|
42
|
+
apiSuccess,
|
|
43
|
+
apiFailure,
|
|
44
|
+
isApiSuccess,
|
|
45
|
+
isApiFailure,
|
|
46
|
+
// constants
|
|
47
|
+
DEFAULT_OPERATION_TIMEOUT,
|
|
48
|
+
MAX_OPERATION_TIMEOUT,
|
|
49
|
+
MAX_INTERCEPTORS,
|
|
50
|
+
MAX_VALIDATION_ISSUES,
|
|
28
51
|
type APIContext,
|
|
29
52
|
type APIContextKey,
|
|
53
|
+
type APIExecutionContext,
|
|
54
|
+
type APIExecutorOptions,
|
|
30
55
|
type APIHandler,
|
|
31
56
|
type APIInterceptor,
|
|
32
|
-
type
|
|
57
|
+
type APIOperation,
|
|
58
|
+
type APIOperationMetadata,
|
|
59
|
+
type APIResult,
|
|
60
|
+
type DefineOperationOptions,
|
|
33
61
|
type APIErrorOptions,
|
|
34
62
|
} from "@zudojs/api";
|
|
35
63
|
```
|
|
36
64
|
|
|
65
|
+
Every API error class from `@zudojs/errors` (`APIError`, `APIValidationError`, `APIInternalError`, `APIOperationNotFoundError`, …) plus `createAPIError`, `isAPIError` and `ErrorCode` are re-exported for convenience.
|
|
66
|
+
|
|
37
67
|
## Usage
|
|
38
68
|
|
|
39
69
|
```typescript
|
|
40
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
APIExecutor,
|
|
72
|
+
APIOperationRegistry,
|
|
73
|
+
createAPIContext,
|
|
74
|
+
defineOperation,
|
|
75
|
+
normalizeRequestId,
|
|
76
|
+
} from "@zudojs/api";
|
|
41
77
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
78
|
+
// 1. Define the operation. The handler is positional: (input, context).
|
|
79
|
+
const getUser = defineOperation<{ id: string }, { id: string; name: string }>({
|
|
80
|
+
name: "users.get",
|
|
81
|
+
input: GetUserSchema, // any Standard Schema (Zod, Valibot, ArkType, …)
|
|
82
|
+
output: UserSchema, // validated too — see "Output validation"
|
|
83
|
+
timeout: 5_000,
|
|
84
|
+
metadata: { tags: ["Users"] },
|
|
85
|
+
handler: async (input, context) => db.findUser(input.id, context.signal),
|
|
46
86
|
});
|
|
47
87
|
|
|
88
|
+
// 2. Register it.
|
|
48
89
|
const registry = new APIOperationRegistry();
|
|
49
90
|
registry.register(getUser);
|
|
50
|
-
|
|
91
|
+
registry.freeze();
|
|
92
|
+
|
|
93
|
+
// 3. Execute it. The registry looks operations up; the executor runs them.
|
|
94
|
+
const executor = new APIExecutor();
|
|
95
|
+
const context = createAPIContext(
|
|
96
|
+
normalizeRequestId(request.headers["x-request-id"]),
|
|
97
|
+
{ locale: "en" },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const result = await executor.execute(
|
|
101
|
+
registry.require("users.get"), // throws APIOperationNotFoundError (404)
|
|
102
|
+
{ id: "u_1" },
|
|
103
|
+
context,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (result.ok) {
|
|
107
|
+
respond(200, result.data);
|
|
108
|
+
} else {
|
|
109
|
+
respond(result.error.statusCode, {
|
|
110
|
+
message: result.error.expose ? result.error.message : "Internal error",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Results are frozen `{ ok: true, data }` / `{ ok: false, error }` objects — `execute` never throws for an operation failure.
|
|
116
|
+
|
|
117
|
+
## Input validation
|
|
118
|
+
|
|
119
|
+
When `operation.input` is a Standard Schema, the executor validates the input before the handler runs and passes the schema's *transformed* value to the handler. Failures return an `APIValidationError` (422).
|
|
120
|
+
|
|
121
|
+
Schema issue messages routinely interpolate the value that failed, so by default the executor does **not** copy them into the client-facing error: each issue becomes `"<path>: invalid"` (e.g. `"user.email: invalid"`), naming where validation failed without echoing what was submitted. The list is capped at `MAX_VALIDATION_ISSUES` entries with a trailing `"… and N more issue(s) omitted."` marker.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
// Opt in to raw messages only when every schema in the process is known
|
|
125
|
+
// to produce value-free messages.
|
|
126
|
+
new APIExecutor({ exposeValidationMessages: true, maxValidationIssues: 10 });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Output validation
|
|
130
|
+
|
|
131
|
+
When `operation.output` is a Standard Schema, the handler's return value is validated too, and the validated (possibly stripped or transformed) value becomes `result.data`. A mismatch is a server bug, so it fails with an `APIInternalError` (500, `expose: false`) naming only the failing paths.
|
|
132
|
+
|
|
133
|
+
## Timeouts
|
|
134
|
+
|
|
135
|
+
Every operation has a deadline. `timeout` must be a positive, finite integer of at most `MAX_OPERATION_TIMEOUT` milliseconds; `defineOperation` rejects `0`, negatives, `NaN` and non-integers rather than silently running unbounded. Precedence is `timeout` → `metadata.timeout` → `DEFAULT_OPERATION_TIMEOUT`, resolved by `resolveOperationTimeout`.
|
|
136
|
+
|
|
137
|
+
The handler itself is not cancellable — pass `context.signal` into anything that supports it. An execution cancelled through the signal fails with `ErrorCode.OPERATION_CANCELLED`; branch on that code rather than on the (nginx-convention) 499 status.
|
|
138
|
+
|
|
139
|
+
## Interceptors
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
const timing: APIInterceptor = {
|
|
143
|
+
async intercept(context, next) {
|
|
144
|
+
context.input = sanitize(context.input); // reaches the handler
|
|
145
|
+
const started = Date.now();
|
|
146
|
+
const result = await next();
|
|
147
|
+
// context.result === result, including when a downstream interceptor
|
|
148
|
+
// short-circuits without calling next().
|
|
149
|
+
log(context.operation.name, Date.now() - started, result.ok);
|
|
150
|
+
return result;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
new APIExecutor([timing]); // or new APIExecutor({ interceptors: [timing] })
|
|
51
155
|
```
|
|
52
156
|
|
|
157
|
+
At most `MAX_INTERCEPTORS` interceptors per executor, and each `next()` may be awaited once.
|
|
158
|
+
|
|
159
|
+
## Context
|
|
160
|
+
|
|
161
|
+
`APIContext` carries the request id, an optional `AbortSignal`, caller state, and typed key/value slots. Keys carry their own identity, so two keys created with the same name never collide:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
const FeatureFlagsKey = createContextKey<readonly string[]>("featureFlags");
|
|
165
|
+
|
|
166
|
+
context.set(FeatureFlagsKey, ["beta"]);
|
|
167
|
+
context.get(FeatureFlagsKey); // readonly string[] | undefined
|
|
168
|
+
context.metadata; // read-only snapshot keyed by key name
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`createAPIContext` requires a safe request id (non-empty, ≤128 chars, `[A-Za-z0-9._:-]`) and throws otherwise. Run client-supplied header values through `normalizeRequestId` first — it replaces anything unsafe with a generated UUID, so a request id can never inject a log line or split a response header. `requestId` cannot be reassigned through `RequestIdContextKey`.
|
|
172
|
+
|
|
173
|
+
## Errors
|
|
174
|
+
|
|
175
|
+
`normalizeAPIError(error, operationName?)` converts anything thrown into an `APIError`. Non-API errors become an `APIInternalError` with a generic message and the original on `cause` — the internal message is deliberately not copied onto the wrapper, because `BaseError.toJSON()` serializes `message`, `stack` and `cause` regardless of `expose`.
|
|
176
|
+
|
|
53
177
|
## License
|
|
54
178
|
|
|
55
179
|
MIT
|
package/dist/api/constants.d.ts
CHANGED
|
@@ -5,14 +5,44 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
7
|
* Default timeout for API operations (30 seconds).
|
|
8
|
+
*
|
|
9
|
+
* Applied by {@link defineOperation} when no explicit timeout is given, and
|
|
10
|
+
* by the executor as a fail-safe for hand-rolled operation objects.
|
|
8
11
|
*/
|
|
9
12
|
export declare const DEFAULT_OPERATION_TIMEOUT = 30000;
|
|
13
|
+
/**
|
|
14
|
+
* Maximum timeout accepted for a single operation (1 hour).
|
|
15
|
+
*
|
|
16
|
+
* Enforced by `defineOperation`. A larger value is almost always a unit
|
|
17
|
+
* mistake (seconds vs milliseconds) rather than an intentional deadline.
|
|
18
|
+
*/
|
|
19
|
+
export declare const MAX_OPERATION_TIMEOUT = 3600000;
|
|
10
20
|
/**
|
|
11
21
|
* Maximum number of interceptors allowed in a pipeline.
|
|
22
|
+
* Enforced by the APIExecutor constructor.
|
|
12
23
|
*/
|
|
13
24
|
export declare const MAX_INTERCEPTORS = 32;
|
|
14
25
|
/**
|
|
15
|
-
* Maximum number of
|
|
26
|
+
* Maximum number of validation issues carried on an `APIValidationError`
|
|
27
|
+
* produced by the executor.
|
|
28
|
+
*
|
|
29
|
+
* Schema libraries emit one issue per failing element, so an array input
|
|
30
|
+
* can produce an unbounded number of issues. The executor truncates the
|
|
31
|
+
* list at this many entries and appends a single marker entry recording
|
|
32
|
+
* how many were dropped.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MAX_VALIDATION_ISSUES = 20;
|
|
35
|
+
/**
|
|
36
|
+
* Maximum length of a single validation issue string produced by the
|
|
37
|
+
* executor. Longer strings are truncated with an ellipsis.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MAX_VALIDATION_ISSUE_LENGTH = 200;
|
|
40
|
+
/**
|
|
41
|
+
* Maximum length of an operation name accepted by `defineOperation`.
|
|
42
|
+
*/
|
|
43
|
+
export declare const MAX_OPERATION_NAME_LENGTH = 128;
|
|
44
|
+
/**
|
|
45
|
+
* Maximum length of a request id accepted by `createAPIContext`.
|
|
16
46
|
*/
|
|
17
|
-
export declare const
|
|
47
|
+
export declare const MAX_REQUEST_ID_LENGTH = 128;
|
|
18
48
|
//# sourceMappingURL=constants.d.ts.map
|
package/dist/api/constants.js
CHANGED
|
@@ -5,14 +5,44 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
7
|
* Default timeout for API operations (30 seconds).
|
|
8
|
+
*
|
|
9
|
+
* Applied by {@link defineOperation} when no explicit timeout is given, and
|
|
10
|
+
* by the executor as a fail-safe for hand-rolled operation objects.
|
|
8
11
|
*/
|
|
9
12
|
export const DEFAULT_OPERATION_TIMEOUT = 30_000;
|
|
13
|
+
/**
|
|
14
|
+
* Maximum timeout accepted for a single operation (1 hour).
|
|
15
|
+
*
|
|
16
|
+
* Enforced by `defineOperation`. A larger value is almost always a unit
|
|
17
|
+
* mistake (seconds vs milliseconds) rather than an intentional deadline.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_OPERATION_TIMEOUT = 3_600_000;
|
|
10
20
|
/**
|
|
11
21
|
* Maximum number of interceptors allowed in a pipeline.
|
|
22
|
+
* Enforced by the APIExecutor constructor.
|
|
12
23
|
*/
|
|
13
24
|
export const MAX_INTERCEPTORS = 32;
|
|
14
25
|
/**
|
|
15
|
-
* Maximum number of
|
|
26
|
+
* Maximum number of validation issues carried on an `APIValidationError`
|
|
27
|
+
* produced by the executor.
|
|
28
|
+
*
|
|
29
|
+
* Schema libraries emit one issue per failing element, so an array input
|
|
30
|
+
* can produce an unbounded number of issues. The executor truncates the
|
|
31
|
+
* list at this many entries and appends a single marker entry recording
|
|
32
|
+
* how many were dropped.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_VALIDATION_ISSUES = 20;
|
|
35
|
+
/**
|
|
36
|
+
* Maximum length of a single validation issue string produced by the
|
|
37
|
+
* executor. Longer strings are truncated with an ellipsis.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_VALIDATION_ISSUE_LENGTH = 200;
|
|
40
|
+
/**
|
|
41
|
+
* Maximum length of an operation name accepted by `defineOperation`.
|
|
42
|
+
*/
|
|
43
|
+
export const MAX_OPERATION_NAME_LENGTH = 128;
|
|
44
|
+
/**
|
|
45
|
+
* Maximum length of a request id accepted by `createAPIContext`.
|
|
16
46
|
*/
|
|
17
|
-
export const
|
|
47
|
+
export const MAX_REQUEST_ID_LENGTH = 128;
|
|
18
48
|
//# sourceMappingURL=constants.js.map
|
|
@@ -10,11 +10,49 @@ export interface APIContext<TState = unknown> {
|
|
|
10
10
|
readonly signal?: AbortSignal;
|
|
11
11
|
readonly state: TState;
|
|
12
12
|
get<T>(key: APIContextKey<T>): T | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Stores a value under a typed key.
|
|
15
|
+
*
|
|
16
|
+
* @throws {TypeError} for {@link RequestIdContextKey}, which is fixed at
|
|
17
|
+
* construction so `get(RequestIdContextKey)` can never disagree with
|
|
18
|
+
* `context.requestId`.
|
|
19
|
+
*/
|
|
13
20
|
set<T>(key: APIContextKey<T>, value: T): void;
|
|
21
|
+
/**
|
|
22
|
+
* Read-only snapshot of the context values, keyed by context key name.
|
|
23
|
+
*
|
|
24
|
+
* This is a genuine read-only façade, not the context's backing store:
|
|
25
|
+
* it has no mutating methods at all, so casting it to `Map` and calling
|
|
26
|
+
* `set`/`delete`/`clear` throws rather than mutating the context.
|
|
27
|
+
*/
|
|
14
28
|
readonly metadata: ReadonlyMap<string, unknown>;
|
|
15
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Determines whether a value is usable as a request id.
|
|
32
|
+
*
|
|
33
|
+
* A valid request id is a non-empty string of at most
|
|
34
|
+
* {@link MAX_REQUEST_ID_LENGTH} characters drawn from `[A-Za-z0-9._:-]`.
|
|
35
|
+
* The charset excludes CR/LF and other control characters, so a request id
|
|
36
|
+
* is always safe to place in a log line or a response header.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isValidRequestId(value: unknown): value is string;
|
|
39
|
+
/**
|
|
40
|
+
* Normalizes an untrusted request id.
|
|
41
|
+
*
|
|
42
|
+
* Transports typically populate the request id from an inbound
|
|
43
|
+
* `X-Request-Id` / `X-Correlation-Id` header, i.e. straight from the
|
|
44
|
+
* network. Pass that value through this function before handing it to
|
|
45
|
+
* {@link createAPIContext}: valid ids are returned unchanged, anything
|
|
46
|
+
* else (missing, empty, over-long, or containing characters that would
|
|
47
|
+
* allow log injection or response splitting) is replaced by a freshly
|
|
48
|
+
* generated UUID.
|
|
49
|
+
*/
|
|
50
|
+
export declare function normalizeRequestId(value: unknown): string;
|
|
16
51
|
/**
|
|
17
52
|
* Creates a new API context.
|
|
53
|
+
*
|
|
54
|
+
* @throws {TypeError} if `requestId` is not a valid request id. Use
|
|
55
|
+
* {@link normalizeRequestId} for client-supplied values.
|
|
18
56
|
*/
|
|
19
57
|
export declare function createAPIContext<TState = unknown>(requestId: string, state: TState, signal?: AbortSignal): APIContext<TState>;
|
|
20
58
|
export type { APIContextKey };
|
|
@@ -1,25 +1,106 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { MAX_REQUEST_ID_LENGTH } from "../constants.js";
|
|
1
3
|
import { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, createContextKey, } from "./contextKey.type.js";
|
|
4
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;
|
|
5
|
+
/**
|
|
6
|
+
* Determines whether a value is usable as a request id.
|
|
7
|
+
*
|
|
8
|
+
* A valid request id is a non-empty string of at most
|
|
9
|
+
* {@link MAX_REQUEST_ID_LENGTH} characters drawn from `[A-Za-z0-9._:-]`.
|
|
10
|
+
* The charset excludes CR/LF and other control characters, so a request id
|
|
11
|
+
* is always safe to place in a log line or a response header.
|
|
12
|
+
*/
|
|
13
|
+
export function isValidRequestId(value) {
|
|
14
|
+
return (typeof value === "string" &&
|
|
15
|
+
value.length > 0 &&
|
|
16
|
+
value.length <= MAX_REQUEST_ID_LENGTH &&
|
|
17
|
+
REQUEST_ID_PATTERN.test(value));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Normalizes an untrusted request id.
|
|
21
|
+
*
|
|
22
|
+
* Transports typically populate the request id from an inbound
|
|
23
|
+
* `X-Request-Id` / `X-Correlation-Id` header, i.e. straight from the
|
|
24
|
+
* network. Pass that value through this function before handing it to
|
|
25
|
+
* {@link createAPIContext}: valid ids are returned unchanged, anything
|
|
26
|
+
* else (missing, empty, over-long, or containing characters that would
|
|
27
|
+
* allow log injection or response splitting) is replaced by a freshly
|
|
28
|
+
* generated UUID.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeRequestId(value) {
|
|
31
|
+
return isValidRequestId(value) ? value : randomUUID();
|
|
32
|
+
}
|
|
2
33
|
/**
|
|
3
34
|
* Creates a new API context.
|
|
35
|
+
*
|
|
36
|
+
* @throws {TypeError} if `requestId` is not a valid request id. Use
|
|
37
|
+
* {@link normalizeRequestId} for client-supplied values.
|
|
4
38
|
*/
|
|
5
39
|
export function createAPIContext(requestId, state, signal) {
|
|
6
|
-
|
|
7
|
-
|
|
40
|
+
if (!isValidRequestId(requestId)) {
|
|
41
|
+
throw new TypeError(`Invalid requestId: expected a non-empty string of at most ${MAX_REQUEST_ID_LENGTH} characters matching ${REQUEST_ID_PATTERN.source}. Use normalizeRequestId() for client-supplied values.`);
|
|
42
|
+
}
|
|
43
|
+
// Values are stored under the key's unique `id` symbol, so keys sharing
|
|
44
|
+
// a `name` never share a slot.
|
|
45
|
+
const store = new Map();
|
|
46
|
+
const names = new Map();
|
|
47
|
+
const remember = (key) => {
|
|
48
|
+
names.set(key.id, key.name);
|
|
49
|
+
};
|
|
50
|
+
remember(RequestIdContextKey);
|
|
51
|
+
store.set(RequestIdContextKey.id, requestId);
|
|
52
|
+
let view;
|
|
8
53
|
const context = {
|
|
9
54
|
requestId,
|
|
10
55
|
signal,
|
|
11
56
|
state,
|
|
12
57
|
get(key) {
|
|
13
|
-
return
|
|
58
|
+
return store.get(key.id);
|
|
14
59
|
},
|
|
15
60
|
set(key, value) {
|
|
16
|
-
|
|
61
|
+
if (key.id === RequestIdContextKey.id) {
|
|
62
|
+
throw new TypeError("requestId is fixed at context creation and cannot be reassigned.");
|
|
63
|
+
}
|
|
64
|
+
remember(key);
|
|
65
|
+
store.set(key.id, value);
|
|
66
|
+
view = undefined;
|
|
17
67
|
},
|
|
18
68
|
get metadata() {
|
|
19
|
-
|
|
69
|
+
if (view === undefined) {
|
|
70
|
+
const snapshot = new Map();
|
|
71
|
+
for (const [id, value] of store) {
|
|
72
|
+
snapshot.set(names.get(id) ?? id.toString(), value);
|
|
73
|
+
}
|
|
74
|
+
view = createReadonlyMapView(snapshot);
|
|
75
|
+
}
|
|
76
|
+
return view;
|
|
20
77
|
},
|
|
21
78
|
};
|
|
22
79
|
return Object.freeze(context);
|
|
23
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Wraps a private map in a frozen object exposing only the read half of
|
|
83
|
+
* the `ReadonlyMap` interface. The wrapped map is unreachable from the
|
|
84
|
+
* returned value, so there is no cast that recovers mutation rights.
|
|
85
|
+
*/
|
|
86
|
+
function createReadonlyMapView(source) {
|
|
87
|
+
const view = {
|
|
88
|
+
get size() {
|
|
89
|
+
return source.size;
|
|
90
|
+
},
|
|
91
|
+
get: (key) => source.get(key),
|
|
92
|
+
has: (key) => source.has(key),
|
|
93
|
+
keys: () => source.keys(),
|
|
94
|
+
values: () => source.values(),
|
|
95
|
+
entries: () => source.entries(),
|
|
96
|
+
forEach: (callback, thisArg) => {
|
|
97
|
+
for (const [key, value] of source) {
|
|
98
|
+
callback.call(thisArg, value, key, view);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
[Symbol.iterator]: () => source[Symbol.iterator](),
|
|
102
|
+
};
|
|
103
|
+
return Object.freeze(view);
|
|
104
|
+
}
|
|
24
105
|
export { RequestIdContextKey, CorrelationIdContextKey, TenantIdContextKey, UserIdContextKey, StartTimeContextKey, createContextKey, };
|
|
25
106
|
//# sourceMappingURL=context.type.js.map
|
|
@@ -8,13 +8,26 @@
|
|
|
8
8
|
*/
|
|
9
9
|
/**
|
|
10
10
|
* A typed key for storing and retrieving values from the API context.
|
|
11
|
+
*
|
|
12
|
+
* Keys carry a unique `id` symbol, so two keys created with the same
|
|
13
|
+
* `name` are distinct slots: a third-party key can never collide with —
|
|
14
|
+
* or be read as the wrong type through — a key owned by someone else.
|
|
15
|
+
* `name` is retained for debugging and for the context's `metadata` view.
|
|
16
|
+
*
|
|
17
|
+
* Keys must be created with {@link createContextKey}; the `id` symbol is
|
|
18
|
+
* the identity the context stores values under.
|
|
11
19
|
*/
|
|
12
20
|
export interface APIContextKey<T> {
|
|
13
21
|
readonly name: string;
|
|
22
|
+
/** Unique identity for this key. Two keys never share an id. */
|
|
23
|
+
readonly id: symbol;
|
|
24
|
+
/** Phantom type carrier. Never populated at runtime. */
|
|
14
25
|
readonly type: T;
|
|
15
26
|
}
|
|
16
27
|
/**
|
|
17
28
|
* Creates a typed context key.
|
|
29
|
+
*
|
|
30
|
+
* @throws {TypeError} if `name` is not a non-empty string.
|
|
18
31
|
*/
|
|
19
32
|
export declare function createContextKey<T>(name: string): APIContextKey<T>;
|
|
20
33
|
/**
|
|
@@ -8,10 +8,16 @@
|
|
|
8
8
|
*/
|
|
9
9
|
/**
|
|
10
10
|
* Creates a typed context key.
|
|
11
|
+
*
|
|
12
|
+
* @throws {TypeError} if `name` is not a non-empty string.
|
|
11
13
|
*/
|
|
12
14
|
export function createContextKey(name) {
|
|
15
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
16
|
+
throw new TypeError("Context key name must be a non-empty string.");
|
|
17
|
+
}
|
|
13
18
|
return Object.freeze({
|
|
14
19
|
name,
|
|
20
|
+
id: Symbol(name),
|
|
15
21
|
type: undefined,
|
|
16
22
|
});
|
|
17
23
|
}
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Re-exported from @zudojs/errors for convenience.
|
|
7
7
|
*/
|
|
8
|
-
export { APIError, APIValidationError, APIAuthenticationError, APIAuthorizationError, APINotFoundError, APIConflictError, APIRateLimitError, APITimeoutError, APIUnavailableError, APIInternalError, APIVersionError, APIOperationNotFoundError, APIDuplicateOperationError, APIIdempotencyError, createAPIError, isAPIError, } from "@zudojs/errors";
|
|
8
|
+
export { APIError, APIValidationError, APIAuthenticationError, APIAuthorizationError, APINotFoundError, APIConflictError, APIRateLimitError, APITimeoutError, APIUnavailableError, APIInternalError, APIVersionError, APIOperationNotFoundError, APIDuplicateOperationError, APIIdempotencyError, createAPIError, isAPIError, ErrorCode, } from "@zudojs/errors";
|
|
9
9
|
export type { APIErrorOptions } from "@zudojs/errors";
|
|
10
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/api/errors/index.js
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Re-exported from @zudojs/errors for convenience.
|
|
7
7
|
*/
|
|
8
|
-
export { APIError, APIValidationError, APIAuthenticationError, APIAuthorizationError, APINotFoundError, APIConflictError, APIRateLimitError, APITimeoutError, APIUnavailableError, APIInternalError, APIVersionError, APIOperationNotFoundError, APIDuplicateOperationError, APIIdempotencyError, createAPIError, isAPIError, } from "@zudojs/errors";
|
|
8
|
+
export { APIError, APIValidationError, APIAuthenticationError, APIAuthorizationError, APINotFoundError, APIConflictError, APIRateLimitError, APITimeoutError, APIUnavailableError, APIInternalError, APIVersionError, APIOperationNotFoundError, APIDuplicateOperationError, APIIdempotencyError, createAPIError, isAPIError, ErrorCode, } from "@zudojs/errors";
|
|
9
9
|
//# sourceMappingURL=index.js.map
|
|
@@ -2,31 +2,101 @@ 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";
|
|
6
|
+
export type { APIExecutionContext } from "../interceptors/interceptor.type.js";
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
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.
|
|
7
19
|
*/
|
|
8
|
-
export
|
|
9
|
-
readonly operation: APIOperation<TInput, TOutput>;
|
|
10
|
-
readonly input: TInput;
|
|
11
|
-
readonly context: APIContext;
|
|
12
|
-
readonly result?: APIResult<TOutput>;
|
|
13
|
-
}
|
|
20
|
+
export declare function normalizeAPIError(error: unknown, operationName?: string): APIError;
|
|
14
21
|
/**
|
|
15
|
-
*
|
|
22
|
+
* Options for {@link APIExecutor}.
|
|
16
23
|
*/
|
|
17
|
-
export
|
|
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
|
+
}
|
|
18
52
|
/**
|
|
19
53
|
* Executes an API operation through its interceptor pipeline.
|
|
54
|
+
*
|
|
55
|
+
* Enforces the operation timeout, honors the context AbortSignal, and
|
|
56
|
+
* validates input and output when the operation's `input` / `output` is a
|
|
57
|
+
* Standard Schema.
|
|
20
58
|
*/
|
|
21
59
|
export declare class APIExecutor {
|
|
22
60
|
private readonly interceptors;
|
|
23
|
-
|
|
61
|
+
private readonly exposeValidationMessages;
|
|
62
|
+
private readonly maxValidationIssues;
|
|
63
|
+
constructor(optionsOrInterceptors?: readonly APIInterceptor[] | APIExecutorOptions);
|
|
24
64
|
/**
|
|
25
65
|
* Executes an operation with the given input and context.
|
|
26
66
|
*/
|
|
27
67
|
execute<TInput = unknown, TOutput = unknown>(operation: APIOperation<TInput, TOutput>, input: TInput, context: APIContext): Promise<APIResult<TOutput>>;
|
|
28
68
|
/**
|
|
29
|
-
*
|
|
69
|
+
* Invokes the operation handler under its timeout and abort signal, and
|
|
70
|
+
* validates the handler's output when `operation.output` is a Standard
|
|
71
|
+
* Schema.
|
|
72
|
+
*/
|
|
73
|
+
private invokeHandler;
|
|
74
|
+
/**
|
|
75
|
+
* Validates handler output against `operation.output`.
|
|
76
|
+
*
|
|
77
|
+
* A response that does not match its declared schema is a server bug,
|
|
78
|
+
* not a client mistake, so failures surface as an `APIInternalError`
|
|
79
|
+
* (500, `expose: false`) naming only the failing paths — never the
|
|
80
|
+
* offending values, which are exactly the fields (password hashes,
|
|
81
|
+
* internal audit columns) that should not reach a client.
|
|
82
|
+
*/
|
|
83
|
+
private validateOutput;
|
|
84
|
+
/**
|
|
85
|
+
* Converts schema issues into the capped, redacted list carried on the
|
|
86
|
+
* client-facing `APIValidationError`.
|
|
87
|
+
*/
|
|
88
|
+
private clientIssues;
|
|
89
|
+
/**
|
|
90
|
+
* Runs the interceptor pipeline (Koa-style dispatch).
|
|
91
|
+
*
|
|
92
|
+
* Each interceptor's `next()` may be awaited at most once; a second
|
|
93
|
+
* call rejects instead of silently re-executing the handler while
|
|
94
|
+
* bypassing downstream interceptors.
|
|
95
|
+
*
|
|
96
|
+
* `context.result` is assigned as each level resolves, so an
|
|
97
|
+
* interceptor reading it after `await next()` sees exactly what the
|
|
98
|
+
* level below returned — including when a downstream interceptor
|
|
99
|
+
* short-circuits without calling `next()`.
|
|
30
100
|
*/
|
|
31
101
|
private runPipeline;
|
|
32
102
|
}
|