@zenera/faker 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/box.d.ts +43 -0
- package/dist/box.js +158 -0
- package/dist/cache.d.ts +57 -0
- package/dist/cache.js +125 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +339 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +38 -0
- package/dist/generate.js +125 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +77 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +363 -0
- package/dist/probe.d.ts +7 -0
- package/dist/probe.js +278 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +98 -0
- package/dist/router.d.ts +14 -0
- package/dist/router.js +83 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +221 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +256 -0
- package/dist/setup.d.ts +35 -0
- package/dist/setup.js +81 -0
- package/dist/spec.d.ts +41 -0
- package/dist/spec.js +257 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +51 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createHash, randomInt } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { BuildFailed } from "./cache.js";
|
|
4
|
+
import { reason } from "./generate.js";
|
|
5
|
+
import { describeIssues, issues } from "./validate.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// The server
|
|
8
|
+
//
|
|
9
|
+
// `node:http` and a switch. A framework would earn its place if there were
|
|
10
|
+
// middleware to compose, and there is not: one route table, one validation
|
|
11
|
+
// step, one generator.
|
|
12
|
+
//
|
|
13
|
+
// The rules that are not obvious from the code are the ones about what does not
|
|
14
|
+
// travel. Request headers are filtered before the envelope is written, because
|
|
15
|
+
// that envelope becomes a file inside a container. Request bodies never reach a
|
|
16
|
+
// prompt at all — the build loop uses probes it made up. And the listener binds
|
|
17
|
+
// to loopback unless somebody says otherwise in as many words.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
/** Headers that are none of a generator's business. */
|
|
20
|
+
const REDACTED = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
|
|
21
|
+
const SECRETISH = /key|token|secret|password|credential/i;
|
|
22
|
+
const DEFAULT_MAX_BODY = 1024 * 1024;
|
|
23
|
+
export function build(opts) {
|
|
24
|
+
return createServer((req, res) => {
|
|
25
|
+
handle(req, res, opts).catch((err) => {
|
|
26
|
+
// Nothing below is expected to throw; if it does, the client still
|
|
27
|
+
// gets an answer and the operator still gets the reason.
|
|
28
|
+
send(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export async function listen(opts, host, port) {
|
|
33
|
+
const server = build(opts);
|
|
34
|
+
await new Promise((settle, fail) => {
|
|
35
|
+
server.once('error', fail);
|
|
36
|
+
server.listen(port, host, () => {
|
|
37
|
+
server.off('error', fail);
|
|
38
|
+
settle();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
server,
|
|
43
|
+
port: server.address().port,
|
|
44
|
+
close: () => new Promise((settle) => {
|
|
45
|
+
server.close(() => settle());
|
|
46
|
+
server.closeIdleConnections();
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// One request
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
async function handle(req, res, opts) {
|
|
54
|
+
const started = Date.now();
|
|
55
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
56
|
+
const method = (req.method ?? 'GET').toLowerCase();
|
|
57
|
+
// The search string is part of the request: without it a 400 caused by a
|
|
58
|
+
// query parameter is unexplainable from the log.
|
|
59
|
+
const say = (status, what) => opts.onRequest?.(`${req.method} ${url.pathname}${url.search} ${status} ${Date.now() - started}ms ${what}`);
|
|
60
|
+
if (url.pathname.startsWith('/__faker/')) {
|
|
61
|
+
introspect(url.pathname, res, opts);
|
|
62
|
+
say(200, 'introspection');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const match = opts.router.match(method, url.pathname);
|
|
66
|
+
if (!match) {
|
|
67
|
+
const allowed = opts.router.allowed(url.pathname);
|
|
68
|
+
if (allowed.length > 0) {
|
|
69
|
+
res.setHeader('allow', allowed.map((m) => m.toUpperCase()).join(', '));
|
|
70
|
+
send(res, 405, { error: `${req.method} is not defined for ${url.pathname}` });
|
|
71
|
+
say(405, 'no such method');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
send(res, 404, { error: `no operation matches ${req.method} ${url.pathname}` });
|
|
75
|
+
say(404, 'no route');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const { operation, pathParams } = match;
|
|
79
|
+
res.setHeader('x-faker-operation', operation.operationId);
|
|
80
|
+
let body;
|
|
81
|
+
try {
|
|
82
|
+
body = await readBody(req, opts.maxBody ?? DEFAULT_MAX_BODY);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
86
|
+
send(res, message.includes('too large') ? 413 : 400, { error: message });
|
|
87
|
+
say(400, 'bad body');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const problems = check(operation, opts.checks, pathParams, url.searchParams, body);
|
|
91
|
+
if (problems.length > 0) {
|
|
92
|
+
send(res, 400, { error: describeIssues(problems), issues: problems });
|
|
93
|
+
say(400, describeIssues(problems));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!operation.success.schema) {
|
|
97
|
+
res.statusCode = operation.success.status;
|
|
98
|
+
res.end();
|
|
99
|
+
say(operation.success.status, 'no body declared');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
let generator;
|
|
103
|
+
try {
|
|
104
|
+
generator = await opts.cache.ensure(operation);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
const status = err instanceof BuildFailed ? 501 : 500;
|
|
108
|
+
const detail = reason(err);
|
|
109
|
+
send(res, status, {
|
|
110
|
+
error: `no generator for ${operation.operationId}`,
|
|
111
|
+
detail,
|
|
112
|
+
diagnostics: err instanceof BuildFailed ? err.diagnostics : undefined,
|
|
113
|
+
});
|
|
114
|
+
say(status, `no generator: ${detail}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
res.setHeader('x-faker-cache', generator.cached ? 'hit' : 'miss');
|
|
118
|
+
const input = {
|
|
119
|
+
operationId: operation.operationId,
|
|
120
|
+
method: operation.method,
|
|
121
|
+
path: operation.path,
|
|
122
|
+
pathParams,
|
|
123
|
+
query: Object.fromEntries(url.searchParams),
|
|
124
|
+
headers: safeHeaders(req),
|
|
125
|
+
body,
|
|
126
|
+
seed: seedFor(opts.seed, operation, pathParams, url.searchParams),
|
|
127
|
+
};
|
|
128
|
+
const outcome = await opts.box.run(operation.key, input);
|
|
129
|
+
if (!outcome.ok) {
|
|
130
|
+
send(res, 502, {
|
|
131
|
+
error: `the generator for ${operation.operationId} ${outcome.fault}`,
|
|
132
|
+
stderr: outcome.stderr || undefined,
|
|
133
|
+
});
|
|
134
|
+
say(502, 'generator faulted');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
send(res, operation.success.status, outcome.value);
|
|
138
|
+
say(operation.success.status, generator.cached ? 'hit' : 'miss');
|
|
139
|
+
}
|
|
140
|
+
function check(operation, checks, pathParams, query, body) {
|
|
141
|
+
const compiled = checks.for(operation);
|
|
142
|
+
const out = [];
|
|
143
|
+
// Coercion rewrites what it is given, so the copies are validated and the
|
|
144
|
+
// originals are what reach the generator.
|
|
145
|
+
if (!compiled.path({ ...pathParams })) {
|
|
146
|
+
out.push(...issues('path', compiled.path.errors));
|
|
147
|
+
}
|
|
148
|
+
if (!compiled.query(Object.fromEntries(query))) {
|
|
149
|
+
out.push(...issues('query', compiled.query.errors));
|
|
150
|
+
}
|
|
151
|
+
if (compiled.body) {
|
|
152
|
+
if (body === undefined) {
|
|
153
|
+
if (compiled.bodyRequired) {
|
|
154
|
+
out.push({ where: 'body', message: 'is required' });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (!compiled.body(body)) {
|
|
158
|
+
out.push(...issues('body', compiled.body.errors));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Wire details
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
async function readBody(req, max) {
|
|
167
|
+
const claimed = Number(req.headers['content-length'] ?? '');
|
|
168
|
+
if (Number.isFinite(claimed) && claimed > max) {
|
|
169
|
+
throw new Error(`the body is too large (${claimed} bytes, limit ${max})`);
|
|
170
|
+
}
|
|
171
|
+
const chunks = [];
|
|
172
|
+
let size = 0;
|
|
173
|
+
for await (const chunk of req) {
|
|
174
|
+
size += chunk.length;
|
|
175
|
+
if (size > max) {
|
|
176
|
+
req.destroy();
|
|
177
|
+
throw new Error(`the body is too large (limit ${max} bytes)`);
|
|
178
|
+
}
|
|
179
|
+
chunks.push(chunk);
|
|
180
|
+
}
|
|
181
|
+
if (size === 0) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
185
|
+
const type = String(req.headers['content-type'] ?? '')
|
|
186
|
+
.split(';')[0]
|
|
187
|
+
.trim();
|
|
188
|
+
if (type && type !== 'application/json' && !type.endsWith('+json')) {
|
|
189
|
+
return text;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(text);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
throw new Error(`the body is not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Headers reach the generator so it can vary on things like `accept-language`.
|
|
200
|
+
* Credentials do not: the envelope is written to a file inside a container, and
|
|
201
|
+
* a mock is exactly what people point a real bearer token at by accident.
|
|
202
|
+
*/
|
|
203
|
+
function safeHeaders(req) {
|
|
204
|
+
const out = {};
|
|
205
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
206
|
+
if (value === undefined || REDACTED.test(name) || SECRETISH.test(name)) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
out[name] = Array.isArray(value) ? value.join(', ') : value;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Without a base seed every call is a fresh roll, which is what a demo wants.
|
|
215
|
+
* With one, the seed is a function of the request, so polling the same URL
|
|
216
|
+
* returns the same body and a test can assert on it.
|
|
217
|
+
*/
|
|
218
|
+
function seedFor(base, operation, pathParams, query) {
|
|
219
|
+
if (base === undefined) {
|
|
220
|
+
return randomInt(1, 2 ** 31 - 1);
|
|
221
|
+
}
|
|
222
|
+
const shape = JSON.stringify([
|
|
223
|
+
base,
|
|
224
|
+
operation.key,
|
|
225
|
+
Object.entries(pathParams).sort(),
|
|
226
|
+
[...query.entries()].sort(),
|
|
227
|
+
]);
|
|
228
|
+
return createHash('sha256').update(shape).digest().readUInt32BE(0) % 2 ** 31;
|
|
229
|
+
}
|
|
230
|
+
function introspect(pathname, res, opts) {
|
|
231
|
+
if (pathname === '/__faker/routes') {
|
|
232
|
+
send(res, 200, opts.router.operations.map((o) => ({
|
|
233
|
+
method: o.method.toUpperCase(),
|
|
234
|
+
path: o.path,
|
|
235
|
+
operationId: o.operationId,
|
|
236
|
+
status: o.success.status,
|
|
237
|
+
body: Boolean(o.success.schema),
|
|
238
|
+
key: o.key,
|
|
239
|
+
source: o.source,
|
|
240
|
+
})));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (pathname === '/__faker/health') {
|
|
244
|
+
send(res, 200, { ok: true, operations: opts.router.operations.length });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
send(res, 404, { error: `no such endpoint: ${pathname}` });
|
|
248
|
+
}
|
|
249
|
+
function send(res, status, value) {
|
|
250
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
251
|
+
res.statusCode = status;
|
|
252
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
253
|
+
res.setHeader('content-length', Buffer.byteLength(text));
|
|
254
|
+
res.end(text);
|
|
255
|
+
}
|
|
256
|
+
//# sourceMappingURL=server.js.map
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Model } from '@zenera/neo';
|
|
2
|
+
import { Box } from './box.ts';
|
|
3
|
+
import { Cache, type CacheOptions } from './cache.ts';
|
|
4
|
+
import { Router } from './router.ts';
|
|
5
|
+
import { SpecError } from './spec.ts';
|
|
6
|
+
import { Checks } from './validate.ts';
|
|
7
|
+
export interface SetupOptions {
|
|
8
|
+
specs: readonly string[];
|
|
9
|
+
cwd: string;
|
|
10
|
+
/** where generators and the container's workspace live */
|
|
11
|
+
cache?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
image?: string;
|
|
14
|
+
attempts?: number;
|
|
15
|
+
/** how many generators may be written at once */
|
|
16
|
+
concurrency?: number;
|
|
17
|
+
rebuild?: boolean;
|
|
18
|
+
ephemeral?: boolean;
|
|
19
|
+
timeout?: number;
|
|
20
|
+
onImageBuild?: (tag: string) => void;
|
|
21
|
+
events?: Pick<CacheOptions, 'onStart' | 'onAttempt' | 'onReady' | 'onFail'>;
|
|
22
|
+
}
|
|
23
|
+
export interface Setup {
|
|
24
|
+
router: Router;
|
|
25
|
+
checks: Checks;
|
|
26
|
+
cache: Cache;
|
|
27
|
+
box: Box;
|
|
28
|
+
model: Model;
|
|
29
|
+
image: string;
|
|
30
|
+
root: string;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export declare function open(opts: SetupOptions): Promise<Setup>;
|
|
34
|
+
export { SpecError };
|
|
35
|
+
//# sourceMappingURL=setup.d.ts.map
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { credentialError, ensureHome, ensurePodmanReady, invalidError, KeyStore, paths, PROVIDERS, SHAPES, } from '@zenera/cli/lib';
|
|
4
|
+
import { createModel } from '@zenera/neo';
|
|
5
|
+
import { Box } from "./box.js";
|
|
6
|
+
import { Cache } from "./cache.js";
|
|
7
|
+
import { ensureImage } from "./image.js";
|
|
8
|
+
import { Router } from "./router.js";
|
|
9
|
+
import { loadSpecs, SpecError } from "./spec.js";
|
|
10
|
+
import { Checks } from "./validate.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Assembly
|
|
13
|
+
//
|
|
14
|
+
// The order here is the whole point and it is the same order `zen run` uses:
|
|
15
|
+
// credentials before anything that needs one, the container engine before the
|
|
16
|
+
// image, the image before the box, and the documents last — so the failure a
|
|
17
|
+
// user sees is the first thing that was actually wrong rather than whatever
|
|
18
|
+
// happened to be checked first.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* The model each provider gets when none is named. Every ref names its
|
|
22
|
+
* provider: the shorthand reads the first segment as a *provider name*, so a
|
|
23
|
+
* bare `gemini-3.5-flash` would be asked of OpenAI.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_MODEL = {
|
|
26
|
+
openai: 'openai:gpt-5.4-mini',
|
|
27
|
+
anthropic: 'anthropic:claude-sonnet-4-5',
|
|
28
|
+
google: 'google:gemini-3.5-flash',
|
|
29
|
+
vertex: 'vertex:gemini-3.5-flash',
|
|
30
|
+
openrouter: 'openrouter:inclusionai/ling-3.0-flash-fin:free',
|
|
31
|
+
};
|
|
32
|
+
export async function open(opts) {
|
|
33
|
+
if (opts.specs.length === 0) {
|
|
34
|
+
throw invalidError('no specification given', 'name one or more openapi/swagger files');
|
|
35
|
+
}
|
|
36
|
+
// Real environment variables win, exactly as they do for `zen`.
|
|
37
|
+
ensureHome();
|
|
38
|
+
const keys = await KeyStore.open();
|
|
39
|
+
keys.materialize();
|
|
40
|
+
const model = createModel(opts.model ?? defaultRef(keys));
|
|
41
|
+
const root = resolve(opts.cwd, opts.cache ?? paths.faker());
|
|
42
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
43
|
+
// Podman is asked about before the image is built, so a machine without a
|
|
44
|
+
// container engine says so instead of failing halfway through a build.
|
|
45
|
+
await ensurePodmanReady({ image: opts.image, yes: true });
|
|
46
|
+
const image = opts.image ?? (await ensureImage({ root, onBuild: opts.onImageBuild }));
|
|
47
|
+
const operations = await loadSpecs(opts.specs.map((s) => resolve(opts.cwd, s)));
|
|
48
|
+
if (operations.length === 0) {
|
|
49
|
+
throw invalidError('the specification declares no operations');
|
|
50
|
+
}
|
|
51
|
+
const router = new Router(operations);
|
|
52
|
+
const checks = new Checks();
|
|
53
|
+
const box = new Box({ root, image, timeout: opts.timeout });
|
|
54
|
+
await box.fresh();
|
|
55
|
+
const cache = new Cache({
|
|
56
|
+
box,
|
|
57
|
+
checks,
|
|
58
|
+
model,
|
|
59
|
+
attempts: opts.attempts,
|
|
60
|
+
concurrency: opts.concurrency,
|
|
61
|
+
rebuild: opts.rebuild,
|
|
62
|
+
ephemeral: opts.ephemeral,
|
|
63
|
+
...opts.events,
|
|
64
|
+
});
|
|
65
|
+
return { router, checks, cache, box, model, image, root, close: () => box.dispose() };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Presence, not liveness. `zen init` probes because it is writing a project
|
|
69
|
+
* that has to work later; this is about to make a call anyway, and the call
|
|
70
|
+
* itself is a better test than a round trip that costs the same.
|
|
71
|
+
*/
|
|
72
|
+
function defaultRef(keys) {
|
|
73
|
+
const provider = PROVIDERS.find((p) => process.env[SHAPES[p].env]) ??
|
|
74
|
+
PROVIDERS.find((p) => keys.active(p) !== undefined);
|
|
75
|
+
if (!provider) {
|
|
76
|
+
throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
|
|
77
|
+
}
|
|
78
|
+
return DEFAULT_MODEL[provider];
|
|
79
|
+
}
|
|
80
|
+
export { SpecError };
|
|
81
|
+
//# sourceMappingURL=setup.js.map
|
package/dist/spec.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { CliError } from '@zenera/cli/lib';
|
|
2
|
+
import { type Schema } from './schema.ts';
|
|
3
|
+
export declare const METHODS: readonly ["get", "put", "post", "delete", "patch", "head", "options"];
|
|
4
|
+
export type Method = (typeof METHODS)[number];
|
|
5
|
+
export type ParamIn = 'path' | 'query' | 'header' | 'cookie';
|
|
6
|
+
export interface ParamSpec {
|
|
7
|
+
name: string;
|
|
8
|
+
in: ParamIn;
|
|
9
|
+
required: boolean;
|
|
10
|
+
schema: Schema;
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Operation {
|
|
14
|
+
/** cache identity — a pure function of the operation's shape, see `keyOf` */
|
|
15
|
+
key: string;
|
|
16
|
+
/** the document it came from, for error messages */
|
|
17
|
+
source: string;
|
|
18
|
+
method: Method;
|
|
19
|
+
/** the template, `/users/{user_id}` */
|
|
20
|
+
path: string;
|
|
21
|
+
operationId: string;
|
|
22
|
+
summary?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
params: ParamSpec[];
|
|
25
|
+
requestBody?: {
|
|
26
|
+
required: boolean;
|
|
27
|
+
schema: Schema;
|
|
28
|
+
};
|
|
29
|
+
/** the response a call is answered with; `schema` absent means no body */
|
|
30
|
+
success: {
|
|
31
|
+
status: number;
|
|
32
|
+
schema?: Schema;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** A `CliError` so an unreadable document exits 3 wherever it is raised. */
|
|
36
|
+
export declare class SpecError extends CliError {
|
|
37
|
+
constructor(message: string, hint?: string);
|
|
38
|
+
}
|
|
39
|
+
export declare function loadSpecs(files: readonly string[]): Promise<Operation[]>;
|
|
40
|
+
export declare function loadSpec(file: string): Promise<Operation[]>;
|
|
41
|
+
//# sourceMappingURL=spec.d.ts.map
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
4
|
+
import { normalize } from "./schema.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Documents, flattened
|
|
7
|
+
//
|
|
8
|
+
// Everything below this file works on `Operation`, which is one method on one
|
|
9
|
+
// path with its schemas already dereferenced and already 2020-12. Which
|
|
10
|
+
// document it came from, and which of the three dialects that document was
|
|
11
|
+
// written in, stops mattering here.
|
|
12
|
+
//
|
|
13
|
+
// `dereference` is used rather than `bundle` on purpose: the generator is asked
|
|
14
|
+
// to produce a body for one operation, and it should see that operation's
|
|
15
|
+
// shapes rather than a pointer into a components section it was never shown.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
export const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'head', 'options'];
|
|
18
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
19
|
+
/** A `CliError` so an unreadable document exits 3 wherever it is raised. */
|
|
20
|
+
export class SpecError extends CliError {
|
|
21
|
+
constructor(message, hint) {
|
|
22
|
+
super(message, EXIT.invalid, hint);
|
|
23
|
+
this.name = 'SpecError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Loading
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
export async function loadSpecs(files) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
out.push(...(await loadSpec(file)));
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
export async function loadSpec(file) {
|
|
37
|
+
let doc;
|
|
38
|
+
try {
|
|
39
|
+
doc = (await SwaggerParser.dereference(file));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw new SpecError(`${file}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`, 'the document must be a readable OpenAPI 3.x or Swagger 2.0 file');
|
|
43
|
+
}
|
|
44
|
+
const dialect = dialectOf(doc);
|
|
45
|
+
const prefix = dialect === 'swagger-2.0' ? (doc.basePath ?? '') : '';
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const [template, item] of Object.entries(doc.paths ?? {})) {
|
|
48
|
+
if (!isObject(item)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Path-level parameters apply to every method on it, and an operation
|
|
52
|
+
// may override one by repeating its name and location.
|
|
53
|
+
const shared = params(item.parameters, dialect);
|
|
54
|
+
for (const method of METHODS) {
|
|
55
|
+
const op = item[method];
|
|
56
|
+
if (!isObject(op)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const path = join(prefix, template);
|
|
60
|
+
const merged = override(shared, params(op.parameters, dialect));
|
|
61
|
+
out.push(build({
|
|
62
|
+
source: file,
|
|
63
|
+
dialect,
|
|
64
|
+
method,
|
|
65
|
+
path,
|
|
66
|
+
op,
|
|
67
|
+
params: merged,
|
|
68
|
+
body: requestBody(op, dialect),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function dialectOf(doc) {
|
|
75
|
+
if (doc.swagger?.startsWith('2.')) {
|
|
76
|
+
return 'swagger-2.0';
|
|
77
|
+
}
|
|
78
|
+
if (doc.openapi?.startsWith('3.1')) {
|
|
79
|
+
return 'openapi-3.1';
|
|
80
|
+
}
|
|
81
|
+
if (doc.openapi?.startsWith('3.')) {
|
|
82
|
+
return 'openapi-3.0';
|
|
83
|
+
}
|
|
84
|
+
throw new SpecError('neither `swagger: 2.0` nor `openapi: 3.x` is declared');
|
|
85
|
+
}
|
|
86
|
+
function join(prefix, template) {
|
|
87
|
+
const base = prefix.replace(/\/+$/, '');
|
|
88
|
+
return `${base}${template.startsWith('/') ? '' : '/'}${template}` || '/';
|
|
89
|
+
}
|
|
90
|
+
function build(b) {
|
|
91
|
+
const success = successOf(b.op, b.dialect);
|
|
92
|
+
const operationId = typeof b.op.operationId === 'string' && b.op.operationId
|
|
93
|
+
? b.op.operationId
|
|
94
|
+
: synthesizeId(b.method, b.path);
|
|
95
|
+
const operation = {
|
|
96
|
+
key: '',
|
|
97
|
+
source: b.source,
|
|
98
|
+
method: b.method,
|
|
99
|
+
path: b.path,
|
|
100
|
+
operationId,
|
|
101
|
+
summary: typeof b.op.summary === 'string' ? b.op.summary : undefined,
|
|
102
|
+
description: typeof b.op.description === 'string' ? b.op.description : undefined,
|
|
103
|
+
params: b.params,
|
|
104
|
+
requestBody: b.body,
|
|
105
|
+
success,
|
|
106
|
+
};
|
|
107
|
+
return { ...operation, key: keyOf(operation) };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Cache identity. Everything that changes what a generator must produce is in
|
|
111
|
+
* here and nothing else is — not the file it came from, not its summary, not
|
|
112
|
+
* the order of its keys. Editing a spec therefore yields a new key and the old
|
|
113
|
+
* artefact is simply never looked up again, so there is no invalidation step
|
|
114
|
+
* to get wrong.
|
|
115
|
+
*/
|
|
116
|
+
function keyOf(op) {
|
|
117
|
+
const shape = {
|
|
118
|
+
method: op.method,
|
|
119
|
+
path: op.path,
|
|
120
|
+
operationId: op.operationId,
|
|
121
|
+
params: [...op.params]
|
|
122
|
+
.sort((a, b) => `${a.in}:${a.name}`.localeCompare(`${b.in}:${b.name}`))
|
|
123
|
+
.map((p) => [p.in, p.name, p.required, canonical(p.schema)]),
|
|
124
|
+
body: op.requestBody ? [op.requestBody.required, canonical(op.requestBody.schema)] : null,
|
|
125
|
+
success: [op.success.status, op.success.schema ? canonical(op.success.schema) : null],
|
|
126
|
+
};
|
|
127
|
+
return createHash('sha256').update(JSON.stringify(shape)).digest('hex').slice(0, 16);
|
|
128
|
+
}
|
|
129
|
+
/** Key order is a formatting accident; it must not change a cache key. */
|
|
130
|
+
function canonical(value) {
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
return value.map(canonical);
|
|
133
|
+
}
|
|
134
|
+
if (isObject(value)) {
|
|
135
|
+
return Object.keys(value)
|
|
136
|
+
.sort()
|
|
137
|
+
.map((k) => [k, canonical(value[k])]);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
function synthesizeId(method, path) {
|
|
142
|
+
const words = path
|
|
143
|
+
.split('/')
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.map((s) => s.replace(/[{}]/g, '').replace(/[^A-Za-z0-9]+/g, '_'));
|
|
146
|
+
return [method, ...words].join('_');
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Parameters
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
function params(raw, dialect) {
|
|
152
|
+
if (!Array.isArray(raw)) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const entry of raw) {
|
|
157
|
+
if (!isObject(entry) || typeof entry.name !== 'string') {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const where = entry.in;
|
|
161
|
+
if (where !== 'path' && where !== 'query' && where !== 'header' && where !== 'cookie') {
|
|
162
|
+
continue; // `body` and `formData` are handled as a request body
|
|
163
|
+
}
|
|
164
|
+
out.push({
|
|
165
|
+
name: entry.name,
|
|
166
|
+
in: where,
|
|
167
|
+
required: where === 'path' ? true : entry.required === true,
|
|
168
|
+
schema: normalize(paramSchema(entry, dialect), dialect),
|
|
169
|
+
description: typeof entry.description === 'string' ? entry.description : undefined,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Swagger 2.0 puts a non-body parameter's type keywords directly on the
|
|
176
|
+
* parameter object; OpenAPI 3 moved them under `schema`. Both end up as a
|
|
177
|
+
* schema, so the difference is confined to this function.
|
|
178
|
+
*/
|
|
179
|
+
function paramSchema(entry, dialect) {
|
|
180
|
+
if (dialect !== 'swagger-2.0') {
|
|
181
|
+
return isObject(entry.schema) ? entry.schema : {};
|
|
182
|
+
}
|
|
183
|
+
const { name: _name, in: _in, required: _required, description: _description, collectionFormat: _collectionFormat, allowEmptyValue: _allowEmptyValue, ...rest } = entry;
|
|
184
|
+
return rest;
|
|
185
|
+
}
|
|
186
|
+
/** Operation-level parameters win over path-level ones with the same identity. */
|
|
187
|
+
function override(base, own) {
|
|
188
|
+
const merged = new Map(base.map((p) => [`${p.in}:${p.name}`, p]));
|
|
189
|
+
for (const p of own) {
|
|
190
|
+
merged.set(`${p.in}:${p.name}`, p);
|
|
191
|
+
}
|
|
192
|
+
return [...merged.values()];
|
|
193
|
+
}
|
|
194
|
+
function requestBody(op, dialect) {
|
|
195
|
+
if (dialect === 'swagger-2.0') {
|
|
196
|
+
const list = Array.isArray(op.parameters) ? op.parameters : [];
|
|
197
|
+
const body = list.find((p) => isObject(p) && p.in === 'body');
|
|
198
|
+
if (!body || !isObject(body.schema)) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
return { required: body.required === true, schema: normalize(body.schema, dialect) };
|
|
202
|
+
}
|
|
203
|
+
const rb = op.requestBody;
|
|
204
|
+
if (!isObject(rb)) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
const schema = jsonContent(rb.content);
|
|
208
|
+
return schema
|
|
209
|
+
? { required: rb.required === true, schema: normalize(schema, dialect) }
|
|
210
|
+
: undefined;
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Responses
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
/**
|
|
216
|
+
* The response a call gets answered with: the lowest 2xx that carries a JSON
|
|
217
|
+
* body, or the lowest 2xx at all when none of them do. An operation whose
|
|
218
|
+
* success response has no JSON schema is answered `204` and never gets a
|
|
219
|
+
* generator — there is nothing for one to produce.
|
|
220
|
+
*/
|
|
221
|
+
function successOf(op, dialect) {
|
|
222
|
+
const responses = isObject(op.responses) ? op.responses : {};
|
|
223
|
+
const codes = Object.keys(responses)
|
|
224
|
+
.map(Number)
|
|
225
|
+
.filter((n) => Number.isInteger(n) && n >= 200 && n < 300)
|
|
226
|
+
.sort((a, b) => a - b);
|
|
227
|
+
for (const status of codes) {
|
|
228
|
+
const schema = responseSchema(responses[String(status)], dialect);
|
|
229
|
+
if (schema) {
|
|
230
|
+
return { status, schema: normalize(schema, dialect) };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { status: codes[0] ?? 204 };
|
|
234
|
+
}
|
|
235
|
+
function responseSchema(response, dialect) {
|
|
236
|
+
if (!isObject(response)) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
if (dialect === 'swagger-2.0') {
|
|
240
|
+
return isObject(response.schema) ? response.schema : undefined;
|
|
241
|
+
}
|
|
242
|
+
return jsonContent(response.content);
|
|
243
|
+
}
|
|
244
|
+
/** The first JSON media type a content map offers, `+json` suffixes included. */
|
|
245
|
+
function jsonContent(content) {
|
|
246
|
+
if (!isObject(content)) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
for (const [type, entry] of Object.entries(content)) {
|
|
250
|
+
const base = type.split(';')[0].trim();
|
|
251
|
+
if ((base === 'application/json' || base.endsWith('+json')) && isObject(entry)) {
|
|
252
|
+
return isObject(entry.schema) ? entry.schema : undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=spec.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ErrorObject, type ValidateFunction } from 'ajv/dist/2020.js';
|
|
2
|
+
import type { Operation } from './spec.ts';
|
|
3
|
+
export interface Issue {
|
|
4
|
+
where: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class Checks {
|
|
8
|
+
#private;
|
|
9
|
+
constructor();
|
|
10
|
+
for(operation: Operation): Compiled;
|
|
11
|
+
}
|
|
12
|
+
export interface Compiled {
|
|
13
|
+
path: ValidateFunction;
|
|
14
|
+
query: ValidateFunction;
|
|
15
|
+
body?: ValidateFunction;
|
|
16
|
+
bodyRequired: boolean;
|
|
17
|
+
response?: ValidateFunction;
|
|
18
|
+
}
|
|
19
|
+
export declare function issues(prefix: string, errors: ErrorObject[] | null | undefined): Issue[];
|
|
20
|
+
export declare function describeIssues(list: readonly Issue[]): string;
|
|
21
|
+
//# sourceMappingURL=validate.d.ts.map
|