lambder 9.0.3 → 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 +25 -11
- 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.d.ts +5 -4
- 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 +12 -8
- 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/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/testing/LambderTestApp.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/package.json +1 -1
|
@@ -3,15 +3,18 @@ import { readApiEnvelope, cookieValuesByName, lowercaseHeaderNames } from "../ap
|
|
|
3
3
|
import { bindCallTools, createApiCallContext, responseToolsOf } from "../api/LambderApiCallContext.js";
|
|
4
4
|
import { toHttpAnswer } from "../api/LambderApiAnswer.js";
|
|
5
5
|
import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { envelopeAnswer, crashAnswer, plainRefusalEnvelope, refusalEnvelope, successEnvelope, } from "../api/LambderApiEnvelope.js";
|
|
7
|
+
import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
|
|
8
|
+
import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
|
|
9
|
+
import { LambderApiRefusal, LAMBDER_REFUSAL_CODES, refuse as refuseApiCall } from "../shared/wire/LambderApiRefusal.js";
|
|
10
|
+
import { allowedRefusalOf, readRefusalVocabulary, toRefusalCodes, } from "../api/LambderApiRefusals.js";
|
|
8
11
|
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
9
12
|
import { buildTransportEnvelope } from "../shared/transport/LambderApiTransport.js";
|
|
10
13
|
import { lambderCookieJarTransport } from "../shared/transport/lambderCookieJarTransport.js";
|
|
11
14
|
import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
|
|
12
15
|
import { serializeClearCookie } from "../shared/wire/LambderCookie.js";
|
|
13
16
|
import { LOOPBACK_CLIENT_IP, normalizeClientIp } from "../shared/util/LambderClientIp.js";
|
|
14
|
-
import { lambderGuardBuilder } from "../api/LambderApiGuards.js";
|
|
17
|
+
import { lambderGuardBuilder, toGuardEntries } from "../api/LambderApiGuards.js";
|
|
15
18
|
import { lambderRateLimitKeyBuilder } from "../api/LambderApiRateLimits.js";
|
|
16
19
|
import { LambderMockFailureInjector, LambderMockTransportError } from "./LambderMockFailureInjector.js";
|
|
17
20
|
import { LambderMockCallRecorder } from "./LambderMockCallRecorder.js";
|
|
@@ -49,6 +52,8 @@ const defaultCookieHost = () => globalThis.location?.host || "localhost";
|
|
|
49
52
|
* which fixes the contract and session types first so everything else is
|
|
50
53
|
* inferred from the options. `TDerived` is true for a mock created with the
|
|
51
54
|
* generated `apiOptions` table, whose entries are their handlers alone.
|
|
55
|
+
* `TVocabulary` and `TCodesRequired` are the refusal vocabulary the init
|
|
56
|
+
* declared (declareRefusals), which types every handler's ctx.refuse.
|
|
52
57
|
*/
|
|
53
58
|
export class LambderMockApp {
|
|
54
59
|
apiVersion;
|
|
@@ -88,11 +93,25 @@ export class LambderMockApp {
|
|
|
88
93
|
registry = new LambderMockEntryRegistry();
|
|
89
94
|
/** The server's declared options per API, when create() was given the generated table; the entries' declarations come from here. */
|
|
90
95
|
apiOptions;
|
|
96
|
+
/** The server's guard declarations, when create() was given the generated table: the refusal codes a declared guard adds to an entry. */
|
|
97
|
+
guardDeclarations;
|
|
98
|
+
/** The server's refusal vocabulary, when create() was given the generated table: whether each code an entry may refuse with carries data. */
|
|
99
|
+
refusalVocabulary;
|
|
100
|
+
/** Whether every refusal an entry answers with has to name a code, as the server's declareRefusals() requireCodes. */
|
|
101
|
+
requireRefusalCodes;
|
|
91
102
|
/** The jars the runtime owns and what it planted in document.cookie (see LambderMockBrowserCookies). */
|
|
92
103
|
browserCookies = new LambderMockBrowserCookies();
|
|
93
|
-
constructor(options
|
|
104
|
+
constructor(options,
|
|
105
|
+
/** The vocabulary the init declared, or null: what initLambderMock().declareRefusals() passes on. */
|
|
106
|
+
declared = null) {
|
|
94
107
|
this.apiVersion = options.apiVersion ?? null;
|
|
95
108
|
this.apiOptions = options.apiOptions ?? null;
|
|
109
|
+
this.guardDeclarations = options.guardDeclarations ?? null;
|
|
110
|
+
this.refusalVocabulary = declared?.vocabulary ?? null;
|
|
111
|
+
this.requireRefusalCodes = declared?.requireCodes ?? false;
|
|
112
|
+
if (this.requireRefusalCodes && !this.apiOptions) {
|
|
113
|
+
throw new Error("LambderMockApp: requireCodes needs the apiOptions table, which holds the codes each entry may refuse with.");
|
|
114
|
+
}
|
|
96
115
|
this.failures = new LambderMockFailureInjector({ apiVersion: this.apiVersion, latency: options.latency ?? 0 });
|
|
97
116
|
this.recorder = new LambderMockCallRecorder({ callLogSize: options.callLogSize ?? DEFAULT_CALL_LOG_SIZE });
|
|
98
117
|
// The loopback address when nothing names a client, as for a request
|
|
@@ -124,15 +143,14 @@ export class LambderMockApp {
|
|
|
124
143
|
maxRequestPayloadBytes: options.maxRequestPayloadBytes,
|
|
125
144
|
// The server app's own answer to a bad input, where it has one:
|
|
126
145
|
// without it the mock would answer 422 where the server answers,
|
|
127
|
-
// say, 200 with
|
|
146
|
+
// say, 200 with a refusal, and a form's error handling would
|
|
128
147
|
// be tested against an answer production never gives.
|
|
129
148
|
onInvalidInput: options.onInvalidInput
|
|
130
149
|
? async (zodError, ctx) => {
|
|
131
150
|
const answer = await options.onInvalidInput(zodError, ctx);
|
|
132
151
|
if (!answer)
|
|
133
152
|
return null;
|
|
134
|
-
|
|
135
|
-
return envelopeAnswer(buildApiEnvelope(this.apiVersion, answer.payload ?? null, { ...config, logList: config.logList ?? ctx.logList }), { statusCode: answer.statusCode ?? 200 });
|
|
153
|
+
return envelopeAnswer(plainRefusalEnvelope(this.apiVersion, answer.config, "the mock's onInvalidInput", ctx.logList), { statusCode: answer.statusCode ?? 200 });
|
|
136
154
|
}
|
|
137
155
|
: undefined,
|
|
138
156
|
sessions: sessionOptions
|
|
@@ -219,6 +237,39 @@ export class LambderMockApp {
|
|
|
219
237
|
}
|
|
220
238
|
return declared;
|
|
221
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* The codes an entry may refuse with, read off the generated tables (its
|
|
242
|
+
* own from apiOptions, its guards' from guardDeclarations), each resolved
|
|
243
|
+
* through the vocabulary the init declared, exactly as the server
|
|
244
|
+
* resolves its own: schema, status and flag. An entry that declares
|
|
245
|
+
* guards needs the guardDeclarations table, and one with any code needs
|
|
246
|
+
* the vocabulary; a code the tables name that the vocabulary lacks means
|
|
247
|
+
* the two describe different servers.
|
|
248
|
+
*/
|
|
249
|
+
declaredRefusalsOf(name, declared) {
|
|
250
|
+
const codes = new Set(toRefusalCodes(declared.refusals));
|
|
251
|
+
const guards = toGuardEntries(declared.guards);
|
|
252
|
+
if (guards.length && !this.guardDeclarations) {
|
|
253
|
+
throw new Error(`LambderMockApp: "${name}" declares guards in the apiOptions table, and create() was not given the guardDeclarations table, which holds the refusal codes they add. Pass both from the generated options module.`);
|
|
254
|
+
}
|
|
255
|
+
for (const { name: guard } of guards) {
|
|
256
|
+
const declaration = this.guardDeclarations && Object.prototype.hasOwnProperty.call(this.guardDeclarations, guard) ? this.guardDeclarations[guard] : undefined;
|
|
257
|
+
for (const code of declaration?.refusals ?? [])
|
|
258
|
+
codes.add(code);
|
|
259
|
+
}
|
|
260
|
+
if (codes.size && !this.refusalVocabulary) {
|
|
261
|
+
throw new Error(`LambderMockApp: "${name}" can refuse with declared codes by the apiOptions table, and the mock init declared no refusal vocabulary. Declare the server's vocabulary, the same object, with initLambderMock().declareRefusals().`);
|
|
262
|
+
}
|
|
263
|
+
const allowed = new Map();
|
|
264
|
+
for (const code of codes) {
|
|
265
|
+
const declaration = this.refusalVocabulary.get(code);
|
|
266
|
+
if (!declaration) {
|
|
267
|
+
throw new Error(`LambderMockApp: "${name}" can refuse with "${code}" by the apiOptions and guardDeclarations tables, which the vocabulary declared on the mock init does not hold. The tables and the vocabulary describe one server: regenerate the tables, or declare the server's vocabulary.`);
|
|
268
|
+
}
|
|
269
|
+
allowed.set(code, allowedRefusalOf(declaration));
|
|
270
|
+
}
|
|
271
|
+
return allowed;
|
|
272
|
+
}
|
|
222
273
|
/** The mode the apiOptions table gives a name, or null without a table or for a name it does not hold. */
|
|
223
274
|
declaredModeOf(name) {
|
|
224
275
|
if (!this.apiOptions || !Object.prototype.hasOwnProperty.call(this.apiOptions, name))
|
|
@@ -243,6 +294,7 @@ export class LambderMockApp {
|
|
|
243
294
|
guards: declarations.guards,
|
|
244
295
|
rateLimit: declarations.rateLimit,
|
|
245
296
|
idempotency: declarations.idempotency,
|
|
297
|
+
refusals: declared ? { codes: this.declaredRefusalsOf(name, declared), codeRequired: this.requireRefusalCodes } : undefined,
|
|
246
298
|
// No cast: the entry's schema is a z.ZodType, the same type the
|
|
247
299
|
// definition holds. A structural { safeParse } here would let a
|
|
248
300
|
// validator that is not a zod schema reach the 422 body as
|
|
@@ -411,7 +463,7 @@ export class LambderMockApp {
|
|
|
411
463
|
// -----------------------------------------------------------------------
|
|
412
464
|
// Control surface
|
|
413
465
|
// -----------------------------------------------------------------------
|
|
414
|
-
/** The next call to the endpoint fails this way; several calls queue in order. */
|
|
466
|
+
/** The next call to the endpoint fails this way; several calls queue in order. An injected refusal names one of the endpoint's declared codes, its data as a handler raises it (see LambderMockRefusalsOf), and is checked and sent as a real one is. */
|
|
415
467
|
failNext(apiName, failure) {
|
|
416
468
|
this.failures.failNext(apiName, failure);
|
|
417
469
|
}
|
|
@@ -590,6 +642,9 @@ export class LambderMockApp {
|
|
|
590
642
|
methods: {
|
|
591
643
|
rateLimit: async (policy, key) => { await chargeRateLimit(policy, key, true); },
|
|
592
644
|
isRateLimited: (policy, key) => chargeRateLimit(policy, key, false),
|
|
645
|
+
// refuse() itself, typed on a handler's context to the
|
|
646
|
+
// endpoint's declared codes; checked where it is rendered.
|
|
647
|
+
refuse: refuseApiCall,
|
|
593
648
|
...responseToolsOf(ctx, request.host),
|
|
594
649
|
},
|
|
595
650
|
});
|
|
@@ -665,7 +720,7 @@ export class LambderMockApp {
|
|
|
665
720
|
// normally.
|
|
666
721
|
const failure = this.failures.take(request.apiName);
|
|
667
722
|
if (failure) {
|
|
668
|
-
answer = await this.failures.answerFor(failure, request);
|
|
723
|
+
answer = await this.failures.answerFor(failure, request, entry?.definition.refusals);
|
|
669
724
|
outcome = "injected";
|
|
670
725
|
}
|
|
671
726
|
else if (prepared) {
|
|
@@ -682,9 +737,12 @@ export class LambderMockApp {
|
|
|
682
737
|
// The payload the handler sees is the restored one.
|
|
683
738
|
callCtx.payload = request.payload;
|
|
684
739
|
const payload = await handler(callCtx);
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
740
|
+
// The server's rule, where the server applies it: an
|
|
741
|
+
// answer is an object or an array, and a handler that
|
|
742
|
+
// returns anything else has broken its contract.
|
|
743
|
+
if (!isObjectPayload(payload))
|
|
744
|
+
throw new LambderApiOutputValidationError(request.apiName, { notObject: describePayloadKind(payload) });
|
|
745
|
+
return envelopeAnswer(successEnvelope(this.apiVersion, payload, callCtx.logList));
|
|
688
746
|
}
|
|
689
747
|
// A notMocked entry refuses where a handler would run, not
|
|
690
748
|
// ahead of the pipeline, so the steps that precede
|
|
@@ -693,7 +751,7 @@ export class LambderMockApp {
|
|
|
693
751
|
// compressed payload reaches the events.
|
|
694
752
|
: async () => {
|
|
695
753
|
throw new LambderApiRefusal(`Not mocked: ${entry.notMockedReason}`, {
|
|
696
|
-
|
|
754
|
+
refusal: { type: "warning", code: LAMBDER_REFUSAL_CODES.notMocked, content: `"${request.apiName}" is not mocked: ${entry.notMockedReason}` },
|
|
697
755
|
});
|
|
698
756
|
}, trace);
|
|
699
757
|
answer = result.answer;
|
|
@@ -713,7 +771,7 @@ export class LambderMockApp {
|
|
|
713
771
|
// for what could have been on the screen. Apps that want the
|
|
714
772
|
// production shape turn it off.
|
|
715
773
|
answer = this.revealHandlerErrors
|
|
716
|
-
? envelopeAnswer(
|
|
774
|
+
? envelopeAnswer(refusalEnvelope(this.apiVersion, { refusal: error.message }), { statusCode: 500 })
|
|
717
775
|
: crashAnswer(this.apiVersion);
|
|
718
776
|
outcome = "crash";
|
|
719
777
|
}
|
|
@@ -849,7 +907,8 @@ export class LambderMockApp {
|
|
|
849
907
|
* });
|
|
850
908
|
* ```
|
|
851
909
|
*/
|
|
852
|
-
|
|
910
|
+
/** The mock init bound to one vocabulary (or none): the builders, refuse and create() that share it. */
|
|
911
|
+
const lambderMockInitOf = (declared) => ({
|
|
853
912
|
/** Builds a mock guard: the server guard's shape, the handler seeing the mock's contexts. */
|
|
854
913
|
guard: lambderGuardBuilder(),
|
|
855
914
|
/**
|
|
@@ -859,6 +918,13 @@ export const initLambderMock = () => ({
|
|
|
859
918
|
* while reading fields the mock context does not have.
|
|
860
919
|
*/
|
|
861
920
|
rateLimitKey: lambderRateLimitKeyBuilder(),
|
|
921
|
+
/**
|
|
922
|
+
* refuse() typed to the vocabulary the mock declared, for a mock guard or
|
|
923
|
+
* a helper of the mock with no endpoint in hand: `code` is one of its
|
|
924
|
+
* codes and `data` the schema's input form, parsed as the server parses
|
|
925
|
+
* it. The free refuse() where the mock declared none.
|
|
926
|
+
*/
|
|
927
|
+
refuse: refuseApiCall,
|
|
862
928
|
/**
|
|
863
929
|
* The mock app, with the guard map and the rate-limit policies inferred
|
|
864
930
|
* from the options.
|
|
@@ -871,6 +937,32 @@ export const initLambderMock = () => ({
|
|
|
871
937
|
* LambderMockSurplusKeys puts the error back on the key.
|
|
872
938
|
*/
|
|
873
939
|
create(options) {
|
|
874
|
-
return new LambderMockApp(options);
|
|
940
|
+
return new LambderMockApp(options, declared);
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
/**
|
|
944
|
+
* The mock's entry point: fixes the contract and session types, and hands
|
|
945
|
+
* out the builders and create() that share them. `declareRefusals()` binds
|
|
946
|
+
* the server's refusal vocabulary too, the same object the server's init
|
|
947
|
+
* declares, so a mock handler's ctx.refuse takes a code's data in the
|
|
948
|
+
* schema's input form and the mock parses it exactly as the server does.
|
|
949
|
+
*/
|
|
950
|
+
export const initLambderMock = () => ({
|
|
951
|
+
...lambderMockInitOf(null),
|
|
952
|
+
/**
|
|
953
|
+
* Declares the server's refusal vocabulary on the mock: the same object,
|
|
954
|
+
* and the same `requireCodes`, the server's init declares, imported from
|
|
955
|
+
* shared code (codes, zod schemas, statuses and flags hold nothing
|
|
956
|
+
* secret). Given, every refusal an entry answers with is checked and sent
|
|
957
|
+
* as the server would send it: the code among the codes the tables give
|
|
958
|
+
* the entry, its data parsed through the code's schema from the input
|
|
959
|
+
* form, and the declaration's status and flag. An entry whose tables name
|
|
960
|
+
* a code needs it.
|
|
961
|
+
*/
|
|
962
|
+
declareRefusals(refusals, options = {}) {
|
|
963
|
+
const vocabulary = readRefusalVocabulary(refusals);
|
|
964
|
+
if (!vocabulary)
|
|
965
|
+
throw new Error("LambderMockApp: declareRefusals() takes the vocabulary, an object of codes.");
|
|
966
|
+
return lambderMockInitOf({ refusals, vocabulary, requireCodes: (options.requireCodes ?? false) });
|
|
875
967
|
},
|
|
876
968
|
});
|
|
@@ -42,7 +42,10 @@ const envelopeOf = (answer) => {
|
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
44
|
/** How an answer reads, in the runtime's vocabulary. */
|
|
45
|
-
const classifyAnswer = (answer,
|
|
45
|
+
const classifyAnswer = (answer, body) => {
|
|
46
|
+
// Read field by field: the body is whatever the answer carried, and a
|
|
47
|
+
// success envelope has none of a refusal's fields.
|
|
48
|
+
const envelope = body;
|
|
46
49
|
if (answer.statusCode >= 500)
|
|
47
50
|
return "crash";
|
|
48
51
|
if (answer.statusCode === 422)
|
|
@@ -55,14 +58,14 @@ const classifyAnswer = (answer, envelope) => {
|
|
|
55
58
|
return "sessionExpired";
|
|
56
59
|
if (envelope.notAuthorized)
|
|
57
60
|
return "notAuthorized";
|
|
58
|
-
const code = envelope.
|
|
61
|
+
const code = envelope.refusal?.code;
|
|
59
62
|
if (code === LAMBDER_REFUSAL_CODES.rateLimited)
|
|
60
63
|
return "rateLimited";
|
|
61
64
|
if (code === LAMBDER_REFUSAL_CODES.notMocked)
|
|
62
65
|
return "notMocked";
|
|
63
66
|
if (code === LAMBDER_REFUSAL_CODES.apiNotFound)
|
|
64
67
|
return "unknownApi";
|
|
65
|
-
if (envelope.
|
|
68
|
+
if (envelope.refusal)
|
|
66
69
|
return "refusal";
|
|
67
70
|
return "ok";
|
|
68
71
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import type { LambderApiSignatureMap } from "../shared/wire/LambderApiSignatureMap.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { LambderApiRefusalConfig } from "../shared/wire/LambderApiContract.js";
|
|
4
4
|
import type { LambderHttpStatusCode } from "../shared/wire/LambderHttpStatus.js";
|
|
5
5
|
import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
|
|
6
6
|
import type { LambderContractGuardNames, LambderContractIdempotencyOf, LambderContractKeysWithMode, LambderContractMode, LambderContractRateLimitNames, LambderContractRateLimitOf } from "../shared/wire/LambderApiContract.js";
|
|
@@ -282,12 +282,11 @@ export type LambderMockAppOptions<C, S, G, P extends LambderMockRateLimitPolicie
|
|
|
282
282
|
};
|
|
283
283
|
/**
|
|
284
284
|
* What the server's input validation handler answers, as a mock states it:
|
|
285
|
-
* the handler's `res.
|
|
286
|
-
* out with (200 unless named).
|
|
285
|
+
* the handler's `res.apiRefusal(config)` as data, with the status it went
|
|
286
|
+
* out with (200 unless named). Like res.apiRefusal it is always a refusal.
|
|
287
287
|
*/
|
|
288
288
|
export type LambderMockInvalidInputAnswer = {
|
|
289
|
-
|
|
290
|
-
config?: LambderApiResponseConfig;
|
|
289
|
+
config: LambderApiRefusalConfig;
|
|
291
290
|
statusCode?: LambderHttpStatusCode;
|
|
292
291
|
};
|
|
293
292
|
/** How the mock transport carries cookies: a fresh memory jar (default), a jar of yours, the memory jar mirrored into document.cookie, or none. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LambderEndpointRefusals } from "../api/LambderApiRefusals.js";
|
|
1
2
|
import type { LambderApiAnswer } from "../api/LambderApiAnswer.js";
|
|
2
3
|
import type { LambderApiRequest } from "../api/LambderApiRequest.js";
|
|
3
4
|
import type { LambderMockFailure, LambderMockFailureReason, LambderMockLatency } from "./LambderMockTypes.js";
|
|
@@ -50,8 +51,14 @@ export declare class LambderMockFailureInjector {
|
|
|
50
51
|
wait(ms: number, signal: AbortSignal | undefined): Promise<void>;
|
|
51
52
|
/** Waits for the caller's own abort, which is what a timeout is; a call with no signal waits for ever. */
|
|
52
53
|
waitForAbort(signal: AbortSignal | undefined): Promise<never>;
|
|
53
|
-
/**
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The answer an injected failure produces, or a throw for the ones that
|
|
56
|
+
* never reach the caller as answers. An injected refusal is checked
|
|
57
|
+
* against the endpoint's declared codes (`endpoint`), as the pipeline
|
|
58
|
+
* checks a real one, so a test cannot inject what the server could never
|
|
59
|
+
* send.
|
|
60
|
+
*/
|
|
61
|
+
answerFor(failure: LambderMockFailure, request: LambderApiRequest, endpoint: LambderEndpointRefusals | undefined): Promise<LambderApiAnswer>;
|
|
55
62
|
/** Clears every injected failure and puts the configured latency back. */
|
|
56
63
|
reset(): void;
|
|
57
64
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
2
2
|
import { crashAnswer, refusalAnswer, sessionExpiredAnswer, versionExpiredAnswer } from "../api/LambderApiEnvelope.js";
|
|
3
3
|
import { rateLimitRefusal } from "../api/LambderApiRateLimits.js";
|
|
4
|
+
import { checkedRefusal } from "../api/LambderApiRefusals.js";
|
|
4
5
|
/** An injected refusal's message, from a string, a full message, or the reason's own wording. */
|
|
5
6
|
const toRefusalMessage = (message, fallback) => typeof message === "string" ? { type: "warning", content: message }
|
|
6
7
|
: message ?? { type: "warning", content: fallback };
|
|
@@ -106,19 +107,32 @@ export class LambderMockFailureInjector {
|
|
|
106
107
|
signal.addEventListener("abort", () => reject(new LambderMockTransportError("timeout")), { once: true });
|
|
107
108
|
});
|
|
108
109
|
}
|
|
109
|
-
/**
|
|
110
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The answer an injected failure produces, or a throw for the ones that
|
|
112
|
+
* never reach the caller as answers. An injected refusal is checked
|
|
113
|
+
* against the endpoint's declared codes (`endpoint`), as the pipeline
|
|
114
|
+
* checks a real one, so a test cannot inject what the server could never
|
|
115
|
+
* send.
|
|
116
|
+
*/
|
|
117
|
+
async answerFor(failure, request, endpoint) {
|
|
111
118
|
switch (failure.reason) {
|
|
112
119
|
case "network": throw new LambderMockTransportError("network");
|
|
113
120
|
case "timeout": return await this.waitForAbort(request.signal);
|
|
114
121
|
case "server": return crashAnswer(this.apiVersion);
|
|
115
122
|
case "refusal": {
|
|
116
123
|
const message = toRefusalMessage(failure.message, "Injected refusal.");
|
|
117
|
-
return refusalAnswer(new LambderApiRefusal(message.content, {
|
|
124
|
+
return refusalAnswer(checkedRefusal(request.apiName, endpoint, new LambderApiRefusal(message.content, { refusal: message, statusCode: failure.statusCode })), this.apiVersion);
|
|
118
125
|
}
|
|
119
126
|
case "notAuthorized": {
|
|
120
127
|
const message = toRefusalMessage(failure.message, "Injected authorization refusal.");
|
|
121
|
-
|
|
128
|
+
// A declared code's flag is its declaration's: the refusal is
|
|
129
|
+
// built without one and the declaration has to supply it.
|
|
130
|
+
const declared = message.code !== undefined && endpoint?.codes.has(message.code) === true;
|
|
131
|
+
const checked = checkedRefusal(request.apiName, endpoint, new LambderApiRefusal(message.content, { refusal: message, ...(declared ? {} : { notAuthorized: true }) }));
|
|
132
|
+
if (!checked.notAuthorized) {
|
|
133
|
+
throw new Error(`LambderMockApp: the injected notAuthorized failure names the code "${String(message.code)}", which is not declared notAuthorized. Inject it as a refusal, or declare the flag on the code.`);
|
|
134
|
+
}
|
|
135
|
+
return refusalAnswer(checked, this.apiVersion);
|
|
122
136
|
}
|
|
123
137
|
case "sessionExpired": return sessionExpiredAnswer(this.apiVersion);
|
|
124
138
|
case "versionExpired": return versionExpiredAnswer(this.apiVersion);
|
|
@@ -14,7 +14,10 @@ import type { LambderApiRequest } from "../api/LambderApiRequest.js";
|
|
|
14
14
|
import type { LambderHttpStatusCode } from "../shared/wire/LambderHttpStatus.js";
|
|
15
15
|
import type { LambderApiDefinition } from "../api/LambderApiDefinition.js";
|
|
16
16
|
import type { LambderApiEnvelopeBody } from "../shared/wire/LambderApiContract.js";
|
|
17
|
-
import type {
|
|
17
|
+
import type { LambderUncheckedRefusalMessage, LambderDeclaredRefuse } from "../shared/wire/LambderApiRefusal.js";
|
|
18
|
+
import type { LambderRateLimitMessage } from "../shared/wire/LambderApiOptionValues.js";
|
|
19
|
+
import type { LambderContractRefusalsOf } from "../shared/wire/LambderApiContract.js";
|
|
20
|
+
import type { LambderHandlerRefusalsOf } from "../api/LambderApiRefusals.js";
|
|
18
21
|
import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
|
|
19
22
|
import type LambderSessionController from "../session/LambderSessionController.js";
|
|
20
23
|
export type LambderMockInputOf<C, K extends keyof C> = C[K] extends {
|
|
@@ -67,7 +70,16 @@ export type LambderMockSessionCallContext<S = any> = Omit<LambderMockCallContext
|
|
|
67
70
|
session: LambderSessionRecord<S>;
|
|
68
71
|
};
|
|
69
72
|
/** What a mock handler for endpoint K receives. */
|
|
70
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The codes endpoint K may refuse with, as a mock handler raises them. Where
|
|
75
|
+
* the mock init declared the vocabulary (declareRefusals), each code's data is
|
|
76
|
+
* its schema's input form and the mock parses it as the server does; where it
|
|
77
|
+
* declared none, the contract's wire form, sent as given.
|
|
78
|
+
*/
|
|
79
|
+
export type LambderMockRefusalsOf<C, K extends keyof C, TVocabulary> = [
|
|
80
|
+
TVocabulary
|
|
81
|
+
] extends [never] ? LambderContractRefusalsOf<C, K> : LambderHandlerRefusalsOf<TVocabulary, keyof LambderContractRefusalsOf<C, K> & string>;
|
|
82
|
+
export type LambderMockContext<C, K extends keyof C, S, G, TVocabulary = never, TCodesRequired extends boolean = false> = Omit<LambderMockCallContext<S>, "apiName" | "session" | "guardData"> & {
|
|
71
83
|
apiName: K;
|
|
72
84
|
/** The payload as posted; never optional, the way a validated payload reaches a server handler. */
|
|
73
85
|
payload: LambderMockInputOf<C, K>;
|
|
@@ -79,6 +91,14 @@ export type LambderMockContext<C, K extends keyof C, S, G> = Omit<LambderMockCal
|
|
|
79
91
|
guardInputs: [LambderContractGuardInputsOf<C, K>] extends [never] ? undefined : LambderContractGuardInputsOf<C, K>;
|
|
80
92
|
/** The key the caller sent, when it sent a string; anything else is not a key and reaches a handler as undefined, having already been refused wherever it mattered. */
|
|
81
93
|
idempotencyKey: string | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* Refuses the call, as a server handler's ctx.refuse does: `code` is one
|
|
96
|
+
* of the endpoint's declared codes, and `data` is the code's data as a
|
|
97
|
+
* handler hands it over (see LambderMockRefusalsOf). Where the mock
|
|
98
|
+
* declared the vocabulary with requireCodes, a code is required. Write
|
|
99
|
+
* `return ctx.refuse(...)` where the code after it relies on it.
|
|
100
|
+
*/
|
|
101
|
+
refuse: LambderDeclaredRefuse<LambderMockRefusalsOf<C, K, TVocabulary>, TCodesRequired>;
|
|
82
102
|
};
|
|
83
103
|
/**
|
|
84
104
|
* The guard map a mock app must declare: one guard per name any endpoint of
|
|
@@ -99,7 +119,7 @@ export type LambderMockGuards<C, S = any> = {
|
|
|
99
119
|
session?: false;
|
|
100
120
|
};
|
|
101
121
|
};
|
|
102
|
-
export type LambderMockHandler<C, K extends keyof C, S, G> = (ctx: LambderMockContext<C, K, S, G>) => LambderMockOutputOf<C, K> | Promise<LambderMockOutputOf<C, K>>;
|
|
122
|
+
export type LambderMockHandler<C, K extends keyof C, S, G, TVocabulary = never, TCodesRequired extends boolean = false> = (ctx: LambderMockContext<C, K, S, G, TVocabulary, TCodesRequired>) => LambderMockOutputOf<C, K> | Promise<LambderMockOutputOf<C, K>>;
|
|
103
123
|
/**
|
|
104
124
|
* The guards field of an entry: required whenever the contract declares any
|
|
105
125
|
* guard for the endpoint, and type-equal to the server's own declaration.
|
|
@@ -196,7 +216,7 @@ type LambderMockDerivedFields = {
|
|
|
196
216
|
* pinned to the contract, or, with `TDerived` (a mock created with the
|
|
197
217
|
* generated `apiOptions` table), the handler alone.
|
|
198
218
|
*/
|
|
199
|
-
export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false> = (TDerived extends true ? LambderMockDerivedFields : LambderMockGuardsField<C, K> & LambderMockRateLimitField<C, K> & LambderMockIdempotencyField<C, K>) & {
|
|
219
|
+
export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false, TVocabulary = never, TCodesRequired extends boolean = false> = (TDerived extends true ? LambderMockDerivedFields : LambderMockGuardsField<C, K> & LambderMockRateLimitField<C, K> & LambderMockIdempotencyField<C, K>) & {
|
|
200
220
|
/**
|
|
201
221
|
* A schema to validate the posted payload against, so the mock answers
|
|
202
222
|
* 422 exactly as the server would. Optional, and the mock's own: the
|
|
@@ -207,7 +227,7 @@ export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema ext
|
|
|
207
227
|
* LambderMockInputPin).
|
|
208
228
|
*/
|
|
209
229
|
input?: TInputSchema & LambderMockInputPin<C, K, TInputSchema>;
|
|
210
|
-
handler: LambderMockHandler<C, K, S, G>;
|
|
230
|
+
handler: LambderMockHandler<C, K, S, G, TVocabulary, TCodesRequired>;
|
|
211
231
|
};
|
|
212
232
|
/**
|
|
213
233
|
* What publicApi/sessionApi accept. On a mock created with the generated
|
|
@@ -219,7 +239,7 @@ export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema ext
|
|
|
219
239
|
* guardless endpoint could drop its rate limit and idempotency through the
|
|
220
240
|
* bare form.
|
|
221
241
|
*/
|
|
222
|
-
export type LambderMockEntryInput<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false> = TDerived extends true ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema, true> : [LambderContractGuardsOf<C, K> | LambderContractRateLimitOf<C, K> | LambderContractIdempotencyOf<C, K>] extends [never] ? LambderMockHandler<C, K, S, G> | LambderMockEntryOptions<C, K, S, G, TInputSchema> : LambderMockEntryOptions<C, K, S, G, TInputSchema>;
|
|
242
|
+
export type LambderMockEntryInput<C, K extends keyof C, S, G, TInputSchema extends z.ZodType = z.ZodType, TDerived extends boolean = false, TVocabulary = never, TCodesRequired extends boolean = false> = TDerived extends true ? LambderMockHandler<C, K, S, G, TVocabulary, TCodesRequired> | LambderMockEntryOptions<C, K, S, G, TInputSchema, true, TVocabulary, TCodesRequired> : [LambderContractGuardsOf<C, K> | LambderContractRateLimitOf<C, K> | LambderContractIdempotencyOf<C, K>] extends [never] ? LambderMockHandler<C, K, S, G, TVocabulary, TCodesRequired> | LambderMockEntryOptions<C, K, S, G, TInputSchema, false, TVocabulary, TCodesRequired> : LambderMockEntryOptions<C, K, S, G, TInputSchema, false, TVocabulary, TCodesRequired>;
|
|
223
243
|
/** One registry entry: the endpoint's definition as the pipeline runs it, and its handler (null when registered as not mocked). */
|
|
224
244
|
export type LambderMockEntry<C, K extends keyof C & string> = {
|
|
225
245
|
readonly name: K;
|
|
@@ -317,9 +337,12 @@ export type LambderMockLatency = number | {
|
|
|
317
337
|
* uses for the real thing, so an injected 429 carries the Retry-After a
|
|
318
338
|
* real one does. `network` rejects the transport; `timeout` waits for the
|
|
319
339
|
* caller's own abort (a call with no timeout configured waits for its
|
|
320
|
-
* external signal, or for ever, which is what a timeout is).
|
|
340
|
+
* external signal, or for ever, which is what a timeout is). TMessage is the
|
|
341
|
+
* endpoint's refusal message, so an injected refusal names one of its
|
|
342
|
+
* declared codes with that code's data, and is checked against the
|
|
343
|
+
* declaration as a real one is.
|
|
321
344
|
*/
|
|
322
|
-
export type LambderMockFailure = {
|
|
345
|
+
export type LambderMockFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = {
|
|
323
346
|
reason: "network";
|
|
324
347
|
} | {
|
|
325
348
|
reason: "timeout";
|
|
@@ -327,11 +350,11 @@ export type LambderMockFailure = {
|
|
|
327
350
|
reason: "server";
|
|
328
351
|
} | {
|
|
329
352
|
reason: "refusal";
|
|
330
|
-
message?:
|
|
353
|
+
message?: TMessage | string;
|
|
331
354
|
statusCode?: LambderHttpStatusCode;
|
|
332
355
|
} | {
|
|
333
356
|
reason: "notAuthorized";
|
|
334
|
-
message?:
|
|
357
|
+
message?: TMessage | string;
|
|
335
358
|
} | {
|
|
336
359
|
reason: "sessionExpired";
|
|
337
360
|
} | {
|
|
@@ -339,7 +362,7 @@ export type LambderMockFailure = {
|
|
|
339
362
|
} | {
|
|
340
363
|
reason: "rateLimited";
|
|
341
364
|
retryAfterSeconds?: number;
|
|
342
|
-
message?:
|
|
365
|
+
message?: LambderRateLimitMessage;
|
|
343
366
|
};
|
|
344
367
|
/** Every sibling in the package spells this `reason`: an outcome's, a transport failure's, an invoke failure's. */
|
|
345
368
|
export type LambderMockFailureReason = LambderMockFailure["reason"];
|
package/dist/mock.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export { LambderMockApp, initLambderMock } from "./mock/LambderMockApp.js";
|
|
10
10
|
export { LambderMockTransportError } from "./mock/LambderMockFailureInjector.js";
|
|
11
11
|
export type { LambderMockAppOptions, LambderMockSessionsOptions, LambderMockIdempotencyOptions, LambderMockInvalidInputAnswer, LambderMockTransport, LambderMockTransportOptions } from "./mock/LambderMockCreateOptions.js";
|
|
12
|
-
export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames, LambderMockPublicNames, LambderMockSessionNames, LambderMockLatency, LambderMockFailure, LambderMockFailureReason, LambderMockOutcome, LambderMockCallEvent, LambderMockRequestEvent, LambderMockResponseEvent, LambderMockCallRecord, LambderMockListener, LambderMockRateLimitPolicies, LambderMockInputOf, LambderMockOutputOf, LambderMockOverride, } from "./mock/LambderMockTypes.js";
|
|
12
|
+
export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockRefusalsOf, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames, LambderMockPublicNames, LambderMockSessionNames, LambderMockLatency, LambderMockFailure, LambderMockFailureReason, LambderMockOutcome, LambderMockCallEvent, LambderMockRequestEvent, LambderMockResponseEvent, LambderMockCallRecord, LambderMockListener, LambderMockRateLimitPolicies, LambderMockInputOf, LambderMockOutputOf, LambderMockOverride, } from "./mock/LambderMockTypes.js";
|
|
13
13
|
export { lambderMockPoliciesFrom } from "./mock/lambderMockPoliciesFrom.js";
|
|
14
14
|
export type { LambderCustomKeyedPolicyNames, LambderMockPolicyKeys } from "./mock/lambderMockPoliciesFrom.js";
|
|
15
15
|
export type { LambderMockGuardShapeOf } from "./mock/LambderMockCreateOptions.js";
|
|
@@ -31,8 +31,9 @@ export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotency
|
|
|
31
31
|
export { LambderWebCrypto, LambderPlainSessionCrypto } from "./session/LambderSessionCrypto.js";
|
|
32
32
|
export type { LambderSessionCrypto } from "./session/LambderSessionCrypto.js";
|
|
33
33
|
export { LambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
|
|
34
|
-
export type { LambderRefusalMessage } from "./shared/wire/LambderApiRefusal.js";
|
|
35
|
-
export {
|
|
34
|
+
export type { LambderRefusalMessage, LambderDeclaredRefuse, LambderDeclaredRefuseOptions } from "./shared/wire/LambderApiRefusal.js";
|
|
35
|
+
export { LambderApiRefusalValidationError } from "./api/LambderApiRefusals.js";
|
|
36
|
+
export { assertApiSuccess, assertApiFailure, assertApiRefusal } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
36
37
|
export type { LambderExpectedFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
37
38
|
export type { LambderApiRequest } from "./api/LambderApiRequest.js";
|
|
38
39
|
export type { LambderApiAnswer } from "./api/LambderApiAnswer.js";
|
package/dist/mock.js
CHANGED
|
@@ -31,6 +31,7 @@ export { LambderMemoryRateLimiter } from "./stores/LambderMemoryRateLimiter.js";
|
|
|
31
31
|
export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
|
|
32
32
|
export { LambderWebCrypto, LambderPlainSessionCrypto } from "./session/LambderSessionCrypto.js";
|
|
33
33
|
export { LambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
34
|
+
export { LambderApiRefusalValidationError } from "./api/LambderApiRefusals.js";
|
|
35
|
+
// The outcome assertions a test over the mock app narrows with; the same
|
|
36
|
+
// ones `lambder/testing` exports for a test over the real server.
|
|
37
|
+
export { assertApiSuccess, assertApiFailure, assertApiRefusal } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
@@ -48,3 +48,14 @@ export type LambderReadonlyDeep<T, TDepth extends unknown[] = []> = TDepth["leng
|
|
|
48
48
|
export type LambderNonEmptyOptionMap<TMap> = {
|
|
49
49
|
[K in keyof TMap]-?: Required<Pick<TMap, K>> & Omit<TMap, K>;
|
|
50
50
|
}[keyof TMap];
|
|
51
|
+
/**
|
|
52
|
+
* Rejects a key the options type does not have, which `const TOptions` would
|
|
53
|
+
* otherwise wave through: inferring a generic from an object literal switches
|
|
54
|
+
* excess-property checking off for the whole literal, so
|
|
55
|
+
* `requireSessionApiGuard` (no trailing "s") or `maxResponseByte` would
|
|
56
|
+
* compile, be dropped in silence, and leave the app on the default. That is
|
|
57
|
+
* worst for the two require*ApiGuards flags, which exist to make a missing
|
|
58
|
+
* authorization declaration a compile error. Mapping every surplus key to
|
|
59
|
+
* `never` puts the error back on the key itself.
|
|
60
|
+
*/
|
|
61
|
+
export type LambderNoExtraKeys<TOptions, TShape> = TOptions & Record<Exclude<keyof TOptions, keyof TShape>, never>;
|