lambder 11.1.1 → 12.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 +137 -0
- package/README.md +18 -13
- package/dist/api/LambderApiDeclarations.d.ts +194 -0
- package/dist/api/LambderApiDeclarations.js +52 -0
- package/dist/api/LambderApiDefinition.d.ts +1 -1
- package/dist/api/LambderApiGuards.d.ts +21 -22
- package/dist/api/LambderApiGuards.js +1 -1
- package/dist/api/LambderApiRateLimits.d.ts +19 -19
- package/dist/api/LambderApiRateLimits.js +1 -1
- package/dist/api/LambderApiRequest.d.ts +15 -6
- package/dist/api/LambderApiRequest.js +8 -9
- package/dist/build/generatedTables.d.ts +2 -1
- package/dist/build/generatedTables.js +1 -1
- package/dist/client/LambderCaller.d.ts +36 -23
- package/dist/client/LambderCaller.js +88 -67
- package/dist/client/lambderFetchTransport.js +2 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/core/Lambder.d.ts +203 -87
- package/dist/core/Lambder.js +265 -86
- package/dist/core/LambderContext.d.ts +1 -1
- package/dist/core/LambderContext.js +13 -5
- package/dist/core/LambderCreateOptions.d.ts +28 -39
- package/dist/core/LambderCreateOptions.js +2 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +2 -0
- package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
- package/dist/invoke/LambderInvokeCaller.js +110 -94
- package/dist/invoke/LambderLambdaEvent.d.ts +0 -1
- package/dist/invoke/LambderLambdaEvent.js +0 -1
- package/dist/invoke/lambderHandlerTransport.js +2 -1
- package/dist/mock/LambderMockApp.d.ts +31 -25
- package/dist/mock/LambderMockApp.js +52 -45
- package/dist/mock/LambderMockCreateOptions.d.ts +6 -7
- package/dist/mock/LambderMockTypes.d.ts +44 -7
- package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
- package/dist/mock/lambderMockInvokeTransport.js +15 -7
- package/dist/mock/lambderMockMswHandler.js +8 -4
- package/dist/mock.d.ts +2 -2
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/transport/LambderApiTransport.js +2 -3
- package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
- package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
- package/dist/shared/wire/LambderApiContract.d.ts +4 -18
- package/dist/shared/wire/LambderApiContract.js +1 -1
- package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
- package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
- package/dist/shared/wire/LambderApiNames.d.ts +55 -0
- package/dist/shared/wire/LambderApiNames.js +81 -0
- package/dist/shared/wire/LambderUploadSchemas.js +2 -1
- package/dist/testing/LambderTestApp.d.ts +5 -1
- package/dist/testing/LambderTestVisitor.d.ts +16 -8
- package/dist/testing/LambderTestVisitor.js +33 -25
- package/package.json +1 -1
package/dist/core/Lambder.js
CHANGED
|
@@ -18,10 +18,12 @@ import { refuse } from "../shared/wire/LambderApiRefusal.js";
|
|
|
18
18
|
import { apiSignatureOf } from "../api/LambderApiSignature.js";
|
|
19
19
|
import { apiNameKeyOf } from "../shared/wire/LambderApiSignatureMap.js";
|
|
20
20
|
import { assertPlainData } from "../shared/util/assertPlainData.js";
|
|
21
|
-
import { apiNotFoundAnswer, envelopeAnswer, refusalAnswer, sessionExpiredAnswer, successEnvelope, } from "../api/LambderApiEnvelope.js";
|
|
21
|
+
import { apiNotFoundAnswer, envelopeAnswer, refusalAnswer, sessionExpiredAnswer, successEnvelope, versionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
|
|
22
22
|
import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
|
|
23
23
|
import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
|
|
24
24
|
import { toGuardEntries } from "../api/LambderApiGuards.js";
|
|
25
|
+
import { buildApiGroup, buildLazyApiGroup, isRegistrableApiGroup, } from "../api/LambderApiDeclarations.js";
|
|
26
|
+
import { splitApiName } from "../shared/wire/LambderApiNames.js";
|
|
25
27
|
import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContext.js";
|
|
26
28
|
import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
|
|
27
29
|
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
@@ -34,26 +36,22 @@ const NO_DECLARED_REFUSALS = { codes: new Map(), codeRequired: false };
|
|
|
34
36
|
* Main Lambder class for building type-safe serverless APIs. Create
|
|
35
37
|
* instances with initLambder<SessionData>().create({...}) (see below): the
|
|
36
38
|
* whole configuration, including the typed policy layer, is given at
|
|
37
|
-
* construction
|
|
39
|
+
* construction. Endpoints are declared as values on the instance
|
|
40
|
+
* (defineApi, defineApiGroup) and registered in one registerApiGroups()
|
|
41
|
+
* call; routes, hooks and actions chain.
|
|
38
42
|
*
|
|
39
|
-
* @typeParam
|
|
40
|
-
* @typeParam _TContract - @internal
|
|
41
|
-
* @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
|
|
42
|
-
* @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
|
|
43
|
-
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
44
|
-
* @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
|
|
45
|
-
* @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
|
|
46
|
-
* @typeParam _TSessionsEnabled - @internal True when create() received the session option (do not pass manually). Defaults to true, unlike its siblings, so a plugin annotating its parameter as the bare Lambder<SessionData> can still register session APIs. create() knows the option and supplies the false; `new Lambder(...)` relies on the registration-time throw alone.
|
|
47
|
-
* @typeParam _TRefusals - @internal The refusal vocabulary declareRefusals() gave the init (do not pass manually)
|
|
48
|
-
* @typeParam _TRefusalCodesRequired - @internal True when declareRefusals() received requireCodes (do not pass manually)
|
|
43
|
+
* @typeParam TApp - @internal What create() configured, as LambderAppTypes: the session data, policies, guards, idempotency, the guard requirement, sessions and the refusal vocabulary (do not pass manually)
|
|
44
|
+
* @typeParam _TContract - @internal The contract of the groups registered so far (do not pass manually)
|
|
49
45
|
*
|
|
50
46
|
* @example
|
|
51
47
|
* ```typescript
|
|
52
48
|
* interface SessionData { userId: string; role: string; }
|
|
53
49
|
*
|
|
54
|
-
* const
|
|
55
|
-
*
|
|
56
|
-
*
|
|
50
|
+
* const app = initLambder<SessionData>().create({ apiPath: '/api', session, guards });
|
|
51
|
+
* const userApis = app.defineApiGroup("users", {
|
|
52
|
+
* get: app.defineApi({ input: z.object({...}), output: z.object({...}), guards: "signedIn" }, handler),
|
|
53
|
+
* });
|
|
54
|
+
* const lambder = app.registerApiGroups(userApis);
|
|
57
55
|
* ```
|
|
58
56
|
*/
|
|
59
57
|
export default class Lambder {
|
|
@@ -70,24 +68,35 @@ export default class Lambder {
|
|
|
70
68
|
* Type property for extracting the API contract: every registered API's
|
|
71
69
|
* input, output, mode and declared options, as a client calls it.
|
|
72
70
|
*
|
|
73
|
-
* A small app's client imports it as it is.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* this property off the exported instance and writes the contract out as
|
|
77
|
-
* plain types for such a client to import instead.
|
|
71
|
+
* A small app's client imports it as it is. A large app's client reads
|
|
72
|
+
* the plain types writeApiContract (lambder/build) prints from this
|
|
73
|
+
* property instead, which cost it nothing to resolve.
|
|
78
74
|
*
|
|
79
75
|
* @example
|
|
80
76
|
* ```typescript
|
|
81
|
-
* export const lambder =
|
|
77
|
+
* export const lambder = lambderApp.registerApiGroups(userApis, orderApis);
|
|
82
78
|
* export type ApiContractType = typeof lambder.ApiContract;
|
|
83
79
|
* ```
|
|
84
80
|
*/
|
|
85
81
|
ApiContract;
|
|
82
|
+
/**
|
|
83
|
+
* Type property for what create() configured (LambderAppTypes): the
|
|
84
|
+
* session data, policies, guards and the rest, for code that takes any
|
|
85
|
+
* instance and needs one of them (lambderTestApp reads the session data
|
|
86
|
+
* type here). Like ApiContract, it has no value.
|
|
87
|
+
*/
|
|
88
|
+
AppTypes;
|
|
86
89
|
actionList = [];
|
|
87
90
|
/** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
|
|
88
91
|
pipeline;
|
|
89
|
-
/** Every registered API by name:
|
|
92
|
+
/** Every registered API by name: what dispatch, apiSignatures() and apiOptionEntries() read. */
|
|
90
93
|
apiDefinitions = new Map();
|
|
94
|
+
/** What answers each registered API: its output schema, compression setting and handler. */
|
|
95
|
+
apiHandlers = new Map();
|
|
96
|
+
/** Every group name registered, lazy or not: the duplicate-group check. */
|
|
97
|
+
apiGroupNames = new Set();
|
|
98
|
+
/** The lazy groups not loaded yet, each with its load in progress, if one is. */
|
|
99
|
+
lazyApiGroups = new Map();
|
|
91
100
|
/** The guards given at creation, merged into one map, kept for apiSignatures(): a guard's schema is part of the signature of every endpoint declaring it. */
|
|
92
101
|
guards;
|
|
93
102
|
/** The rate-limit policies given at creation, kept for apiOptionEntries(), which records each one less its key handler. */
|
|
@@ -109,7 +118,8 @@ export default class Lambder {
|
|
|
109
118
|
eventActionList = [];
|
|
110
119
|
corsConfig = null;
|
|
111
120
|
finalizeOptions;
|
|
112
|
-
|
|
121
|
+
/** Whether every endpoint has to declare guards (create()'s requireApiGuards). */
|
|
122
|
+
requireApiGuards;
|
|
113
123
|
/** Whether every refusal an API answers with has to name a code (declareRefusals's requireCodes). */
|
|
114
124
|
requireRefusalCodes;
|
|
115
125
|
/** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
|
|
@@ -120,7 +130,6 @@ export default class Lambder {
|
|
|
120
130
|
contextTools;
|
|
121
131
|
trustedClientIpHeaders;
|
|
122
132
|
trustedHostHeaders;
|
|
123
|
-
requirePublicApiGuards;
|
|
124
133
|
constructor(given = {}) {
|
|
125
134
|
// The guards and the rate-limit policies as one map each, whether
|
|
126
135
|
// they were given as one or as a list; a name two maps declare
|
|
@@ -179,19 +188,19 @@ export default class Lambder {
|
|
|
179
188
|
});
|
|
180
189
|
this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
|
|
181
190
|
this.trustedHostHeaders = options.trustedHostHeaders ?? [];
|
|
182
|
-
this.
|
|
191
|
+
this.requireApiGuards = options.requireApiGuards ?? false;
|
|
183
192
|
this.requireRefusalCodes = options.requireRefusalCodes ?? false;
|
|
184
|
-
this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
|
|
185
193
|
this.crashHandling = new LambderCrashHandling(options.crashes ?? {}, this.apiVersion);
|
|
186
194
|
this.contextTools = {
|
|
187
195
|
sessionControllerFor: (ctx) => this.getSessionController(ctx),
|
|
188
196
|
chargeRateLimit: async (ctx, policy, key, refuse) => {
|
|
197
|
+
// A per-API budget counts per registered API. The posted name
|
|
198
|
+
// of a call no API matched (a hook or the fallback charging
|
|
199
|
+
// it) is the caller's choice, and a fresh name per request
|
|
200
|
+
// would be a fresh counter.
|
|
201
|
+
const apiName = ctx.api && await this.registeredDefinitionOf(ctx.api.apiName) ? ctx.api.apiName : null;
|
|
189
202
|
const { checkResult, refusal: refusalToThrow } = await this.pipeline.chargeRateLimit(policy, {
|
|
190
|
-
|
|
191
|
-
// name of a call no API matched (a hook or the fallback
|
|
192
|
-
// charging it) is the caller's choice, and a fresh name
|
|
193
|
-
// per request would be a fresh counter.
|
|
194
|
-
apiName: ctx.api && this.apiDefinitions.has(ctx.api.apiName) ? ctx.api.apiName : null,
|
|
203
|
+
apiName,
|
|
195
204
|
ip: ctx.ip,
|
|
196
205
|
session: ctx.session,
|
|
197
206
|
key,
|
|
@@ -287,45 +296,196 @@ export default class Lambder {
|
|
|
287
296
|
});
|
|
288
297
|
return this;
|
|
289
298
|
}
|
|
290
|
-
//
|
|
291
|
-
|
|
299
|
+
// =====================================================================
|
|
300
|
+
// Endpoints
|
|
301
|
+
// Declared as values, gathered into named groups, registered in one call.
|
|
302
|
+
// =====================================================================
|
|
303
|
+
/**
|
|
304
|
+
* Declares one endpoint, typed on this instance's types: its input and
|
|
305
|
+
* output schemas, its guards, rate limits, idempotency and refusals, and
|
|
306
|
+
* its handler. Nothing is registered until the declaration is put in a
|
|
307
|
+
* group (defineApiGroup) and the group is registered (registerApiGroups).
|
|
308
|
+
*
|
|
309
|
+
* The guards say who may call it, and so its mode: an endpoint whose
|
|
310
|
+
* guards include one that needs a session (`session: true`) is a session
|
|
311
|
+
* endpoint, its session read before the guards run and a call without
|
|
312
|
+
* one answered sessionExpired, and its handler's ctx.session is typed
|
|
313
|
+
* present. Any other endpoint is public.
|
|
314
|
+
*
|
|
315
|
+
* An arrow property rather than a method, so an app can hand it out
|
|
316
|
+
* detached: `export const { defineApi, defineApiGroup } = lambderApp;`.
|
|
317
|
+
*/
|
|
318
|
+
defineApi = (options,
|
|
292
319
|
/** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with ctx.refuse() or refuse(). */
|
|
293
|
-
handler) {
|
|
294
|
-
|
|
295
|
-
|
|
320
|
+
handler) => ({ kind: "lambderApi", options: options, handler: handler });
|
|
321
|
+
/**
|
|
322
|
+
* Gathers endpoints into a named group: each is registered as
|
|
323
|
+
* `name.action` and called at `{apiPath}/{name}/{action}`, and every
|
|
324
|
+
* Lambder caller reaches it as `caller.name.action(input)`. A group's
|
|
325
|
+
* name and its actions are identifiers, and a group may not take a name
|
|
326
|
+
* a caller already answers for (see LAMBDER_RESERVED_GROUP_NAMES).
|
|
327
|
+
*
|
|
328
|
+
* A group declared across files takes each file's part:
|
|
329
|
+
* `defineApiGroup("orders", orderReadApis, orderWriteApis)`. An action
|
|
330
|
+
* two parts declare is refused, at compile time and at startup, rather
|
|
331
|
+
* than one silently replacing the other as an object spread would.
|
|
332
|
+
*/
|
|
333
|
+
defineApiGroup = (name, ...parts) => buildApiGroup(name, parts);
|
|
334
|
+
/**
|
|
335
|
+
* A group loaded on the first call to one of its endpoints:
|
|
336
|
+
* `lazyApiGroup("orders", () => import("./orders.js").then((m) => m.orderApis))`.
|
|
337
|
+
* A cold start then parses none of it, nor anything only it imports,
|
|
338
|
+
* until a request calls it; the contract is the loaded group's, read off
|
|
339
|
+
* its type. The group loaded must carry the same name, which the type
|
|
340
|
+
* checks and loading checks again. `loadApiGroups()` loads every group,
|
|
341
|
+
* for a build step or a boot check that has to see them all.
|
|
342
|
+
*/
|
|
343
|
+
lazyApiGroup = (name, load) => buildLazyApiGroup(name, load);
|
|
344
|
+
/**
|
|
345
|
+
* Registers groups of endpoints, as one contract: `typeof
|
|
346
|
+
* lambder.ApiContract` then maps every `group.action` to its entry, one
|
|
347
|
+
* flat object type the api-contract generator prints as it is. Register
|
|
348
|
+
* every group in one call where you can: the contract of one call is one
|
|
349
|
+
* mapped type, and a second call intersects it with the first.
|
|
350
|
+
*
|
|
351
|
+
* The call's groups take their place in the first-match chain here, as
|
|
352
|
+
* a route does: a route or action registered before them sees their calls
|
|
353
|
+
* first, one registered after them never does. The beforeRender hooks run
|
|
354
|
+
* before a lazy group loads, so a hook that answers a request spares it
|
|
355
|
+
* the import; one that throws a refusal or charges a per-API budget loads
|
|
356
|
+
* it (see registeredDefinitionOf).
|
|
357
|
+
*
|
|
358
|
+
* Refused at compile time: a group typed `any`, or a lazy one loading a
|
|
359
|
+
* group typed `any`, or a group whose endpoints are (its endpoints would
|
|
360
|
+
* be `any` to every client), and a group name given twice. Refused at
|
|
361
|
+
* registration, which is at startup for a group and at its first call for
|
|
362
|
+
* a lazy one: everything registration refuses of one endpoint (an unknown
|
|
363
|
+
* guard or policy, a missing guard where requireApiGuards is on, a
|
|
364
|
+
* refusal code outside the vocabulary).
|
|
365
|
+
*/
|
|
366
|
+
registerApiGroups(...groups) {
|
|
367
|
+
const instance = this;
|
|
368
|
+
// Every group and every endpoint is checked before anything is
|
|
369
|
+
// claimed, so a registration refused anywhere leaves the instance as
|
|
370
|
+
// it was: no group name taken, no endpoint half registered, and a
|
|
371
|
+
// caller that catches the error and fixes the declaration is told the
|
|
372
|
+
// real problem on its next try.
|
|
373
|
+
const names = new Set();
|
|
374
|
+
const prepared = [];
|
|
375
|
+
for (const group of groups) {
|
|
376
|
+
if (!isRegistrableApiGroup(group)) {
|
|
377
|
+
throw new Error("Lambder: registerApiGroups takes groups built by defineApiGroup() or lazyApiGroup().");
|
|
378
|
+
}
|
|
379
|
+
if (instance.apiGroupNames.has(group.name) || names.has(group.name)) {
|
|
380
|
+
throw new Error(`Lambder: group "${group.name}" is registered twice. A group is one namespace, so its endpoints belong in one defineApiGroup().`);
|
|
381
|
+
}
|
|
382
|
+
names.add(group.name);
|
|
383
|
+
if (group.kind === "lambderApiGroup")
|
|
384
|
+
prepared.push(...instance.prepareApiGroup(group));
|
|
385
|
+
}
|
|
386
|
+
for (const group of groups) {
|
|
387
|
+
instance.apiGroupNames.add(group.name);
|
|
388
|
+
if (group.kind === "lambderLazyApiGroup")
|
|
389
|
+
instance.lazyApiGroups.set(group.name, { group, loading: null });
|
|
390
|
+
}
|
|
391
|
+
for (const api of prepared)
|
|
392
|
+
instance.commitApi(api);
|
|
393
|
+
instance.actionList.push({
|
|
394
|
+
match: (ctx) => {
|
|
395
|
+
const group = ctx.api ? splitApiName(ctx.api.apiName)?.group : undefined;
|
|
396
|
+
return group !== undefined && names.has(group) ? {} : false;
|
|
397
|
+
},
|
|
398
|
+
actionFn: (ctx, resolver) => instance.dispatchApiCall(ctx, resolver),
|
|
399
|
+
});
|
|
400
|
+
return instance;
|
|
296
401
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
402
|
+
/**
|
|
403
|
+
* Loads every lazy group not loaded yet, registering its endpoints: what
|
|
404
|
+
* a build step digesting the signatures, a generator writing the options
|
|
405
|
+
* or a boot check has to do before it sees every endpoint. A group whose
|
|
406
|
+
* registration fails rejects here, which is the point of calling it.
|
|
407
|
+
*/
|
|
408
|
+
async loadApiGroups() {
|
|
409
|
+
for (const name of [...this.lazyApiGroups.keys()])
|
|
410
|
+
await this.loadLazyApiGroup(name);
|
|
303
411
|
}
|
|
304
412
|
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* they give the handler.
|
|
413
|
+
* An API call to a registered group, its hooks run: its group loaded
|
|
414
|
+
* first when it is a lazy one no call has reached yet, then its endpoint
|
|
415
|
+
* run. An action the group does not have is answered as any unmatched API
|
|
416
|
+
* call is, fallback hooks first.
|
|
310
417
|
*/
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
418
|
+
async dispatchApiCall(ctx, resolver) {
|
|
419
|
+
const apiName = ctx.api.apiName;
|
|
420
|
+
const definition = await this.registeredDefinitionOf(apiName);
|
|
421
|
+
const endpoint = this.apiHandlers.get(apiName);
|
|
422
|
+
if (!definition || !endpoint)
|
|
423
|
+
return await this.answerUnmatched(ctx, resolver);
|
|
424
|
+
return await this.runApi(ctx, definition, endpoint.output, endpoint.compress, endpoint.handler);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* The definition of the endpoint a call names, its group loaded first
|
|
428
|
+
* when it is a lazy one no call has reached yet; undefined for a name no
|
|
429
|
+
* registered group declares. Everything that reads an endpoint's
|
|
430
|
+
* declarations for a call asks here (its dispatch, a refusal a hook
|
|
431
|
+
* throws on the way to it, a per-API budget a hook charges), so a lazy
|
|
432
|
+
* group answers each of them as an eager one does.
|
|
433
|
+
*/
|
|
434
|
+
async registeredDefinitionOf(apiName) {
|
|
435
|
+
const group = splitApiName(apiName)?.group;
|
|
436
|
+
if (group !== undefined && this.lazyApiGroups.has(group))
|
|
437
|
+
await this.loadLazyApiGroup(group);
|
|
438
|
+
return this.apiDefinitions.get(apiName);
|
|
439
|
+
}
|
|
440
|
+
/** The endpoints of one group, each checked as `group.action`; nothing is registered until they are committed. */
|
|
441
|
+
prepareApiGroup(group) {
|
|
442
|
+
return Object.entries(group.apis).map(([action, declaration]) => this.prepareApi(`${group.name}.${action}`, declaration.options, declaration.handler));
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* A lazy group, loaded once: its import, checked to be the group it was
|
|
446
|
+
* registered as, then registered. Concurrent first calls share one load;
|
|
447
|
+
* a failed load is forgotten, so the next call tries again rather than
|
|
448
|
+
* answering every later call with the first failure.
|
|
449
|
+
*/
|
|
450
|
+
async loadLazyApiGroup(name) {
|
|
451
|
+
const lazy = this.lazyApiGroups.get(name);
|
|
452
|
+
if (!lazy)
|
|
453
|
+
return;
|
|
454
|
+
if (!lazy.loading) {
|
|
455
|
+
const loading = (async () => {
|
|
456
|
+
const loaded = await lazy.group.load();
|
|
457
|
+
if (!isRegistrableApiGroup(loaded) || loaded.kind !== "lambderApiGroup") {
|
|
458
|
+
throw new Error(`Lambder: the loader of lazy group "${name}" resolved to something that is not a group. Resolve to the value defineApiGroup() built.`);
|
|
459
|
+
}
|
|
460
|
+
if (loaded.name !== name) {
|
|
461
|
+
throw new Error(`Lambder: lazy group "${name}" loaded the group "${loaded.name}". The loader must resolve to the group of the name it was registered under.`);
|
|
462
|
+
}
|
|
463
|
+
for (const api of this.prepareApiGroup(loaded))
|
|
464
|
+
this.commitApi(api);
|
|
465
|
+
this.lazyApiGroups.delete(name);
|
|
466
|
+
})();
|
|
467
|
+
lazy.loading = loading;
|
|
468
|
+
loading.catch(() => { if (lazy.loading === loading)
|
|
469
|
+
lazy.loading = null; });
|
|
314
470
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
471
|
+
await lazy.loading;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* What registering an endpoint takes: the checks that can refuse it and
|
|
475
|
+
* its mode read off its guards, into the definition apiSignatures()
|
|
476
|
+
* digests, beside its handler. Nothing is recorded here (commitApi does
|
|
477
|
+
* that), so a refusal anywhere in a registration leaves nothing behind.
|
|
478
|
+
*/
|
|
479
|
+
prepareApi(name, schema, handler) {
|
|
480
|
+
const guardEntries = toGuardEntries(schema.guards);
|
|
481
|
+
const guardNeedsSession = (guard) => !!this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) && this.guards[guard]?.session === true;
|
|
482
|
+
const mode = guardEntries.some(({ name: guard }) => guardNeedsSession(guard)) ? "session" : "public";
|
|
319
483
|
if (mode === "session" && !this.pipeline.hasSessions) {
|
|
320
|
-
throw new Error(`Lambder:
|
|
484
|
+
throw new Error(`Lambder: a guard of API "${name}" needs a session, and the instance was created without the session option.`);
|
|
321
485
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
? "the named no-op guard that marks the session itself as the whole authorization"
|
|
326
|
-
: "the named no-op guard that records why anyone may call it";
|
|
327
|
-
throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
|
|
328
|
-
`Declare the guard that authorizes it, or ${optOut}.`);
|
|
486
|
+
if (this.requireApiGuards && schema.guards === undefined) {
|
|
487
|
+
throw new Error(`Lambder: API "${name}" declares no guards, and requireApiGuards is on. ` +
|
|
488
|
+
`Declare the guard that authorizes it, or the named no-op guard that records why it needs nothing more.`);
|
|
329
489
|
}
|
|
330
490
|
// The option, like guards and rateLimit, is a declaration or absent:
|
|
331
491
|
// an empty list would read as declaring codes while declaring none.
|
|
@@ -333,20 +493,22 @@ export default class Lambder {
|
|
|
333
493
|
if (schema.refusals !== undefined && ownRefusals.length === 0) {
|
|
334
494
|
throw new Error(`Lambder: API "${name}" declares an empty refusals option, which declares no code. Name the codes it refuses with, or omit the option entirely.`);
|
|
335
495
|
}
|
|
336
|
-
const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals,
|
|
496
|
+
const allowedCodes = resolveAllowedRefusals(name, this.refusalVocabulary, ownRefusals, guardEntries.map(({ name: guard }) => ({
|
|
337
497
|
guard,
|
|
338
498
|
codes: (this.guards && Object.prototype.hasOwnProperty.call(this.guards, guard) ? this.guards[guard]?.refusals : undefined) ?? [],
|
|
339
499
|
})));
|
|
340
500
|
const refusals = { codes: allowedCodes, codeRequired: this.requireRefusalCodes };
|
|
341
501
|
const definition = { name, mode, guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output, refusals };
|
|
342
502
|
this.pipeline.assertRegistration(definition);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
503
|
+
return { definition, refusalsOption: schema.refusals, output: schema.output, compress: schema.compress ?? "auto", handler };
|
|
504
|
+
}
|
|
505
|
+
/** Records a checked endpoint: its definition, its refusals option as written, and what answers it. */
|
|
506
|
+
commitApi(api) {
|
|
507
|
+
const { name } = api.definition;
|
|
508
|
+
this.apiDefinitions.set(name, api.definition);
|
|
509
|
+
if (api.refusalsOption !== undefined)
|
|
510
|
+
this.refusalOptions.set(name, api.refusalsOption);
|
|
511
|
+
this.apiHandlers.set(name, { output: api.output, compress: api.compress, handler: api.handler });
|
|
350
512
|
}
|
|
351
513
|
addHook(hookEvent, hookFn, priority = 0) {
|
|
352
514
|
if (hookEvent === "created") {
|
|
@@ -378,16 +540,15 @@ export default class Lambder {
|
|
|
378
540
|
});
|
|
379
541
|
return this;
|
|
380
542
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
// making an instance with a non-default value unassignable to its own
|
|
388
|
-
// plugins.
|
|
543
|
+
/**
|
|
544
|
+
* Hands the instance to a function that registers on it (routes, hooks,
|
|
545
|
+
* actions) and continues the chain with what it returns. Endpoints are
|
|
546
|
+
* not registered this way: they are values, registered by
|
|
547
|
+
* registerApiGroups(), so a plugin adds nothing to the contract.
|
|
548
|
+
*/
|
|
389
549
|
use(plugin) {
|
|
390
|
-
|
|
550
|
+
plugin(this);
|
|
551
|
+
return this;
|
|
391
552
|
}
|
|
392
553
|
// =====================================================================
|
|
393
554
|
// Accessors
|
|
@@ -451,6 +612,7 @@ export default class Lambder {
|
|
|
451
612
|
* unless the generator writes it there.
|
|
452
613
|
*/
|
|
453
614
|
async apiSignatureEntries() {
|
|
615
|
+
await this.loadApiGroups();
|
|
454
616
|
const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => ({
|
|
455
617
|
name: definition.name,
|
|
456
618
|
key: await apiNameKeyOf(definition.name),
|
|
@@ -478,7 +640,8 @@ export default class Lambder {
|
|
|
478
640
|
* same object. Every table is sorted by name, so the module diffs by
|
|
479
641
|
* endpoint and never moves when registrations are reordered.
|
|
480
642
|
*/
|
|
481
|
-
apiOptionEntries() {
|
|
643
|
+
async apiOptionEntries() {
|
|
644
|
+
await this.loadApiGroups();
|
|
482
645
|
const apis = {};
|
|
483
646
|
for (const name of [...this.apiDefinitions.keys()].sort()) {
|
|
484
647
|
const { mode, guards, rateLimit, idempotency } = this.apiDefinitions.get(name);
|
|
@@ -624,11 +787,22 @@ export default class Lambder {
|
|
|
624
787
|
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
|
|
625
788
|
if (beforeRenderResult instanceof LambderResponse)
|
|
626
789
|
return beforeRenderResult;
|
|
627
|
-
|
|
790
|
+
return await this.answerUnmatched(beforeRenderResult, resolver);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* What answers a request nothing matched, its beforeRender hooks run:
|
|
794
|
+
* the fallback hooks, then the API fallback for an API call, and
|
|
795
|
+
* otherwise the public files, the shell and the route fallback in turn.
|
|
796
|
+
*/
|
|
797
|
+
async answerUnmatched(currentCtx, resolver) {
|
|
628
798
|
for (const hook of this.hookList["fallback"]) {
|
|
629
799
|
await hook.hookFn(currentCtx, resolver);
|
|
630
800
|
}
|
|
631
|
-
|
|
801
|
+
// A request under apiPath is the API's to answer, whatever it asked
|
|
802
|
+
// for. A root apiPath shares every path with the site, so there only
|
|
803
|
+
// apiPath itself and the calls are.
|
|
804
|
+
const apiArea = this.apiPath.replace(/\/+$/, "");
|
|
805
|
+
const isAPI = currentCtx.api !== null || currentCtx.path === this.apiPath || (apiArea !== "" && currentCtx.path.startsWith(`${apiArea}/`));
|
|
632
806
|
if (isAPI) {
|
|
633
807
|
if (this.apiFallbackHandler)
|
|
634
808
|
return await this.apiFallbackHandler(currentCtx, resolver);
|
|
@@ -661,6 +835,10 @@ export default class Lambder {
|
|
|
661
835
|
if (this.isCorsPreflight(ctx))
|
|
662
836
|
return new LambderResponse({ statusCode: 204, body: null });
|
|
663
837
|
if (ctx.api) {
|
|
838
|
+
// A page built before endpoints had paths: its call cannot be
|
|
839
|
+
// answered, and telling it its version expired is what reloads it.
|
|
840
|
+
if (ctx.api.retiredPath)
|
|
841
|
+
return responseFromAnswer(versionExpiredAnswer(this.apiVersion));
|
|
664
842
|
// The protocol's own pre-pass, run here rather than left to the
|
|
665
843
|
// pipeline so that hooks and route matching see a plain payload,
|
|
666
844
|
// and so a stale client is answered before any of them, whether or
|
|
@@ -781,7 +959,7 @@ export default class Lambder {
|
|
|
781
959
|
if (thrown instanceof LambderResponse)
|
|
782
960
|
return thrown;
|
|
783
961
|
if (isLambderApiRefusal(thrown) && ctx.api)
|
|
784
|
-
return this.apiErrorResponse(thrown, ctx);
|
|
962
|
+
return await this.apiErrorResponse(thrown, ctx);
|
|
785
963
|
if (thrown instanceof LambderSessionNotFoundError)
|
|
786
964
|
return await this.sessionMissingResponse(ctx, resolver);
|
|
787
965
|
throw thrown;
|
|
@@ -985,13 +1163,14 @@ export default class Lambder {
|
|
|
985
1163
|
/**
|
|
986
1164
|
* A thrown LambderApiRefusal (from a hook, say) as the structured API
|
|
987
1165
|
* envelope: the core's one mapping, after the same check the pipeline
|
|
988
|
-
* applies, against the endpoint the call names
|
|
1166
|
+
* applies, against the endpoint the call names, its lazy group loaded if
|
|
1167
|
+
* the refusal came before the call reached it. A name no API is
|
|
989
1168
|
* registered under declares no code, so only an uncoded or a framework
|
|
990
1169
|
* refusal goes out for it.
|
|
991
1170
|
*/
|
|
992
|
-
apiErrorResponse(err, ctx) {
|
|
1171
|
+
async apiErrorResponse(err, ctx) {
|
|
993
1172
|
const apiName = ctx.apiName ?? "";
|
|
994
|
-
const refusal = checkedRefusal(apiName, this.
|
|
1173
|
+
const refusal = checkedRefusal(apiName, (await this.registeredDefinitionOf(apiName))?.refusals ?? NO_DECLARED_REFUSALS, err);
|
|
995
1174
|
return responseFromAnswer(refusalAnswer(refusal, this.apiVersion, ctx.logList));
|
|
996
1175
|
}
|
|
997
1176
|
}
|
|
@@ -25,7 +25,7 @@ export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxy
|
|
|
25
25
|
* clearCookie) that write onto whatever answer the request ends with.
|
|
26
26
|
*
|
|
27
27
|
* TRateLimitPolicies is the app's policies map on a handler registered with
|
|
28
|
-
*
|
|
28
|
+
* defineApi, addRoute or addSessionRoute, so a policy name is
|
|
29
29
|
* checked where it is charged; anywhere else (a hook, a guard) the names are
|
|
30
30
|
* any string.
|
|
31
31
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readApiEnvelope, cookieValuesByName, isApiCallContentType, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
|
|
2
|
+
import { apiNameOfCallPath } from "../shared/wire/LambderApiNames.js";
|
|
2
3
|
import { resolveClientIp } from "../shared/util/LambderClientIp.js";
|
|
3
4
|
import { base64ToText } from "../shared/util/LambderBase64.js";
|
|
4
5
|
import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
|
|
@@ -162,13 +163,20 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
|
|
|
162
163
|
post[key] = value;
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
|
-
// A JSON POST to
|
|
166
|
+
// A JSON POST to `{apiPath}/{group}/{action}` is a call to that endpoint;
|
|
166
167
|
// the core reads the envelope, and everything downstream reads ctx.api.
|
|
167
168
|
// JSON only (isApiCallContentType): any site can submit a plain HTML form
|
|
168
|
-
// to this path, and enctype="text/plain" lays out a JSON body exactly.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
// to this path, and enctype="text/plain" lays out a JSON body exactly. A
|
|
170
|
+
// JSON POST to apiPath itself whose body names an endpoint comes from a
|
|
171
|
+
// page built before endpoints had paths, and is read as a call on the
|
|
172
|
+
// retired path, which the core answers with versionExpired.
|
|
173
|
+
const isJsonPost = method === "POST" && !!apiPath && isApiCallContentType(lowercasedHeaders);
|
|
174
|
+
const calledName = isJsonPost ? apiNameOfCallPath(apiPath, path) : null;
|
|
175
|
+
const requestInfo = { headers: lowercasedHeaders, cookies: cookieList, ip, host };
|
|
176
|
+
const api = calledName !== null ? readApiEnvelope(post, requestInfo, calledName)
|
|
177
|
+
: isJsonPost && path === apiPath && typeof post.apiName === "string" && post.apiName !== ""
|
|
178
|
+
? readApiEnvelope(post, requestInfo, post.apiName, { retiredPath: true })
|
|
179
|
+
: null;
|
|
172
180
|
return bindContextTools({
|
|
173
181
|
host, path, rawPath, pathParams: {}, method,
|
|
174
182
|
get, post, cookie, cookieList, event,
|