lambder 9.0.2 → 10.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +195 -0
- package/README.md +29 -14
- package/dist/api/LambderApiDefinition.d.ts +9 -0
- package/dist/api/LambderApiEnvelope.d.ts +22 -10
- package/dist/api/LambderApiEnvelope.js +57 -22
- package/dist/api/LambderApiGuards.d.ts +77 -22
- package/dist/api/LambderApiGuards.js +13 -1
- package/dist/api/LambderApiIdempotency.js +4 -4
- package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
- package/dist/api/LambderApiOutputValidationError.js +14 -6
- package/dist/api/LambderApiPipeline.d.ts +2 -1
- package/dist/api/LambderApiPipeline.js +10 -2
- package/dist/api/LambderApiRateLimits.d.ts +15 -11
- package/dist/api/LambderApiRateLimits.js +31 -7
- package/dist/api/LambderApiRefusals.d.ts +204 -0
- package/dist/api/LambderApiRefusals.js +223 -0
- package/dist/api/LambderApiSignature.d.ts +13 -4
- package/dist/api/LambderApiSignature.js +21 -4
- package/dist/build/writeApiOptions.js +7 -5
- package/dist/client/LambderCaller.d.ts +28 -17
- package/dist/client/LambderCaller.js +19 -15
- package/dist/client/LambderUploadRunner.d.ts +5 -5
- package/dist/client/LambderUploadRunner.js +25 -16
- package/dist/client.d.ts +6 -5
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +121 -23
- package/dist/core/Lambder.js +105 -51
- package/dist/core/LambderContext.d.ts +1 -1
- package/dist/core/LambderContext.js +5 -0
- package/dist/core/LambderCreateOptions.d.ts +58 -15
- package/dist/core/LambderCreateOptions.js +10 -0
- package/dist/core/LambderPolicyBuilders.d.ts +11 -8
- package/dist/core/LambderPolicyBuilders.js +10 -8
- package/dist/core/LambderResolver.d.ts +1 -1
- package/dist/core/LambderResolver.js +1 -1
- package/dist/core/LambderResponseBuilder.d.ts +11 -9
- package/dist/core/LambderResponseBuilder.js +12 -11
- package/dist/index.d.ts +13 -9
- package/dist/index.js +6 -2
- package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
- package/dist/invoke/LambderInvokeCaller.js +13 -12
- package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
- package/dist/invoke/LambderInvokeOutcome.js +4 -4
- package/dist/mock/LambderMockApp.d.ts +86 -24
- package/dist/mock/LambderMockApp.js +108 -16
- package/dist/mock/LambderMockCallRecorder.js +6 -3
- package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
- package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
- package/dist/mock/LambderMockFailureInjector.js +18 -4
- package/dist/mock/LambderMockTypes.d.ts +34 -11
- package/dist/mock.d.ts +4 -3
- package/dist/mock.js +4 -3
- package/dist/shared/contracts/LambderUploadBucket.d.ts +18 -3
- package/dist/shared/contracts/LambderUploadBucket.js +2 -2
- package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
- package/dist/shared/wire/LambderApiContract.d.ts +96 -31
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
- package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
- package/dist/shared/wire/LambderApiOptionValues.js +3 -3
- package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
- package/dist/shared/wire/LambderApiOutcome.js +28 -11
- package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
- package/dist/shared/wire/LambderApiRefusal.js +15 -7
- package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
- package/dist/shared/wire/LambderCrashDetail.js +1 -1
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
- package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
- package/dist/shared/wire/LambderObjectPayload.js +24 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -1
- package/dist/shared/wire/LambderUploadObjectFields.js +22 -1
- package/dist/shared/wire/LambderUploadSchemas.d.ts +8 -2
- package/dist/shared/wire/LambderUploadSchemas.js +18 -7
- package/dist/stores/LambderMemoryUploadBucket.d.ts +21 -7
- package/dist/stores/LambderMemoryUploadBucket.js +60 -12
- package/dist/stores/LambderS3UploadBucket.d.ts +22 -3
- package/dist/stores/LambderS3UploadBucket.js +51 -4
- package/dist/testing/LambderTestApp.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Contracts are built via method chaining and inferred using typeof lambder.ApiContract
|
|
5
5
|
*/
|
|
6
6
|
import type { LambderCrashDetail } from "./LambderCrashDetail.js";
|
|
7
|
-
import type {
|
|
7
|
+
import type { LambderUncheckedRefusalMessage, LambderPlainRefusalMessage, LambderRefusalMessage } from "./LambderApiRefusal.js";
|
|
8
8
|
import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "./LambderApiOptionValues.js";
|
|
9
9
|
/** Whether an endpoint runs without a session or requires one (addApi versus addSessionApi). */
|
|
10
10
|
export type LambderApiMode = "public" | "session";
|
|
@@ -30,26 +30,27 @@ export type LambderApiContractShape = Record<string, {
|
|
|
30
30
|
rateLimit?: LambderRateLimitOptionValue;
|
|
31
31
|
/** Present when the API declares idempotency: the `idempotency` option exactly as written. */
|
|
32
32
|
idempotency?: LambderApiIdempotencyOption;
|
|
33
|
+
/** Present when the API can refuse with a declared code: each code mapped to `{ data }` or `{}`. See LambderContractRefusalsOf. */
|
|
34
|
+
refusals?: Record<string, {
|
|
35
|
+
data?: unknown;
|
|
36
|
+
}>;
|
|
33
37
|
}>;
|
|
34
38
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
39
|
+
* What a refusal envelope may carry besides its null payload: the flags a
|
|
40
|
+
* caller routes on, the refusal's message, the call's logList, and a crash
|
|
41
|
+
* described for a caller allowed to see it. The one shape the envelope
|
|
42
|
+
* writer takes for every answer that is not a handler's output.
|
|
38
43
|
*/
|
|
39
|
-
export type
|
|
44
|
+
export type LambderRefusalEnvelopeFields = {
|
|
40
45
|
versionExpired?: boolean;
|
|
41
46
|
sessionExpired?: boolean;
|
|
42
47
|
notAuthorized?: boolean;
|
|
43
48
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* the message object either way. Read off the wire it can still be a
|
|
47
|
-
* string, or no message at all, wherever a Lambder server did not write
|
|
48
|
-
* the body (a hand-built mock answer, a proxy); refusalMessageOf reads
|
|
49
|
-
* whatever arrives as a message.
|
|
49
|
+
* The refusal's message, or a plain string: the envelope goes out with
|
|
50
|
+
* the message object either way (refusalMessageOf).
|
|
50
51
|
*/
|
|
51
|
-
|
|
52
|
-
logList?:
|
|
52
|
+
refusal?: LambderUncheckedRefusalMessage | string;
|
|
53
|
+
logList?: unknown[];
|
|
53
54
|
/**
|
|
54
55
|
* A crash described in full (name, message, stack, cause chain, where it
|
|
55
56
|
* happened), for a caller that is allowed to see it: a global error
|
|
@@ -60,16 +61,54 @@ export type LambderApiResponseConfig = {
|
|
|
60
61
|
crash?: LambderCrashDetail;
|
|
61
62
|
};
|
|
62
63
|
/**
|
|
63
|
-
*
|
|
64
|
-
* handler
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
64
|
+
* What `res.apiRefusal` writes: an answer to an API call from outside its
|
|
65
|
+
* handler (a hook, a fallback, the input validation handler, the global
|
|
66
|
+
* error handler). It is always a refusal, never a success: it carries an
|
|
67
|
+
* refusal or one of the three flags, which is what a reader tells it
|
|
68
|
+
* from a handler's output by, so a caller's success is only ever the
|
|
69
|
+
* handler's parsed output.
|
|
70
|
+
*
|
|
71
|
+
* Its message carries a framework code or none, and never data: it answers
|
|
72
|
+
* outside any one endpoint's declared refusals, which are what a reader's
|
|
73
|
+
* types allow.
|
|
68
74
|
*/
|
|
69
|
-
export type
|
|
75
|
+
export type LambderApiRefusalConfig = Omit<LambderRefusalEnvelopeFields, "refusal"> & {
|
|
76
|
+
refusal?: LambderPlainRefusalMessage | string;
|
|
77
|
+
} & ({
|
|
78
|
+
refusal: LambderPlainRefusalMessage | string;
|
|
79
|
+
} | {
|
|
80
|
+
versionExpired: true;
|
|
81
|
+
} | {
|
|
82
|
+
sessionExpired: true;
|
|
83
|
+
} | {
|
|
84
|
+
notAuthorized: true;
|
|
85
|
+
});
|
|
86
|
+
/** What every API envelope carries. `apiVersion` is always there (null when the server set none): it is how a reader tells a Lambder envelope from another JSON answer, such as API Gateway's own `{ "message": ... }` errors. */
|
|
87
|
+
type LambderApiEnvelopeFields = {
|
|
70
88
|
apiVersion: string | null;
|
|
71
|
-
|
|
89
|
+
logList?: unknown[];
|
|
90
|
+
};
|
|
91
|
+
/** A handler's answer on the wire: its output, parsed through the API's output schema, and nothing else. */
|
|
92
|
+
export type LambderApiSuccessEnvelope<T> = LambderApiEnvelopeFields & {
|
|
93
|
+
payload: T;
|
|
72
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* Every other answer on the wire: a null payload beside the refusal's
|
|
97
|
+
* message and flags. The flags are present only when set. `refusal` is
|
|
98
|
+
* as it came off the wire, which a reader normalizes (refusalMessageOf),
|
|
99
|
+
* since a body no Lambder server wrote (a hand-built mock answer, a proxy)
|
|
100
|
+
* can put anything there.
|
|
101
|
+
*/
|
|
102
|
+
export type LambderApiRefusalEnvelope = LambderApiEnvelopeFields & {
|
|
103
|
+
payload: null;
|
|
104
|
+
versionExpired?: true;
|
|
105
|
+
sessionExpired?: true;
|
|
106
|
+
notAuthorized?: true;
|
|
107
|
+
refusal?: LambderUncheckedRefusalMessage | string;
|
|
108
|
+
crash?: LambderCrashDetail;
|
|
109
|
+
};
|
|
110
|
+
/** The API wire envelope both sides speak: the server writes it, LambderCaller and LambderInvokeCaller read it. */
|
|
111
|
+
export type LambderApiEnvelopeBody<T> = LambderApiSuccessEnvelope<T> | LambderApiRefusalEnvelope;
|
|
73
112
|
/** A value that is already JSON, recursive structures such as z.json() included. */
|
|
74
113
|
type LambderJsonValue = string | number | boolean | null | LambderJsonValue[] | {
|
|
75
114
|
[key: string]: LambderJsonValue;
|
|
@@ -109,15 +148,6 @@ export type LambderJsonOf<T> = unknown extends T ? T : T extends LambderJsonValu
|
|
|
109
148
|
} : T extends ReadonlyMap<unknown, unknown> | ReadonlySet<unknown> ? {} : T extends object ? (Partial<Pick<T, LambderJsonOmissibleKeys<T>>> & Omit<T, LambderJsonOmissibleKeys<T>>) extends infer TKeyed ? {
|
|
110
149
|
[K in keyof TKeyed as K extends string | number ? (string extends K ? K : number extends K ? K : [LambderJsonOf<TKeyed[K]>] extends [never] ? never : K) : never]: LambderJsonOf<TKeyed[K]>;
|
|
111
150
|
} : never : never;
|
|
112
|
-
/**
|
|
113
|
-
* What an API's output reaches the client as: LambderJsonOf of the schema's
|
|
114
|
-
* output, except at the top, where an envelope carries no payload at all
|
|
115
|
-
* rather than a JSON `undefined`. A void or undefined output keeps its type,
|
|
116
|
-
* so a handler and a mock handler answer nothing, and an output that may be
|
|
117
|
-
* undefined keeps that member, which LambderJsonOf drops as it would a
|
|
118
|
-
* member of an object.
|
|
119
|
-
*/
|
|
120
|
-
export type LambderJsonOutputOf<T> = [T] extends [void] ? T : (undefined extends T ? undefined : never) | LambderJsonOf<T>;
|
|
121
151
|
/**
|
|
122
152
|
* One contract entry as addApi/addSessionApi record it: the payload types,
|
|
123
153
|
* the mode, and every declarative option exactly as written. Options that
|
|
@@ -129,12 +159,19 @@ export type LambderJsonOutputOf<T> = [T] extends [void] ? T : (undefined extends
|
|
|
129
159
|
* what it receives (the output schema's z.output as JSON, see
|
|
130
160
|
* LambderJsonOf). The handler's own types are the other side of each, and
|
|
131
161
|
* are not recorded here.
|
|
162
|
+
*
|
|
163
|
+
* `Refusals` is the one member not recorded as written: it is every code the
|
|
164
|
+
* endpoint can refuse with, its own `refusals` option and those of the guards
|
|
165
|
+
* it declares together, each mapped to `{ data }` (the data as JSON) or `{}`.
|
|
166
|
+
* A reader needs the whole set, and the guards' part is not in the entry.
|
|
132
167
|
*/
|
|
133
|
-
export type LambderContractEntry<In, Out, Mode extends LambderApiMode, GuardInputs = never, Guards = never, RateLimit = never, Idempotency = never> = {
|
|
168
|
+
export type LambderContractEntry<In, Out, Mode extends LambderApiMode, GuardInputs = never, Guards = never, RateLimit = never, Idempotency = never, Refusals = never> = {
|
|
134
169
|
input: In;
|
|
135
170
|
output: Out;
|
|
136
171
|
mode: Mode;
|
|
137
|
-
} & ([
|
|
172
|
+
} & ([Refusals] extends [never] ? {} : {
|
|
173
|
+
refusals: Refusals;
|
|
174
|
+
}) & ([GuardInputs] extends [never] ? {} : {
|
|
138
175
|
guardInputs: GuardInputs;
|
|
139
176
|
}) & ([Guards] extends [never] ? {} : {
|
|
140
177
|
guards: Guards;
|
|
@@ -220,4 +257,32 @@ export type LambderContractRateLimitNames<C, M extends LambderApiMode = LambderA
|
|
|
220
257
|
export type LambderContractIdempotencyOf<C, K extends keyof C> = C[K] extends {
|
|
221
258
|
idempotency: infer I;
|
|
222
259
|
} ? I : never;
|
|
260
|
+
/**
|
|
261
|
+
* The codes endpoint K can refuse with, each mapped to `{ data }` or `{}`;
|
|
262
|
+
* `{}` when it declares none.
|
|
263
|
+
*/
|
|
264
|
+
export type LambderContractRefusalsOf<C, K extends keyof C> = C[K] extends {
|
|
265
|
+
refusals: infer R;
|
|
266
|
+
} ? R : {};
|
|
267
|
+
/**
|
|
268
|
+
* The refusal message a call to endpoint K can come back with: one arm per
|
|
269
|
+
* code it declares, plus the framework's and the uncoded refusal. An untyped
|
|
270
|
+
* contract (one with an index signature) is read as any code.
|
|
271
|
+
*/
|
|
272
|
+
export type LambderContractRefusalMessage<C, K extends keyof C> = 0 extends 1 & C ? LambderUncheckedRefusalMessage : string extends keyof C ? LambderUncheckedRefusalMessage : LambderRefusalMessage<LambderContractRefusalsOf<C, K>>;
|
|
273
|
+
type LambderContractRefusalMaps<C> = {
|
|
274
|
+
[K in keyof C]: LambderContractRefusalsOf<C, K>;
|
|
275
|
+
}[keyof C];
|
|
276
|
+
type LambderContractRefusalCodes<C> = LambderContractRefusalMaps<C> extends infer TMap ? TMap extends unknown ? keyof TMap & string : never : never;
|
|
277
|
+
/**
|
|
278
|
+
* Every code any endpoint of the contract declares, mapped to its `{ data }`
|
|
279
|
+
* or `{}`. Built code by code, not by joining the endpoints' own maps, so a
|
|
280
|
+
* code many endpoints declare is one arm rather than one per endpoint (the
|
|
281
|
+
* vocabulary gives a code one data shape wherever it is used).
|
|
282
|
+
*/
|
|
283
|
+
export type LambderContractRefusals<C> = {
|
|
284
|
+
[TCode in LambderContractRefusalCodes<C>]: Extract<LambderContractRefusalMaps<C>, Record<TCode, unknown>>[TCode];
|
|
285
|
+
};
|
|
286
|
+
/** The refusal message any call of the contract can come back with: what a handler for every endpoint at once (a caller's refusalHandler) is handed. */
|
|
287
|
+
export type LambderContractAnyRefusalMessage<C> = 0 extends 1 & C ? LambderUncheckedRefusalMessage : string extends keyof C ? LambderUncheckedRefusalMessage : LambderRefusalMessage<LambderContractRefusals<C>>;
|
|
223
288
|
export {};
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
* declared here too and re-exported by the engines that read them.
|
|
17
17
|
*/
|
|
18
18
|
import type { LambderApiMode, LambderGuardNamesIn } from "./LambderApiContract.js";
|
|
19
|
-
import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "./LambderApiOptionValues.js";
|
|
20
|
-
import type { LambderAppRefusalMessage } from "./LambderApiRefusal.js";
|
|
19
|
+
import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitMessage, LambderRateLimitOptionValue, LambderRefusalsOptionValue } from "./LambderApiOptionValues.js";
|
|
21
20
|
import type { LambderRateLimitPolicy } from "../contracts/LambderRateLimiter.js";
|
|
22
21
|
/**
|
|
23
22
|
* When a guard runs, relative to the API's input validation.
|
|
@@ -64,17 +63,19 @@ export type LambderRateLimitBudget = "perApi" | "perPolicy";
|
|
|
64
63
|
*/
|
|
65
64
|
export type LambderRateLimitChargeAt = "beforeGuards" | "afterGuards";
|
|
66
65
|
/**
|
|
67
|
-
* One API as the generated module records it: its mode and its
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
66
|
+
* One API as the generated module records it: its mode and its declarative
|
|
67
|
+
* options exactly as written at registration. A guard's parameter is written
|
|
68
|
+
* as the JSON it is (a permission string, a list of them, a reason), which is
|
|
69
|
+
* what makes the module plain data: a parameter that is code or a class
|
|
70
|
+
* instance fails the write and names the API. `refusals` is the API's own
|
|
71
|
+
* option; the codes its guards add are on their declarations.
|
|
72
72
|
*/
|
|
73
73
|
export type LambderApiOptionEntry = {
|
|
74
74
|
mode: LambderApiMode;
|
|
75
75
|
guards?: LambderGuardsOptionValue;
|
|
76
76
|
rateLimit?: LambderRateLimitOptionValue;
|
|
77
77
|
idempotency?: LambderApiIdempotencyOption;
|
|
78
|
+
refusals?: LambderRefusalsOptionValue;
|
|
78
79
|
};
|
|
79
80
|
/**
|
|
80
81
|
* One rate-limit policy as the generated module records it: its windows,
|
|
@@ -87,21 +88,27 @@ export type LambderRateLimitPolicyEntry = LambderRateLimitPolicy & {
|
|
|
87
88
|
per?: "ip" | "session" | "custom";
|
|
88
89
|
budget?: LambderRateLimitBudget;
|
|
89
90
|
chargeAt?: LambderRateLimitChargeAt;
|
|
90
|
-
|
|
91
|
+
refusal?: LambderRateLimitMessage;
|
|
91
92
|
};
|
|
92
93
|
/**
|
|
93
94
|
* One guard as the generated module records it: how it is fed (a slice of
|
|
94
95
|
* the API's own payload, a value the client sends separately, or nothing),
|
|
95
|
-
* whether it needs a session,
|
|
96
|
-
*
|
|
97
|
-
*
|
|
96
|
+
* whether it needs a session, when it runs, and the refusal codes it
|
|
97
|
+
* declares, which join those of every API declaring it. The schema behind an
|
|
98
|
+
* input mode is never written; the mode alone is what a mock guard standing
|
|
99
|
+
* in for it has to match.
|
|
98
100
|
*/
|
|
99
101
|
export type LambderGuardDeclarationEntry = {
|
|
100
102
|
input: "apiInput" | "guardInput" | "none";
|
|
101
103
|
session: boolean;
|
|
102
104
|
runAt: LambderGuardRunAt;
|
|
105
|
+
refusals?: readonly string[];
|
|
103
106
|
};
|
|
104
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Everything `lambder.apiOptionEntries()` reports, and the three tables the
|
|
109
|
+
* generated module exports. The refusal vocabulary is not among them: it is
|
|
110
|
+
* shared code, which the mock declares as the server does.
|
|
111
|
+
*/
|
|
105
112
|
export type LambderApiOptionEntries = {
|
|
106
113
|
apis: Record<string, LambderApiOptionEntry>;
|
|
107
114
|
rateLimitPolicies: Record<string, LambderRateLimitPolicyEntry>;
|
|
@@ -1,29 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The runtime shapes of the
|
|
3
|
-
* idempotency), declared below both the contract that records them
|
|
4
|
-
* engines that enforce them so neither has to import the other.
|
|
2
|
+
* The runtime shapes of the per-API declarative options (guards, rate limit,
|
|
3
|
+
* idempotency, refusals), declared below both the contract that records them
|
|
4
|
+
* and the engines that enforce them so neither has to import the other.
|
|
5
5
|
*
|
|
6
6
|
* A contract type carries these options exactly as an API wrote them, and the
|
|
7
7
|
* engines in `api/` read the same shapes back. Declaring them here is what
|
|
8
8
|
* keeps `shared/` at the bottom of the stack: without it the contract would
|
|
9
9
|
* name an `api/` type and `shared/` would depend on a layer above it.
|
|
10
10
|
*/
|
|
11
|
-
import type {
|
|
11
|
+
import type { LambderUncheckedRefusalMessage } from "./LambderApiRefusal.js";
|
|
12
12
|
import type { LambderRateLimitPolicy } from "../contracts/LambderRateLimiter.js";
|
|
13
13
|
/** The guards option's runtime shape: a name, ordered names, or a name-to-param map. */
|
|
14
14
|
export type LambderGuardsOptionValue = string | readonly string[] | Readonly<Record<string, unknown>>;
|
|
15
|
+
/**
|
|
16
|
+
* The message a rate-limit refusal carries, as a policy or an API writes it:
|
|
17
|
+
* its type, title and content. Its code is always `lambder/rate-limited` and
|
|
18
|
+
* it carries no data, so a rate limit is never a code an endpoint has to
|
|
19
|
+
* declare.
|
|
20
|
+
*/
|
|
21
|
+
export type LambderRateLimitMessage = Omit<LambderUncheckedRefusalMessage, "code" | "data">;
|
|
15
22
|
/**
|
|
16
23
|
* What an API may override on a policy it references, in the map form of the
|
|
17
24
|
* rateLimit option. Windows merge over the policy's own (a tighter burst keeps
|
|
18
25
|
* the policy's daily cap) and are only overridable on "perApi" budgets: a
|
|
19
|
-
* shared counter has one set of numbers.
|
|
26
|
+
* shared counter has one set of numbers. refusal is per-API text, so it
|
|
20
27
|
* is overridable on either budget.
|
|
21
28
|
*/
|
|
22
29
|
export type LambderRateLimitOverride = LambderRateLimitPolicy & {
|
|
23
|
-
|
|
30
|
+
refusal?: LambderRateLimitMessage;
|
|
24
31
|
};
|
|
25
32
|
/** The rateLimit option's runtime shape: a name, ordered names, or a name-to-override map (LambderRateLimitOption narrows the names and overrides per policy). */
|
|
26
33
|
export type LambderRateLimitOptionValue = string | readonly string[] | Readonly<Record<string, true | LambderRateLimitOverride | undefined>>;
|
|
34
|
+
/** The refusals option's runtime shape: one code of the app's vocabulary, or a non-empty list of them. */
|
|
35
|
+
export type LambderRefusalsOptionValue = string | readonly string[];
|
|
27
36
|
/** The per-endpoint idempotency declaration: on, or on with its own replay TTL. */
|
|
28
37
|
export type LambderApiIdempotencyOption = boolean | {
|
|
29
38
|
/** Seconds this API's stored answer replays for; overrides defaultTtlSeconds. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The runtime shapes of the
|
|
3
|
-
* idempotency), declared below both the contract that records them
|
|
4
|
-
* engines that enforce them so neither has to import the other.
|
|
2
|
+
* The runtime shapes of the per-API declarative options (guards, rate limit,
|
|
3
|
+
* idempotency, refusals), declared below both the contract that records them
|
|
4
|
+
* and the engines that enforce them so neither has to import the other.
|
|
5
5
|
*
|
|
6
6
|
* A contract type carries these options exactly as an API wrote them, and the
|
|
7
7
|
* engines in `api/` read the same shapes back. Declaring them here is what
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* Pure and dependency-free, so the browser entry can include it.
|
|
11
11
|
*/
|
|
12
12
|
import type { z } from "zod";
|
|
13
|
-
import type {
|
|
14
|
-
import { type
|
|
13
|
+
import type { LambderApiRefusalEnvelope, LambderApiSuccessEnvelope } from "./LambderApiContract.js";
|
|
14
|
+
import { type LambderUncheckedRefusalMessage } from "./LambderApiRefusal.js";
|
|
15
15
|
/**
|
|
16
16
|
* The 422 body's `zodError` as it survives JSON: a ZodError's name and
|
|
17
17
|
* message, and its issues spelled out. Not a ZodError instance (it has no
|
|
@@ -22,22 +22,33 @@ export type LambderValidationError = {
|
|
|
22
22
|
message: string;
|
|
23
23
|
issues: z.core.$ZodIssue[];
|
|
24
24
|
};
|
|
25
|
-
export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | '
|
|
26
|
-
/**
|
|
25
|
+
export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'refusal' | 'unknown';
|
|
26
|
+
/**
|
|
27
|
+
* The endpoint's handler answered: `payload` is its output, parsed through
|
|
28
|
+
* the API's output schema on the server, exactly the contract's type. Only a
|
|
29
|
+
* handler's answer reads as a success: everything else the server writes
|
|
30
|
+
* into an API call is a refusal (see LambderApiRefusalConfig), and a reader
|
|
31
|
+
* refuses a success whose payload is not an object (see isObjectPayload).
|
|
32
|
+
*/
|
|
27
33
|
export type LambderApiSuccessOutcome<T> = {
|
|
28
34
|
ok: true;
|
|
29
|
-
payload: T
|
|
30
|
-
response:
|
|
35
|
+
payload: T;
|
|
36
|
+
response: LambderApiSuccessEnvelope<T>;
|
|
31
37
|
/** The answer's logList, when it carried one. See LambderApiFailureFields.logList: the field is on every arm so a caller surfaces logs once. */
|
|
32
38
|
logList?: unknown[];
|
|
33
39
|
};
|
|
34
|
-
/**
|
|
35
|
-
|
|
40
|
+
/**
|
|
41
|
+
* What every failure carries, whatever went wrong. TMessage is the refusal
|
|
42
|
+
* message the endpoint can answer with (LambderContractRefusalMessage), on
|
|
43
|
+
* every arm: a refusal flagged notAuthorized arrives as that reason and still
|
|
44
|
+
* carries its message.
|
|
45
|
+
*/
|
|
46
|
+
type LambderApiFailureFields<TMessage extends LambderUncheckedRefusalMessage> = {
|
|
36
47
|
ok: false;
|
|
37
48
|
/** HTTP status, when a response was received. */
|
|
38
49
|
status?: number;
|
|
39
|
-
/**
|
|
40
|
-
|
|
50
|
+
/** The envelope's refusal, when the server provided one: always the message object, a plain string having been read as one (refusalMessageOf). */
|
|
51
|
+
refusal?: TMessage;
|
|
41
52
|
/** Seconds to wait before retrying, from the response's Retry-After header (rate-limit refusals send it, and so may a 503). */
|
|
42
53
|
retryAfterSeconds?: number;
|
|
43
54
|
/**
|
|
@@ -57,36 +68,37 @@ type LambderApiFailureFields = {
|
|
|
57
68
|
* A 5xx also carries `response` when the server answered with Lambder's own
|
|
58
69
|
* envelope, which is how a crash detail and a logList arrive with it.
|
|
59
70
|
*/
|
|
60
|
-
export type LambderApiCallFailure<
|
|
71
|
+
export type LambderApiCallFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
|
|
61
72
|
reason: 'network' | 'timeout' | 'server' | 'unknown';
|
|
62
73
|
error: Error;
|
|
63
|
-
response?:
|
|
74
|
+
response?: LambderApiRefusalEnvelope;
|
|
64
75
|
};
|
|
65
76
|
/** HTTP 422: the server rejected the input against the API's schema. Always carries the issues. */
|
|
66
|
-
export type LambderApiValidationFailure = LambderApiFailureFields & {
|
|
77
|
+
export type LambderApiValidationFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
|
|
67
78
|
reason: 'validation';
|
|
68
79
|
zodError: LambderValidationError;
|
|
69
80
|
};
|
|
70
|
-
/** The server answered, and the envelope itself says the call is refused. Always carries that envelope, and
|
|
71
|
-
export type LambderApiEnvelopeFailure<
|
|
72
|
-
response:
|
|
81
|
+
/** The server answered, and the envelope itself says the call is refused. Always carries that envelope, and a `refusal` failure always carries the refusal. */
|
|
82
|
+
export type LambderApiEnvelopeFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
|
|
83
|
+
response: LambderApiRefusalEnvelope;
|
|
73
84
|
} & ({
|
|
74
85
|
reason: 'versionExpired' | 'sessionExpired' | 'notAuthorized';
|
|
75
86
|
} | {
|
|
76
|
-
reason: '
|
|
77
|
-
|
|
87
|
+
reason: 'refusal';
|
|
88
|
+
refusal: TMessage;
|
|
78
89
|
});
|
|
79
90
|
/**
|
|
80
|
-
* Discriminated result of an API call: `ok: true` carries the
|
|
81
|
-
* failure carries a machine-readable reason, so "the server
|
|
82
|
-
* and "the request failed" are never conflated.
|
|
91
|
+
* Discriminated result of an API call: `ok: true` carries the handler's
|
|
92
|
+
* output, every failure carries a machine-readable reason, so "the server
|
|
93
|
+
* answered" and "the request failed" are never conflated.
|
|
83
94
|
*
|
|
84
95
|
* The failure side is discriminated by `reason`, so narrowing to a reason
|
|
85
96
|
* narrows to what it carries: `zodError` after `reason === 'validation'`,
|
|
86
97
|
* `response` after an envelope reason, `error` after the rest, with no
|
|
87
|
-
* non-null assertion needed.
|
|
98
|
+
* non-null assertion needed. TMessage is the endpoint's refusal message
|
|
99
|
+
* (LambderContractRefusalMessage), which `refusal.code` narrows.
|
|
88
100
|
*/
|
|
89
|
-
export type LambderApiOutcome<T> = LambderApiSuccessOutcome<T> | LambderApiCallFailure<
|
|
101
|
+
export type LambderApiOutcome<T, TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiSuccessOutcome<T> | LambderApiCallFailure<TMessage> | LambderApiValidationFailure<TMessage> | LambderApiEnvelopeFailure<TMessage>;
|
|
90
102
|
/**
|
|
91
103
|
* What reading one HTTP answer can produce: LambderApiOutcome minus the
|
|
92
104
|
* three reasons no answer carries (`network` and `timeout` are the caller's
|
|
@@ -94,9 +106,9 @@ export type LambderApiOutcome<T> = LambderApiSuccessOutcome<T> | LambderApiCallF
|
|
|
94
106
|
* has handled `server` and `validation` holds a success or an envelope
|
|
95
107
|
* refusal, both of which carry the envelope.
|
|
96
108
|
*/
|
|
97
|
-
export type LambderApiAnswerOutcome<T> = LambderApiSuccessOutcome<T> | (LambderApiCallFailure<
|
|
109
|
+
export type LambderApiAnswerOutcome<T, TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiSuccessOutcome<T> | (LambderApiCallFailure<TMessage> & {
|
|
98
110
|
reason: 'server';
|
|
99
|
-
}) | LambderApiValidationFailure | LambderApiEnvelopeFailure<
|
|
111
|
+
}) | LambderApiValidationFailure<TMessage> | LambderApiEnvelopeFailure<TMessage>;
|
|
100
112
|
/**
|
|
101
113
|
* What the mapping needs from an HTTP answer, whichever transport produced it.
|
|
102
114
|
*
|
|
@@ -130,11 +142,21 @@ export type LambderApiHttpAnswer = {
|
|
|
130
142
|
/**
|
|
131
143
|
* Reads one HTTP answer into an outcome. A 5xx is a server failure that keeps
|
|
132
144
|
* the envelope when the server sent one (Lambder's own 500 body carries
|
|
133
|
-
*
|
|
145
|
+
* refusal, and a global error handler may add crash and logList); a
|
|
134
146
|
* 422 is a validation failure only with Lambder's validation body; anything
|
|
135
147
|
* else must be a JSON envelope, whose flags are honoured in a fixed order.
|
|
136
148
|
* A failure read off any answer but a 422 carries the answer's Retry-After
|
|
137
149
|
* as retryAfterSeconds.
|
|
150
|
+
*
|
|
151
|
+
* A success is a 2xx envelope with no flag and no refusal whose payload
|
|
152
|
+
* is an object or an array: what only a handler's parsed output is. Anything
|
|
153
|
+
* else that says nothing is wrong (a null or primitive payload a hand-built
|
|
154
|
+
* body, a proxy or an old stored answer carries) is a server failure, so a
|
|
155
|
+
* success's payload is always the contract's output and never falsy.
|
|
156
|
+
*
|
|
157
|
+
* TMessage is the endpoint's refusal message type: the reader cannot check
|
|
158
|
+
* a code against declarations it does not have, and relies on the server,
|
|
159
|
+
* which never sends a code the endpoint did not declare.
|
|
138
160
|
*/
|
|
139
|
-
export declare const resolveApiOutcome: <T>(answer: LambderApiHttpAnswer) => Promise<LambderApiAnswerOutcome<T>>;
|
|
161
|
+
export declare const resolveApiOutcome: <T, TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage>(answer: LambderApiHttpAnswer) => Promise<LambderApiAnswerOutcome<T, TMessage>>;
|
|
140
162
|
export {};
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Pure and dependency-free, so the browser entry can include it.
|
|
11
11
|
*/
|
|
12
12
|
import { refusalMessageOf } from "./LambderApiRefusal.js";
|
|
13
|
+
import { isObjectPayload } from "./LambderObjectPayload.js";
|
|
13
14
|
/**
|
|
14
15
|
* Whether a parsed body is Lambder's envelope, which always carries
|
|
15
16
|
* apiVersion (null when the server set none). An object without it is
|
|
@@ -21,15 +22,25 @@ const isApiEnvelope = (value) => value !== null && typeof value === "object" &&
|
|
|
21
22
|
/**
|
|
22
23
|
* Reads one HTTP answer into an outcome. A 5xx is a server failure that keeps
|
|
23
24
|
* the envelope when the server sent one (Lambder's own 500 body carries
|
|
24
|
-
*
|
|
25
|
+
* refusal, and a global error handler may add crash and logList); a
|
|
25
26
|
* 422 is a validation failure only with Lambder's validation body; anything
|
|
26
27
|
* else must be a JSON envelope, whose flags are honoured in a fixed order.
|
|
27
28
|
* A failure read off any answer but a 422 carries the answer's Retry-After
|
|
28
29
|
* as retryAfterSeconds.
|
|
30
|
+
*
|
|
31
|
+
* A success is a 2xx envelope with no flag and no refusal whose payload
|
|
32
|
+
* is an object or an array: what only a handler's parsed output is. Anything
|
|
33
|
+
* else that says nothing is wrong (a null or primitive payload a hand-built
|
|
34
|
+
* body, a proxy or an old stored answer carries) is a server failure, so a
|
|
35
|
+
* success's payload is always the contract's output and never falsy.
|
|
36
|
+
*
|
|
37
|
+
* TMessage is the endpoint's refusal message type: the reader cannot check
|
|
38
|
+
* a code against declarations it does not have, and relies on the server,
|
|
39
|
+
* which never sends a code the endpoint did not declare.
|
|
29
40
|
*/
|
|
30
41
|
export const resolveApiOutcome = async (answer) => {
|
|
31
42
|
const status = answer.status;
|
|
32
|
-
const
|
|
43
|
+
const refusalOf = (envelope) => envelope?.refusal !== undefined ? { refusal: refusalMessageOf(envelope.refusal) } : {};
|
|
33
44
|
// Retry-After (delta-seconds) rides every refusal that knows its reset
|
|
34
45
|
// time, e.g. a rate limit, and a 503 that says when to come back; absent
|
|
35
46
|
// or unreadable is undefined.
|
|
@@ -53,7 +64,7 @@ export const resolveApiOutcome = async (answer) => {
|
|
|
53
64
|
catch { /* body unavailable */ }
|
|
54
65
|
return {
|
|
55
66
|
ok: false, reason: 'server', status,
|
|
56
|
-
...
|
|
67
|
+
...refusalOf(envelope),
|
|
57
68
|
logList: envelope?.logList,
|
|
58
69
|
...(envelope ? { response: envelope } : {}),
|
|
59
70
|
error: new Error("Request failed: " + status + " - " + (answer.statusText ?? "")),
|
|
@@ -91,21 +102,27 @@ export const resolveApiOutcome = async (answer) => {
|
|
|
91
102
|
const gatewayMessage = typeof data.message === "string" ? `: ${data.message}` : "";
|
|
92
103
|
return { ok: false, reason: 'server', status, error: new Error(`Request failed: ${status} - the answer is not a Lambder envelope${gatewayMessage}`), ...retryAfter };
|
|
93
104
|
}
|
|
105
|
+
const asRefusal = data;
|
|
94
106
|
if (data.versionExpired)
|
|
95
|
-
return { ok: false, reason: 'versionExpired', status, ...
|
|
107
|
+
return { ok: false, reason: 'versionExpired', status, ...refusalOf(data), response: asRefusal, logList: data.logList, ...retryAfter };
|
|
96
108
|
if (data.sessionExpired)
|
|
97
|
-
return { ok: false, reason: 'sessionExpired', status, ...
|
|
109
|
+
return { ok: false, reason: 'sessionExpired', status, ...refusalOf(data), response: asRefusal, logList: data.logList, ...retryAfter };
|
|
98
110
|
if (data.notAuthorized)
|
|
99
|
-
return { ok: false, reason: 'notAuthorized', status, ...
|
|
100
|
-
// Presence, not truthiness: the writer keeps
|
|
111
|
+
return { ok: false, reason: 'notAuthorized', status, ...refusalOf(data), response: asRefusal, logList: data.logList, ...retryAfter };
|
|
112
|
+
// Presence, not truthiness: the writer keeps a refusal an app set
|
|
101
113
|
// to the empty string, and a refusal that says nothing is still a
|
|
102
114
|
// refusal, not a success.
|
|
103
|
-
if (data.
|
|
104
|
-
return { ok: false, reason: '
|
|
115
|
+
if (data.refusal !== undefined)
|
|
116
|
+
return { ok: false, reason: 'refusal', status, refusal: refusalMessageOf(data.refusal), response: asRefusal, logList: data.logList, ...retryAfter };
|
|
105
117
|
// An envelope that says nothing is wrong is still not a success when the
|
|
106
|
-
// status says otherwise
|
|
118
|
+
// status says otherwise, nor when its payload is not what a handler
|
|
119
|
+
// answers. Neither is a refusal either, so neither carries the body as
|
|
120
|
+
// one: the error says what it was.
|
|
107
121
|
if (status < 200 || status >= 300) {
|
|
108
|
-
return { ok: false, reason: 'server', status,
|
|
122
|
+
return { ok: false, reason: 'server', status, logList: data.logList, error: new Error(`Request failed: ${status} - ${answer.statusText ?? ""}`), ...retryAfter };
|
|
123
|
+
}
|
|
124
|
+
if (!isObjectPayload(data.payload)) {
|
|
125
|
+
return { ok: false, reason: 'server', status, logList: data.logList, error: new Error("Request failed: the answer's payload is not an object or an array, so no handler of this API wrote it") };
|
|
109
126
|
}
|
|
110
127
|
return { ok: true, payload: data.payload, response: data, logList: data.logList };
|
|
111
128
|
};
|