lambder 14.1.1 → 15.0.2
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 +429 -0
- package/README.md +14 -17
- package/dist/api/LambderApiDeclarations.d.ts +11 -5
- package/dist/api/LambderApiDefinition.d.ts +2 -1
- package/dist/api/LambderApiEnvelope.d.ts +2 -2
- package/dist/api/LambderApiEnvelope.js +8 -2
- package/dist/api/LambderApiGuards.d.ts +44 -5
- package/dist/api/LambderApiGuards.js +27 -0
- package/dist/api/LambderApiIdempotency.d.ts +14 -7
- package/dist/api/LambderApiIdempotency.js +39 -27
- package/dist/api/LambderApiPipeline.d.ts +25 -11
- package/dist/api/LambderApiPipeline.js +34 -17
- package/dist/api/LambderApiRateLimits.d.ts +28 -21
- package/dist/api/LambderApiRateLimits.js +55 -35
- package/dist/api/LambderApiRefusals.d.ts +10 -4
- package/dist/api/LambderApiRefusals.js +31 -5
- package/dist/api/LambderApiRequest.d.ts +21 -1
- package/dist/api/LambderApiRequest.js +31 -2
- package/dist/build/checkApiRefusals.d.ts +8 -3
- package/dist/build/checkApiRefusals.js +80 -29
- package/dist/build/contractPrinterProcess.d.ts +11 -0
- package/dist/build/contractPrinterProcess.js +17 -0
- package/dist/build/generateApiFiles.d.ts +26 -5
- package/dist/build/generateApiFiles.js +68 -7
- package/dist/build/writeApiSchemas.d.ts +109 -0
- package/dist/build/writeApiSchemas.js +308 -0
- package/dist/build.d.ts +3 -1
- package/dist/build.js +2 -0
- package/dist/client/LambderCaller.d.ts +25 -11
- package/dist/client/LambderCaller.js +57 -49
- package/dist/client/LambderUploadRunner.d.ts +55 -27
- package/dist/client/LambderUploadRunner.js +89 -34
- package/dist/client.d.ts +6 -6
- package/dist/client.js +2 -2
- package/dist/core/Lambder.d.ts +31 -21
- package/dist/core/Lambder.js +79 -23
- package/dist/core/LambderCallSummary.d.ts +1 -1
- package/dist/core/LambderContext.d.ts +59 -7
- package/dist/core/LambderContext.js +31 -15
- package/dist/core/LambderCrashHandling.d.ts +5 -0
- package/dist/core/LambderCrashHandling.js +11 -1
- package/dist/core/LambderCreateOptions.d.ts +28 -17
- package/dist/core/LambderCreateOptions.js +14 -7
- package/dist/core/LambderResolver.d.ts +3 -2
- package/dist/core/LambderResponseBuilder.d.ts +20 -9
- package/dist/core/LambderResponseBuilder.js +39 -8
- package/dist/core/LambderTemplatingEngine.d.ts +24 -6
- package/dist/core/LambderTemplatingEngine.js +23 -3
- package/dist/index.d.ts +15 -12
- package/dist/index.js +7 -5
- package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
- package/dist/invoke/LambderInvokeCaller.js +47 -9
- package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
- package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
- package/dist/mock/LambderMockAdapterDoor.js +10 -0
- package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
- package/dist/mock/LambderMockApiSchemas.js +150 -0
- package/dist/mock/LambderMockApp.d.ts +56 -48
- package/dist/mock/LambderMockApp.js +107 -62
- package/dist/mock/LambderMockBrowserCookies.js +18 -5
- package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
- package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
- package/dist/mock/LambderMockFailureInjector.js +12 -8
- package/dist/mock/LambderMockTypes.d.ts +8 -4
- package/dist/mock/lambderMockInvokeTransport.js +2 -9
- package/dist/mock/lambderMockMswHandler.d.ts +19 -13
- package/dist/mock/lambderMockMswHandler.js +25 -22
- package/dist/mock.d.ts +1 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
- package/dist/secrets/LambderOneShotSecrets.js +6 -36
- package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
- package/dist/secrets/LambderPasswordHasher.js +168 -0
- package/dist/session/LambderSessionController.d.ts +37 -4
- package/dist/session/LambderSessionController.js +36 -6
- package/dist/session/LambderSessionManager.d.ts +33 -3
- package/dist/session/LambderSessionManager.js +55 -16
- package/dist/shared/LambderHtml.d.ts +23 -4
- package/dist/shared/LambderHtml.js +17 -5
- package/dist/shared/LambderI18n.d.ts +106 -16
- package/dist/shared/LambderI18n.js +259 -74
- package/dist/shared/contracts/LambderCache.d.ts +14 -1
- package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
- package/dist/shared/util/LambderBackoffTimer.js +40 -12
- package/dist/shared/util/LambderCallAbort.d.ts +22 -6
- package/dist/shared/util/LambderCallAbort.js +37 -9
- package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
- package/dist/shared/util/LambderErrorReportMark.js +22 -0
- package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
- package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
- package/dist/shared/util/LambderSignedClaims.js +54 -0
- package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
- package/dist/shared/util/LambderSwappableInstances.js +54 -0
- package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
- package/dist/shared/util/LambderTestingDoors.js +6 -2
- package/dist/shared/util/LambderTextDigest.d.ts +5 -5
- package/dist/shared/util/LambderTextDigest.js +5 -5
- package/dist/shared/wire/LambderApiContract.d.ts +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
- package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
- package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
- package/dist/shared/wire/LambderApiOutcome.js +1 -1
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
- package/dist/shared/wire/LambderApiRefusal.js +3 -3
- package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
- package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
- package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
- package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
- package/dist/shared/wire/LambderCallOutcome.js +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
- package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
- package/dist/shared/wire/LambderInvokeApiId.js +3 -3
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
- package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
- package/dist/stores/LambderCacheValues.d.ts +2 -5
- package/dist/stores/LambderDdbCache.d.ts +15 -9
- package/dist/stores/LambderDdbCache.js +25 -6
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
- package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
- package/dist/stores/LambderDdbSdk.d.ts +59 -29
- package/dist/stores/LambderDdbSdk.js +116 -44
- package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
- package/dist/stores/LambderDdbSessionStore.js +133 -68
- package/dist/stores/LambderMemoryCache.d.ts +2 -2
- package/dist/stores/LambderS3FileSource.d.ts +3 -2
- package/dist/stores/LambderS3FileSource.js +11 -13
- package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
- package/dist/stores/LambderS3UploadBucket.js +23 -0
- package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
- package/dist/stores/LambderStoredText.d.ts +26 -0
- package/dist/stores/LambderStoredText.js +25 -0
- package/dist/testing/LambderConformanceRunner.d.ts +9 -2
- package/dist/testing/LambderConformanceRunner.js +9 -2
- package/dist/testing/LambderTestApp.d.ts +65 -7
- package/dist/testing/LambderTestApp.js +101 -6
- package/dist/testing/LambderTestVisitor.d.ts +43 -12
- package/dist/testing/LambderTestVisitor.js +35 -11
- package/dist/testing/bootLambdaPackage.d.ts +105 -0
- package/dist/testing/bootLambdaPackage.js +265 -0
- package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
- package/dist/testing/lambdaPackageBootProcess.js +130 -0
- package/dist/testing/lambderCacheConformance.d.ts +26 -0
- package/dist/testing/lambderCacheConformance.js +287 -0
- package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
- package/dist/testing/lambderCacheStorageConformance.js +159 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
- package/dist/testing/lambderSessionStoreConformance.js +3 -3
- package/dist/testing.d.ts +7 -1
- package/dist/testing.js +5 -0
- package/package.json +1 -6
- package/dist/shared/util/boundKeyField.d.ts +0 -20
- package/dist/shared/util/boundKeyField.js +0 -34
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CONTRACT_REPORT_PREFIX } from "./generateApiFiles.js";
|
|
2
|
+
import { writeApiContract } from "./writeApiContract.js";
|
|
3
|
+
const request = JSON.parse(process.argv[2] ?? "null");
|
|
4
|
+
let report;
|
|
5
|
+
try {
|
|
6
|
+
report = { result: await writeApiContract(request) };
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
const thrown = err instanceof Error ? [] : [String(err)];
|
|
10
|
+
for (let cause = err; cause instanceof Error && thrown.length < 8; cause = cause.cause)
|
|
11
|
+
thrown.push(cause.message);
|
|
12
|
+
report = { thrown };
|
|
13
|
+
}
|
|
14
|
+
// Waited for: a write to a pipe can still be pending when the process exits,
|
|
15
|
+
// and the parent reads the pipe.
|
|
16
|
+
await new Promise((resolveWrite) => process.stdout.write(`\n${CONTRACT_REPORT_PREFIX}${JSON.stringify(report)}\n`, () => resolveWrite()));
|
|
17
|
+
process.exit(0);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { LambderModuleLocation } from "./moduleLocation.js";
|
|
2
|
-
import {
|
|
2
|
+
import type { LambderApiContractFileOptions } from "./writeApiContract.js";
|
|
3
3
|
import { type LambderApiSignatureFileOptions } from "./writeApiSignatures.js";
|
|
4
4
|
import { type LambderApiOptionsFileOptions } from "./writeApiOptions.js";
|
|
5
5
|
import { type LambderApiGuardParamsFileOptions } from "./writeApiGuardParams.js";
|
|
6
|
+
import { type LambderApiSchemasFileOptions } from "./writeApiSchemas.js";
|
|
6
7
|
/** What a file's writer takes beyond where the instance is, which the app gives once for all its files. */
|
|
7
8
|
type LambderAppFile<TOptions> = Omit<TOptions, "module" | "exportName" | "tsconfig" | "check">;
|
|
8
9
|
/** One app: where its instance is, and which files it is written to. Each file is optional. */
|
|
@@ -21,21 +22,43 @@ export type LambderApiFilesApp = {
|
|
|
21
22
|
options?: LambderAppFile<LambderApiOptionsFileOptions>;
|
|
22
23
|
/** One file per guard whose parameters a browser needs (writeApiGuardParams). */
|
|
23
24
|
guardParams?: readonly LambderAppFile<LambderApiGuardParamsFileOptions>[];
|
|
25
|
+
/** The input and output schemas as JSON Schema, for a mock to validate against in development (writeApiSchemas). */
|
|
26
|
+
schemas?: LambderAppFile<LambderApiSchemasFileOptions>;
|
|
24
27
|
};
|
|
25
28
|
/** The files generateApiFiles writes: every app's, by a name the output lines use. */
|
|
26
29
|
export type LambderApiFilesConfig = {
|
|
27
30
|
apps: Record<string, LambderApiFilesApp>;
|
|
28
31
|
};
|
|
32
|
+
/** How generateApiFiles runs. */
|
|
33
|
+
export type LambderApiFilesOptions = {
|
|
34
|
+
/** Write nothing, and answer whether each file on disk is what its writer produces now. Default: false. */
|
|
35
|
+
check?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The heap, in megabytes, of the Node process each contract is printed
|
|
38
|
+
* in (its --max-old-space-size). Reading a contract compiles the server,
|
|
39
|
+
* twice for a write that changes the file, which in a large app takes
|
|
40
|
+
* gigabytes; the process is started for that alone, so the heap is the
|
|
41
|
+
* printing's whatever the script's own is. Default: 8192.
|
|
42
|
+
*/
|
|
43
|
+
contractHeapMegabytes?: number;
|
|
44
|
+
};
|
|
29
45
|
export type LambderApiFilesResult = {
|
|
30
46
|
/** False when a check found a file stale, or a writer failed. */
|
|
31
47
|
ok: boolean;
|
|
32
48
|
/** What happened, per app and file, as lines to print. */
|
|
33
49
|
lines: string[];
|
|
34
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* How the contract printing process reports: one line on stdout starting
|
|
53
|
+
* with this, then its report as JSON. The line, not the exit status, is the
|
|
54
|
+
* answer: a process that dies before it (out of heap, say) answered nothing.
|
|
55
|
+
*/
|
|
56
|
+
export declare const CONTRACT_REPORT_PREFIX = "lambder-api-contract-printed:";
|
|
35
57
|
/**
|
|
36
58
|
* Writes every file the config names, or with `check` writes nothing and
|
|
37
59
|
* answers whether each one on disk is current. Paths are relative to the
|
|
38
|
-
* working directory, as each writer takes them.
|
|
60
|
+
* working directory, as each writer takes them. Each contract is printed in
|
|
61
|
+
* a Node process of its own, with a heap of `contractHeapMegabytes`.
|
|
39
62
|
*
|
|
40
63
|
* ```ts
|
|
41
64
|
* import { generateApiFiles } from "lambder/build";
|
|
@@ -54,7 +77,5 @@ export type LambderApiFilesResult = {
|
|
|
54
77
|
* process.exit(result.ok ? 0 : 1);
|
|
55
78
|
* ```
|
|
56
79
|
*/
|
|
57
|
-
export declare const generateApiFiles: (config: LambderApiFilesConfig, options?:
|
|
58
|
-
check?: boolean;
|
|
59
|
-
}) => Promise<LambderApiFilesResult>;
|
|
80
|
+
export declare const generateApiFiles: (config: LambderApiFilesConfig, options?: LambderApiFilesOptions) => Promise<LambderApiFilesResult>;
|
|
60
81
|
export {};
|
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
2
3
|
import { writeApiSignatures } from "./writeApiSignatures.js";
|
|
3
4
|
import { writeApiOptions } from "./writeApiOptions.js";
|
|
4
5
|
import { writeApiGuardParams } from "./writeApiGuardParams.js";
|
|
5
|
-
|
|
6
|
-
const
|
|
6
|
+
import { writeApiSchemas } from "./writeApiSchemas.js";
|
|
7
|
+
const APP_KEYS = ["module", "exportName", "tsconfig", "contract", "signatures", "options", "guardParams", "schemas"];
|
|
8
|
+
const FILE_KINDS = ["contract", "signatures", "options", "guardParams", "schemas"];
|
|
9
|
+
const DEFAULT_CONTRACT_HEAP_MEGABYTES = 8192;
|
|
10
|
+
/**
|
|
11
|
+
* How the contract printing process reports: one line on stdout starting
|
|
12
|
+
* with this, then its report as JSON. The line, not the exit status, is the
|
|
13
|
+
* answer: a process that dies before it (out of heap, say) answered nothing.
|
|
14
|
+
*/
|
|
15
|
+
export const CONTRACT_REPORT_PREFIX = "lambder-api-contract-printed:";
|
|
16
|
+
/** The contract printing process's entry, beside this module in the build. */
|
|
17
|
+
const CONTRACT_PRINTER_ENTRY = new URL("./contractPrinterProcess.js", import.meta.url);
|
|
18
|
+
/** How much of a process's error output a failure repeats. */
|
|
19
|
+
const STDERR_TAIL_LINES = 20;
|
|
7
20
|
/** A config that names apps, each with its module and at least one file, and no key the generator does not read. */
|
|
8
21
|
function assertConfig(config) {
|
|
9
22
|
const apps = config?.apps;
|
|
@@ -19,7 +32,7 @@ function assertConfig(config) {
|
|
|
19
32
|
if (!app.module)
|
|
20
33
|
throw new Error(`generateApiFiles: the app "${name}" names no module to read its instance from.`);
|
|
21
34
|
if (!FILE_KINDS.some((kind) => app[kind] !== undefined)) {
|
|
22
|
-
throw new Error(`generateApiFiles: the app "${name}" is written to no file; give it a contract, signatures, options or
|
|
35
|
+
throw new Error(`generateApiFiles: the app "${name}" is written to no file; give it a contract, signatures, options, guardParams or schemas.`);
|
|
23
36
|
}
|
|
24
37
|
}
|
|
25
38
|
}
|
|
@@ -35,10 +48,51 @@ const settle = async (label, write) => {
|
|
|
35
48
|
return { ok: false, lines: [`✗ ${label} failed: ${reasons.join(": ") || String(err)}`] };
|
|
36
49
|
}
|
|
37
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* writeApiContract in a Node process of its own, started with the heap given
|
|
53
|
+
* and none of this process's flags: the compiler loads no module of the app,
|
|
54
|
+
* so the script's loader is not needed there, and the heap the script runs
|
|
55
|
+
* with is not the printing's. Its result comes back as writeApiContract
|
|
56
|
+
* answers it, and what it throws is thrown here with the same messages, down
|
|
57
|
+
* the chain of causes, so a run reads the same as one in this process.
|
|
58
|
+
*/
|
|
59
|
+
const writeApiContractInOwnProcess = async (label, options, heapMegabytes) => {
|
|
60
|
+
const request = { ...options, module: options.module instanceof URL ? options.module.href : options.module };
|
|
61
|
+
const child = spawn(process.execPath, [`--max-old-space-size=${heapMegabytes}`, fileURLToPath(CONTRACT_PRINTER_ENTRY), JSON.stringify(request)], { stdio: ["ignore", "pipe", "pipe"] });
|
|
62
|
+
let stdout = "";
|
|
63
|
+
let stderr = "";
|
|
64
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
|
|
65
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
|
|
66
|
+
const ended = await new Promise((resolveEnd) => {
|
|
67
|
+
child.once("error", (error) => resolveEnd({ error }));
|
|
68
|
+
child.once("close", (status, signal) => resolveEnd({ status, signal }));
|
|
69
|
+
});
|
|
70
|
+
if ("error" in ended)
|
|
71
|
+
return { ok: false, lines: [`✗ ${label} failed: the process to print it in could not start: ${ended.error.message}`] };
|
|
72
|
+
const reportLine = stdout.split("\n").findLast((line) => line.startsWith(CONTRACT_REPORT_PREFIX));
|
|
73
|
+
if (reportLine === undefined) {
|
|
74
|
+
if (/JavaScript heap out of memory/i.test(stderr)) {
|
|
75
|
+
return { ok: false, lines: [`✗ ${label} failed: the process printing it ran out of its ${heapMegabytes} MB heap; raise contractHeapMegabytes`] };
|
|
76
|
+
}
|
|
77
|
+
const ending = ended.signal ? `signal ${ended.signal}` : `exit status ${ended.status ?? "none"}`;
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
lines: [
|
|
81
|
+
`✗ ${label} failed: the process printing it ended without an answer (${ending})`,
|
|
82
|
+
...stderr.trim().split("\n").filter(Boolean).slice(-STDERR_TAIL_LINES).map((line) => ` ${line}`),
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const report = JSON.parse(reportLine.slice(CONTRACT_REPORT_PREFIX.length));
|
|
87
|
+
if ("thrown" in report)
|
|
88
|
+
throw report.thrown.reduceRight((cause, message) => new Error(message, cause ? { cause } : undefined), undefined);
|
|
89
|
+
return report.result;
|
|
90
|
+
};
|
|
38
91
|
/**
|
|
39
92
|
* Writes every file the config names, or with `check` writes nothing and
|
|
40
93
|
* answers whether each one on disk is current. Paths are relative to the
|
|
41
|
-
* working directory, as each writer takes them.
|
|
94
|
+
* working directory, as each writer takes them. Each contract is printed in
|
|
95
|
+
* a Node process of its own, with a heap of `contractHeapMegabytes`.
|
|
42
96
|
*
|
|
43
97
|
* ```ts
|
|
44
98
|
* import { generateApiFiles } from "lambder/build";
|
|
@@ -60,6 +114,10 @@ const settle = async (label, write) => {
|
|
|
60
114
|
export const generateApiFiles = async (config, options = {}) => {
|
|
61
115
|
assertConfig(config);
|
|
62
116
|
const check = options.check ?? false;
|
|
117
|
+
const contractHeapMegabytes = options.contractHeapMegabytes ?? DEFAULT_CONTRACT_HEAP_MEGABYTES;
|
|
118
|
+
if (!Number.isInteger(contractHeapMegabytes) || contractHeapMegabytes <= 0) {
|
|
119
|
+
throw new Error(`generateApiFiles: contractHeapMegabytes is ${String(contractHeapMegabytes)}; give the heap of the process each contract is printed in as a whole number of megabytes, such as ${DEFAULT_CONTRACT_HEAP_MEGABYTES}.`);
|
|
120
|
+
}
|
|
63
121
|
const apps = Object.entries(config.apps);
|
|
64
122
|
const runs = [];
|
|
65
123
|
// The contracts first: they compile the app's sources and run none of
|
|
@@ -67,10 +125,11 @@ export const generateApiFiles = async (config, options = {}) => {
|
|
|
67
125
|
for (const [name, app] of apps) {
|
|
68
126
|
if (!app.contract)
|
|
69
127
|
continue;
|
|
70
|
-
|
|
128
|
+
const label = `${name}: the contract`;
|
|
129
|
+
runs.push(await settle(label, () => writeApiContractInOwnProcess(label, { ...app.contract, module: app.module, exportName: app.exportName, tsconfig: app.tsconfig, check }, contractHeapMegabytes)));
|
|
71
130
|
}
|
|
72
131
|
// Then what is read off the instance itself, which importing the module
|
|
73
|
-
// builds once for all
|
|
132
|
+
// builds once for all of them.
|
|
74
133
|
for (const [name, app] of apps) {
|
|
75
134
|
const instance = { module: app.module, exportName: app.exportName, check };
|
|
76
135
|
if (app.signatures)
|
|
@@ -80,6 +139,8 @@ export const generateApiFiles = async (config, options = {}) => {
|
|
|
80
139
|
for (const guardFile of app.guardParams ?? []) {
|
|
81
140
|
runs.push(await settle(`${name}: the "${guardFile.guard}" guard parameters`, () => writeApiGuardParams({ ...guardFile, ...instance })));
|
|
82
141
|
}
|
|
142
|
+
if (app.schemas)
|
|
143
|
+
runs.push(await settle(`${name}: the schemas`, () => writeApiSchemas({ ...app.schemas, ...instance })));
|
|
83
144
|
}
|
|
84
145
|
return { ok: runs.every((run) => run.ok), lines: runs.flatMap((run) => run.lines) };
|
|
85
146
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type LambderModuleLocation } from "./moduleLocation.js";
|
|
3
|
+
import { type LambderNameChanges } from "./generatedTables.js";
|
|
4
|
+
/** The zod schemas of one API, as a source reports them. */
|
|
5
|
+
type LambderApiSchemaPair = {
|
|
6
|
+
input: z.ZodType;
|
|
7
|
+
output: z.ZodType;
|
|
8
|
+
};
|
|
9
|
+
/** What writeApiSchemas reads the schemas from: a Lambder instance, or anything else that reports them the same way. */
|
|
10
|
+
export type LambderApiSchemasSource = {
|
|
11
|
+
/** Every API's input and output schemas by name. Asynchronous, since a Lambder instance loads its lazy groups before it can report every endpoint. */
|
|
12
|
+
apiSchemaEntries(): Promise<Record<string, LambderApiSchemaPair>> | Record<string, LambderApiSchemaPair>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* One place where an API's schema does something its written JSON Schema
|
|
16
|
+
* cannot hold, so the mock does not do it either:
|
|
17
|
+
*
|
|
18
|
+
* - `refinement`: a `.refine()`, `.superRefine()` or `.check()` check, or a
|
|
19
|
+
* `z.custom()` schema (written as one that takes anything). The mock does
|
|
20
|
+
* not run it, so it lets through what the refinement refuses.
|
|
21
|
+
* - `transform`: a `.transform()` (in the output form written as a schema
|
|
22
|
+
* that takes anything), a `z.preprocess()`, a codec, a rewrite such as
|
|
23
|
+
* `.trim()` or `.toLowerCase()`, a `z.coerce` schema, a `.catch()`. The mock
|
|
24
|
+
* does not apply it: its handler reads the value as posted, and a coerced or
|
|
25
|
+
* caught input the server takes, the mock refuses.
|
|
26
|
+
* - `pipe`: the second schema of a `.pipe()`. The input form carries the
|
|
27
|
+
* first, so the mock does not check the second.
|
|
28
|
+
* - `default`: a default a function computes on each parse, which the file
|
|
29
|
+
* cannot hold, so it is left out and the mock fills nothing there.
|
|
30
|
+
* - `check`: a string check JSON Schema cannot state as zod runs it: a regex
|
|
31
|
+
* with flags (`/i`, `/u`), whose pattern means something else without
|
|
32
|
+
* them, or a URL's `protocol` or `hostname` rule (`z.httpUrl()` among
|
|
33
|
+
* them). The mock does not run it, so it lets through what the check
|
|
34
|
+
* refuses.
|
|
35
|
+
*/
|
|
36
|
+
export type LambderApiSchemaLoss = {
|
|
37
|
+
/** The API whose schema it is. */
|
|
38
|
+
api: string;
|
|
39
|
+
/** Which of its schemas: the input a client posts, or the output it receives. */
|
|
40
|
+
direction: "input" | "output";
|
|
41
|
+
/** Where, as a JSON pointer into the written schema: `#/properties/name`, or `#` for the whole schema. */
|
|
42
|
+
path: string;
|
|
43
|
+
kind: "refinement" | "transform" | "pipe" | "default" | "check";
|
|
44
|
+
};
|
|
45
|
+
export type LambderApiSchemasFileOptions = {
|
|
46
|
+
/**
|
|
47
|
+
* The module that exports the instance, usually the server's entry: a
|
|
48
|
+
* path relative to the working directory, or a file URL. It is imported
|
|
49
|
+
* in this process, so a TypeScript module needs the process's loader
|
|
50
|
+
* (`tsx`, `node --import tsx`), as the generator script itself does.
|
|
51
|
+
*/
|
|
52
|
+
module: LambderModuleLocation;
|
|
53
|
+
/** The export that holds the instance. Default: "default", the module's default export. */
|
|
54
|
+
exportName?: string;
|
|
55
|
+
/** The TypeScript module to write, exporting `apiSchemas`. Relative to the working directory. */
|
|
56
|
+
file: string;
|
|
57
|
+
/** Write nothing, and answer whether the file on disk holds what the instance reports now. Default: false. */
|
|
58
|
+
check?: boolean;
|
|
59
|
+
/** The comment the file opens with, one `//` line per line. It should name what generates the file and say it is for development only. Default: a note naming writeApiSchemas(). */
|
|
60
|
+
header?: string;
|
|
61
|
+
/** End the generated statement with a semicolon. Default: true. The table itself is JSON, double quotes included, whatever the project's style: that is what lets a check read it back. */
|
|
62
|
+
semicolons?: boolean;
|
|
63
|
+
};
|
|
64
|
+
export type LambderApiSchemasFileResult = {
|
|
65
|
+
/** False when a check found the file stale or unreadable. Losses do not make it false: they are what the file cannot hold, however current it is. */
|
|
66
|
+
ok: boolean;
|
|
67
|
+
/** The absolute path. */
|
|
68
|
+
file: string;
|
|
69
|
+
/** True when the file was (re)written; a file that already holds this table is left untouched, however it is formatted. */
|
|
70
|
+
written: boolean;
|
|
71
|
+
/** How many APIs the table holds. */
|
|
72
|
+
count: number;
|
|
73
|
+
/** Which APIs moved against the file that was on disk. */
|
|
74
|
+
changes: LambderNameChanges;
|
|
75
|
+
/** Every place a schema does what its written form cannot hold, sorted by API, direction and place. */
|
|
76
|
+
losses: LambderApiSchemaLoss[];
|
|
77
|
+
/** What happened, as lines to print: a summary, one line per API that moved, then one line per loss. */
|
|
78
|
+
lines: string[];
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Writes every API's input and output schemas as JSON Schema to a module of
|
|
82
|
+
* plain data, or checks the one on disk, and says which APIs moved and what
|
|
83
|
+
* their schemas do that the file cannot hold.
|
|
84
|
+
*
|
|
85
|
+
* Call it from a generator script beside writeApiOptions, writing to where
|
|
86
|
+
* the mock's setup reads it:
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* import { writeApiSchemas } from "lambder/build";
|
|
90
|
+
*
|
|
91
|
+
* const result = await writeApiSchemas({
|
|
92
|
+
* module: "server/src/index.ts", // export const lambder = initLambder()...
|
|
93
|
+
* exportName: "lambder",
|
|
94
|
+
* file: "web/src/mock/apiSchemas.generated.ts",
|
|
95
|
+
* check: process.argv.includes("--check"),
|
|
96
|
+
* });
|
|
97
|
+
* console.log(result.lines.join("\n"));
|
|
98
|
+
* process.exit(result.ok ? 0 : 1);
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* The table is compared as the data the file holds, so a checkout that
|
|
102
|
+
* rewrote its line endings or a formatter that re-indented it is neither
|
|
103
|
+
* stale nor rewritten. The same instance writes the same file on every run.
|
|
104
|
+
* A module that does not load, an export that is not an instance, or a
|
|
105
|
+
* schema holding what JSON Schema cannot represent (other than a refinement
|
|
106
|
+
* or a transform, which are listed) throws.
|
|
107
|
+
*/
|
|
108
|
+
export declare const writeApiSchemas: (options: LambderApiSchemasFileOptions) => Promise<LambderApiSchemasFileResult>;
|
|
109
|
+
export {};
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { canonicalJson } from "../shared/util/canonicalJson.js";
|
|
5
|
+
import { LAMBDER_STRIP_UNKNOWN_KEYS } from "../shared/wire/LambderApiSchemaEntries.js";
|
|
6
|
+
import { moduleUrlOf } from "./moduleLocation.js";
|
|
7
|
+
import { headerLinesOf, nameChangesOf, settleGeneratedFile, tableStatementLines } from "./generatedTables.js";
|
|
8
|
+
/*
|
|
9
|
+
* Every API's input and output schemas as JSON Schema, in a generated module
|
|
10
|
+
* of plain data a mock validates its calls against: so in development an
|
|
11
|
+
* endpoint refuses what the server refuses (a length, a format, a range), a
|
|
12
|
+
* handler reads the payload the server's parse leaves, and an answer goes out
|
|
13
|
+
* stripped and defaulted as the server sends it.
|
|
14
|
+
*
|
|
15
|
+
* The input is written in its input form, what the server's parse takes from
|
|
16
|
+
* a client. The output is written in its output form, because a mock handler
|
|
17
|
+
* returns what the client receives (the contract types it so), where a server
|
|
18
|
+
* handler returns what the schema parses from: the output form is what the
|
|
19
|
+
* mock has to check and strip.
|
|
20
|
+
*
|
|
21
|
+
* z.toJSONSchema writes the schemas, and three things are this writer's own.
|
|
22
|
+
* A zod object drops the keys it does not declare, which JSON Schema cannot
|
|
23
|
+
* say, so each such object is marked (LAMBDER_STRIP_UNKNOWN_KEYS) and the mock
|
|
24
|
+
* drops them again. What zod cannot represent at all (a Date, a bigint, a Map)
|
|
25
|
+
* throws, naming the API and the place, since a schema the mock reads
|
|
26
|
+
* differently from the server is the drift this file exists to end. And what
|
|
27
|
+
* zod represents only in part is listed as a loss, in the result and its
|
|
28
|
+
* lines: a refinement it leaves out, a transform it does not run, a pipe whose
|
|
29
|
+
* second schema the input form does not carry, a default a function computes
|
|
30
|
+
* on each parse, a check it writes without what zod runs it with (a regex's
|
|
31
|
+
* flags, a URL's protocol rule). Where zod writes a check the mock would read
|
|
32
|
+
* back as another (a guid as a strict uuid, a custom email pattern as zod's
|
|
33
|
+
* own), the writer leaves out the misleading part instead.
|
|
34
|
+
*
|
|
35
|
+
* The file names every endpoint and every field each takes and gives, so it
|
|
36
|
+
* is for development alone: imported by the mock's setup, which a production
|
|
37
|
+
* build never loads.
|
|
38
|
+
*/
|
|
39
|
+
const DEFAULT_HEADER = [
|
|
40
|
+
"Generated by writeApiSchemas() from lambder/build. Do not edit.",
|
|
41
|
+
"",
|
|
42
|
+
"Every API's input schema, in the form a client posts, and output schema,",
|
|
43
|
+
"in the form a client receives, as JSON Schema (draft 2020-12): what the",
|
|
44
|
+
"mock validates its calls against, as the server does. Development only:",
|
|
45
|
+
"it names every endpoint, so only the mock's setup imports it.",
|
|
46
|
+
].join("\n");
|
|
47
|
+
const TABLE_NAME = "apiSchemas";
|
|
48
|
+
/** What each kind of loss says in the lines. */
|
|
49
|
+
const LOSS_WORDING = {
|
|
50
|
+
refinement: "a refinement, which the mock does not run",
|
|
51
|
+
transform: "a transform, which the mock does not apply",
|
|
52
|
+
pipe: "a pipe, whose second schema the mock does not check",
|
|
53
|
+
default: "a default computed on each parse, which the mock does not fill",
|
|
54
|
+
check: "a check written without its flags or URL rule, which the mock does not run",
|
|
55
|
+
};
|
|
56
|
+
const defOf = (schema) => schema._zod.def;
|
|
57
|
+
/**
|
|
58
|
+
* Regex flags that change what a pattern matches. A JSON Schema pattern is
|
|
59
|
+
* the source alone, so the mock compiles it without them; `g` and `d` change
|
|
60
|
+
* nothing about a test zod runs from the start of the value.
|
|
61
|
+
*/
|
|
62
|
+
const MEANING_FLAGS = /[imsuvy]/;
|
|
63
|
+
/** zod's own email pattern, which the mock rebuilds a written `format: "email"` with. */
|
|
64
|
+
const DEFAULT_EMAIL_PATTERN = z.email()._zod.def.pattern;
|
|
65
|
+
/** A JSON pointer into the written schema, for a path as z.toJSONSchema reports it. */
|
|
66
|
+
const pointerOf = (path) => ["#", ...path.map((segment) => String(segment).replace(/~/g, "~0").replace(/\//g, "~1"))].join("/");
|
|
67
|
+
/** A value as the JSON it is written as, compared by data: what the file holds of a default. */
|
|
68
|
+
const asWritten = (value) => {
|
|
69
|
+
const text = JSON.stringify(value);
|
|
70
|
+
return text === undefined ? undefined : canonicalJson(JSON.parse(text));
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Whether the default zod wrote is computed on each parse: read once more,
|
|
74
|
+
* it answers otherwise. A default given as a value is copied on each read and
|
|
75
|
+
* answers the same; one a function computes (an id, a timestamp) would put a
|
|
76
|
+
* different value in the file on every run, and a value the server never
|
|
77
|
+
* repeats in every mock call.
|
|
78
|
+
*/
|
|
79
|
+
const computedOnEachParse = (def, written) => {
|
|
80
|
+
let again;
|
|
81
|
+
try {
|
|
82
|
+
again = def.type === "catch" ? def.catchValue?.(undefined) : def.defaultValue;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return asWritten(again) !== asWritten(written);
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Whether zod cannot represent a schema because of what it does to a value
|
|
91
|
+
* rather than what it takes: a transform, a z.custom(), a catch whose value is
|
|
92
|
+
* computed from the failed parse, a coercion (z.coerce.date() parses to a
|
|
93
|
+
* Date, which JSON Schema has no type for). Each is written as a schema that
|
|
94
|
+
* takes anything and listed as a loss; anything else zod cannot represent
|
|
95
|
+
* throws.
|
|
96
|
+
*/
|
|
97
|
+
const isNonCarryable = (def) => def.type === "transform" || def.type === "custom" || def.type === "catch" || def.coerce === true;
|
|
98
|
+
/** One API's schema in the direction given, as plain JSON Schema, with what it does not carry added to `losses`. */
|
|
99
|
+
const jsonSchemaOf = (api, direction, schema, losses) => {
|
|
100
|
+
if (typeof schema?._zod !== "object") {
|
|
101
|
+
throw new Error(`writeApiSchemas: API "${api}" reports no zod ${direction} schema.`);
|
|
102
|
+
}
|
|
103
|
+
const lose = (path, kind) => {
|
|
104
|
+
const loss = { api, direction, path: pointerOf(path), kind };
|
|
105
|
+
if (!losses.some((known) => known.api === loss.api && known.direction === loss.direction && known.path === loss.path && known.kind === loss.kind))
|
|
106
|
+
losses.push(loss);
|
|
107
|
+
};
|
|
108
|
+
const written = z.toJSONSchema(schema, {
|
|
109
|
+
io: direction,
|
|
110
|
+
unrepresentable: ({ zodSchema, path, message }) => {
|
|
111
|
+
const def = defOf(zodSchema);
|
|
112
|
+
if (!isNonCarryable(def)) {
|
|
113
|
+
throw new Error(`writeApiSchemas: the ${direction} schema of API "${api}" holds what JSON Schema cannot represent, at ${pointerOf(path)}: ${message}. Declare it as the value it is on the wire (a z.date() as z.iso.datetime(), a bigint as a string).`);
|
|
114
|
+
}
|
|
115
|
+
lose(path, def.type === "custom" ? "refinement" : "transform");
|
|
116
|
+
return "any";
|
|
117
|
+
},
|
|
118
|
+
override: ({ zodSchema, jsonSchema, path }) => {
|
|
119
|
+
const def = defOf(zodSchema);
|
|
120
|
+
const json = jsonSchema;
|
|
121
|
+
if (def.type === "object" && def.catchall === undefined) {
|
|
122
|
+
// zod writes nothing in the input form and false in the
|
|
123
|
+
// output form, where the parse accepts the key and drops it.
|
|
124
|
+
delete json.additionalProperties;
|
|
125
|
+
json[LAMBDER_STRIP_UNKNOWN_KEYS] = true;
|
|
126
|
+
}
|
|
127
|
+
for (const check of def.checks ?? []) {
|
|
128
|
+
if (check._zod.def.check === "custom")
|
|
129
|
+
lose(path, "refinement");
|
|
130
|
+
else if (check._zod.def.check === "overwrite")
|
|
131
|
+
lose(path, "transform");
|
|
132
|
+
}
|
|
133
|
+
if (def.type === "string") {
|
|
134
|
+
// The mock rebuilds a written format by its name, with zod's
|
|
135
|
+
// defaults (a guid as a strict uuid, an email under zod's
|
|
136
|
+
// own pattern), and the pattern written beside it as given.
|
|
137
|
+
const formatChecks = [def, ...(def.checks ?? []).map((check) => check._zod.def)].filter((check) => check.check === "string_format");
|
|
138
|
+
for (const check of formatChecks) {
|
|
139
|
+
// The pattern written beside these is the check exactly;
|
|
140
|
+
// the format would read back stricter than the server.
|
|
141
|
+
if (check.format === "guid" || (check.format === "email" && (check.pattern?.source !== DEFAULT_EMAIL_PATTERN.source || check.pattern.flags !== DEFAULT_EMAIL_PATTERN.flags)))
|
|
142
|
+
delete json.format;
|
|
143
|
+
if (check.format === "url") {
|
|
144
|
+
if (check.protocol || check.hostname)
|
|
145
|
+
lose(path, "check");
|
|
146
|
+
if (check.normalize)
|
|
147
|
+
lose(path, "transform");
|
|
148
|
+
}
|
|
149
|
+
if (check.pattern && MEANING_FLAGS.test(check.pattern.flags)) {
|
|
150
|
+
// Without its flags the pattern refuses what the
|
|
151
|
+
// server takes, so it is not written. A format the
|
|
152
|
+
// mock rebuilds by name (z.emoji()) still runs it.
|
|
153
|
+
const source = check.pattern.source;
|
|
154
|
+
if (json.pattern === source)
|
|
155
|
+
delete json.pattern;
|
|
156
|
+
if (Array.isArray(json.allOf)) {
|
|
157
|
+
json.allOf = json.allOf.filter((entry) => entry.pattern !== source);
|
|
158
|
+
if (!json.allOf.length)
|
|
159
|
+
delete json.allOf;
|
|
160
|
+
}
|
|
161
|
+
if (check.format === "regex" || !("format" in json))
|
|
162
|
+
lose(path, "check");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// A string's second pattern goes in allOf, and the mock reads
|
|
166
|
+
// an allOf entry's pattern only when the entry says string.
|
|
167
|
+
for (const entry of Array.isArray(json.allOf) ? json.allOf : []) {
|
|
168
|
+
if ("pattern" in entry)
|
|
169
|
+
entry.type = "string";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (def.type === "custom")
|
|
173
|
+
lose(path, "refinement");
|
|
174
|
+
if (def.type === "transform")
|
|
175
|
+
lose(path, "transform");
|
|
176
|
+
// A coercion, a catch and a pipe change only what an input may
|
|
177
|
+
// be: a mock handler returns its output already in the form the
|
|
178
|
+
// client receives, which the output form checks in full.
|
|
179
|
+
if (direction === "input") {
|
|
180
|
+
if (def.coerce === true || def.type === "catch")
|
|
181
|
+
lose(path, "transform");
|
|
182
|
+
if (def.type === "pipe") {
|
|
183
|
+
const transforms = def.in?._zod.def.type === "transform" || def.out?._zod.def.type === "transform" || def.transform !== undefined;
|
|
184
|
+
lose(path, transforms ? "transform" : "pipe");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if ((def.type === "default" || def.type === "prefault" || def.type === "catch") && "default" in json && computedOnEachParse(def, json.default)) {
|
|
188
|
+
delete json.default;
|
|
189
|
+
lose(path, "default");
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
// Through JSON, so what the file holds is exactly the plain data a check
|
|
194
|
+
// reads back; the draft is the module's, said once in its header.
|
|
195
|
+
const plain = JSON.parse(JSON.stringify(written));
|
|
196
|
+
delete plain.$schema;
|
|
197
|
+
return plain;
|
|
198
|
+
};
|
|
199
|
+
/** Losses in a stable order, so the lines and the result read the same on every run. */
|
|
200
|
+
const compareLosses = (a, b) => a.api.localeCompare(b.api) || a.direction.localeCompare(b.direction) || a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind);
|
|
201
|
+
/** The losses as lines, under one naming how many APIs carry them. */
|
|
202
|
+
const lossLinesOf = (losses) => {
|
|
203
|
+
if (!losses.length)
|
|
204
|
+
return [];
|
|
205
|
+
const apis = new Set(losses.map((loss) => loss.api)).size;
|
|
206
|
+
return [
|
|
207
|
+
` ${apis === 1 ? "1 API carries" : `${apis} APIs carry`} what JSON Schema cannot hold, which the mock does not check:`,
|
|
208
|
+
...losses.map((loss) => ` ! ${loss.api} ${loss.direction} ${loss.path}: ${LOSS_WORDING[loss.kind]}`),
|
|
209
|
+
];
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* The table a generated file holds, read back as data, or null for a file
|
|
213
|
+
* that does not hold it as written. Read up to the last `as const` in the
|
|
214
|
+
* file, which is the statement's own: the file holds one statement, and a
|
|
215
|
+
* schema's description may say "as const" itself.
|
|
216
|
+
*/
|
|
217
|
+
const readSchemaTable = (contents) => {
|
|
218
|
+
const match = new RegExp(`export const ${TABLE_NAME}\\s*=\\s*([\\s\\S]*)\\s+as const\\b`).exec(contents);
|
|
219
|
+
if (!match)
|
|
220
|
+
return null;
|
|
221
|
+
try {
|
|
222
|
+
const table = JSON.parse(match[1]);
|
|
223
|
+
return table !== null && typeof table === "object" && !Array.isArray(table) ? table : null;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const renderSchemasFile = (entries, options) => [
|
|
230
|
+
...headerLinesOf(options.header ?? DEFAULT_HEADER),
|
|
231
|
+
"",
|
|
232
|
+
...tableStatementLines({
|
|
233
|
+
name: TABLE_NAME,
|
|
234
|
+
doc: "Every API the server registers: its input schema in its input form and its output schema in its output form, as JSON Schema.",
|
|
235
|
+
value: entries,
|
|
236
|
+
semicolon: options.semicolons === false ? "" : ";",
|
|
237
|
+
}),
|
|
238
|
+
"",
|
|
239
|
+
].join("\n");
|
|
240
|
+
/** Imports the module, finds the instance under its export, and answers the schemas it reports. */
|
|
241
|
+
const loadApiSchemaPairs = async (options) => {
|
|
242
|
+
const moduleUrl = moduleUrlOf(options.module);
|
|
243
|
+
const exportName = options.exportName ?? "default";
|
|
244
|
+
let namespace;
|
|
245
|
+
try {
|
|
246
|
+
namespace = await import(moduleUrl);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
throw new Error(`writeApiSchemas could not load ${moduleUrl}`, { cause: err });
|
|
250
|
+
}
|
|
251
|
+
const source = namespace[exportName];
|
|
252
|
+
if (typeof source?.apiSchemaEntries !== "function") {
|
|
253
|
+
throw new Error(`${moduleUrl} has no export "${exportName}" that reports API schemas: name the export holding the instance in exportName`);
|
|
254
|
+
}
|
|
255
|
+
return await source.apiSchemaEntries();
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Writes every API's input and output schemas as JSON Schema to a module of
|
|
259
|
+
* plain data, or checks the one on disk, and says which APIs moved and what
|
|
260
|
+
* their schemas do that the file cannot hold.
|
|
261
|
+
*
|
|
262
|
+
* Call it from a generator script beside writeApiOptions, writing to where
|
|
263
|
+
* the mock's setup reads it:
|
|
264
|
+
*
|
|
265
|
+
* ```ts
|
|
266
|
+
* import { writeApiSchemas } from "lambder/build";
|
|
267
|
+
*
|
|
268
|
+
* const result = await writeApiSchemas({
|
|
269
|
+
* module: "server/src/index.ts", // export const lambder = initLambder()...
|
|
270
|
+
* exportName: "lambder",
|
|
271
|
+
* file: "web/src/mock/apiSchemas.generated.ts",
|
|
272
|
+
* check: process.argv.includes("--check"),
|
|
273
|
+
* });
|
|
274
|
+
* console.log(result.lines.join("\n"));
|
|
275
|
+
* process.exit(result.ok ? 0 : 1);
|
|
276
|
+
* ```
|
|
277
|
+
*
|
|
278
|
+
* The table is compared as the data the file holds, so a checkout that
|
|
279
|
+
* rewrote its line endings or a formatter that re-indented it is neither
|
|
280
|
+
* stale nor rewritten. The same instance writes the same file on every run.
|
|
281
|
+
* A module that does not load, an export that is not an instance, or a
|
|
282
|
+
* schema holding what JSON Schema cannot represent (other than a refinement
|
|
283
|
+
* or a transform, which are listed) throws.
|
|
284
|
+
*/
|
|
285
|
+
export const writeApiSchemas = async (options) => {
|
|
286
|
+
const file = resolve(options.file);
|
|
287
|
+
const pairs = await loadApiSchemaPairs(options);
|
|
288
|
+
const losses = [];
|
|
289
|
+
const entries = {};
|
|
290
|
+
for (const api of Object.keys(pairs).sort()) {
|
|
291
|
+
const pair = pairs[api];
|
|
292
|
+
entries[api] = { input: jsonSchemaOf(api, "input", pair.input, losses), output: jsonSchemaOf(api, "output", pair.output, losses) };
|
|
293
|
+
}
|
|
294
|
+
losses.sort(compareLosses);
|
|
295
|
+
const previousText = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
296
|
+
const previous = previousText === null ? null : readSchemaTable(previousText);
|
|
297
|
+
const changes = nameChangesOf(entries, previous ?? {});
|
|
298
|
+
const count = Object.keys(entries).length;
|
|
299
|
+
const settled = settleGeneratedFile({
|
|
300
|
+
name: options.file, file, check: options.check, previousText, readBack: previous !== null,
|
|
301
|
+
tables: [{ name: TABLE_NAME, count, changes }],
|
|
302
|
+
held: count === 1 ? "1 API" : `${count} APIs`,
|
|
303
|
+
tablesNoun: "the schema table",
|
|
304
|
+
unmovedLine: "no schemas changed",
|
|
305
|
+
render: () => renderSchemasFile(entries, options),
|
|
306
|
+
});
|
|
307
|
+
return { ...settled, lines: [...settled.lines, ...lossLinesOf(losses)], file, count, changes, losses };
|
|
308
|
+
};
|
package/dist/build.d.ts
CHANGED
|
@@ -19,8 +19,10 @@ export type { LambderApiOptionsFileOptions, LambderApiOptionsFileResult, } from
|
|
|
19
19
|
export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
|
|
20
20
|
export type { LambderApiGuardParamsFileOptions, LambderApiGuardParamsFileResult, } from "./build/writeApiGuardParams.js";
|
|
21
21
|
export type { LambderApiOptionsSource, LambderNameChanges } from "./build/generatedTables.js";
|
|
22
|
+
export { writeApiSchemas } from "./build/writeApiSchemas.js";
|
|
23
|
+
export type { LambderApiSchemasSource, LambderApiSchemasFileOptions, LambderApiSchemasFileResult, LambderApiSchemaLoss, } from "./build/writeApiSchemas.js";
|
|
22
24
|
export { checkApiRefusals } from "./build/checkApiRefusals.js";
|
|
23
25
|
export type { LambderApiRefusalCheckOptions, LambderApiRefusalCheckResult, LambderRefusalCheckFinding, } from "./build/checkApiRefusals.js";
|
|
24
26
|
export { generateApiFiles } from "./build/generateApiFiles.js";
|
|
25
|
-
export type { LambderApiFilesConfig, LambderApiFilesApp, LambderApiFilesResult } from "./build/generateApiFiles.js";
|
|
27
|
+
export type { LambderApiFilesConfig, LambderApiFilesApp, LambderApiFilesOptions, LambderApiFilesResult } from "./build/generateApiFiles.js";
|
|
26
28
|
export type { LambderModuleLocation } from "./build/moduleLocation.js";
|
package/dist/build.js
CHANGED
|
@@ -14,5 +14,7 @@ export { writeApiSignatures } from "./build/writeApiSignatures.js";
|
|
|
14
14
|
export { writeApiContract } from "./build/writeApiContract.js";
|
|
15
15
|
export { writeApiOptions } from "./build/writeApiOptions.js";
|
|
16
16
|
export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
|
|
17
|
+
// Development only: the schemas a mock validates its calls against.
|
|
18
|
+
export { writeApiSchemas } from "./build/writeApiSchemas.js";
|
|
17
19
|
export { checkApiRefusals } from "./build/checkApiRefusals.js";
|
|
18
20
|
export { generateApiFiles } from "./build/generateApiFiles.js";
|