@vxnsin/inkan 0.0.0-stage → 0.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 +281 -2
- package/assets/logo.svg +17 -0
- package/dist/app.d.ts +222 -0
- package/dist/app.js +487 -0
- package/dist/check.d.ts +28 -0
- package/dist/check.js +166 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +108 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/openapi.d.ts +12 -0
- package/dist/openapi.js +112 -0
- package/dist/pages.d.ts +10 -0
- package/dist/pages.js +286 -0
- package/dist/problem.d.ts +26 -0
- package/dist/problem.js +38 -0
- package/dist/router.d.ts +18 -0
- package/dist/router.js +84 -0
- package/dist/schema.d.ts +185 -0
- package/dist/schema.js +398 -0
- package/package.json +59 -4
package/dist/app.js
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { HttpProblem, problem } from "./problem.js";
|
|
3
|
+
import { Router } from "./router.js";
|
|
4
|
+
import { t } from "./schema.js";
|
|
5
|
+
import { buildOpenAPI } from "./openapi.js";
|
|
6
|
+
import { docsPage, inspectorPage } from "./pages.js";
|
|
7
|
+
import { runChecks } from "./check.js";
|
|
8
|
+
export class Reply {
|
|
9
|
+
status;
|
|
10
|
+
body;
|
|
11
|
+
headers;
|
|
12
|
+
constructor(status, body, headers = {}) {
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.body = body;
|
|
15
|
+
this.headers = headers;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Answers with a status that is not the default one, or with extra headers. */
|
|
19
|
+
export const reply = (status, body, headers) => new Reply(status, body, headers);
|
|
20
|
+
// ---------- route tables ----------
|
|
21
|
+
const joinPath = (a, b) => "/" + [a, b].join("/").split("/").filter(Boolean).join("/");
|
|
22
|
+
/** A set of routes that can be mounted under a prefix. */
|
|
23
|
+
export class Routes {
|
|
24
|
+
/** @internal */
|
|
25
|
+
_records = [];
|
|
26
|
+
/** @internal */
|
|
27
|
+
_use = [];
|
|
28
|
+
/** For a group, middleware that every route in it gets. Add it before mounting. */
|
|
29
|
+
use(...mw) {
|
|
30
|
+
this._use.push(...mw);
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
define(method, path, a, b) {
|
|
34
|
+
const [spec, handler] = typeof a === "function" ? [{}, a] : [a, b];
|
|
35
|
+
const s = spec;
|
|
36
|
+
this.add({ method, path, spec: s, handler: handler, use: s.use ?? [] });
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
/** @internal */
|
|
40
|
+
add(r) {
|
|
41
|
+
this._records.push({ ...r, use: [...this._use, ...r.use] });
|
|
42
|
+
}
|
|
43
|
+
get = ((p, a, b) => this.define("GET", p, a, b));
|
|
44
|
+
post = ((p, a, b) => this.define("POST", p, a, b));
|
|
45
|
+
put = ((p, a, b) => this.define("PUT", p, a, b));
|
|
46
|
+
patch = ((p, a, b) => this.define("PATCH", p, a, b));
|
|
47
|
+
delete = ((p, a, b) => this.define("DELETE", p, a, b));
|
|
48
|
+
mount(prefix, group) {
|
|
49
|
+
for (const r of group._records)
|
|
50
|
+
this.add({ ...r, path: joinPath(prefix, r.path) });
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const routes = () => new Routes();
|
|
55
|
+
const REDACT = new Set(["authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"]);
|
|
56
|
+
const clip = (s, n = 4096) => (s.length > n ? s.slice(0, n) + `… (${s.length - n} more)` : s);
|
|
57
|
+
const isLoopback = (addr) => !addr || addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
58
|
+
export class App extends Routes {
|
|
59
|
+
options;
|
|
60
|
+
dev;
|
|
61
|
+
router = new Router();
|
|
62
|
+
global = [];
|
|
63
|
+
log = [];
|
|
64
|
+
logId = 0;
|
|
65
|
+
spec;
|
|
66
|
+
constructor(options = {}) {
|
|
67
|
+
super();
|
|
68
|
+
this.dev = options.dev ?? process.env.NODE_ENV !== "production";
|
|
69
|
+
this.options = {
|
|
70
|
+
docs: "/docs",
|
|
71
|
+
openapi: "/openapi.json",
|
|
72
|
+
inspector: this.dev ? "/_inkan" : false,
|
|
73
|
+
validateResponses: this.dev,
|
|
74
|
+
bodyLimit: 1024 * 1024,
|
|
75
|
+
log: this.dev,
|
|
76
|
+
gracefulShutdown: true,
|
|
77
|
+
...options,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Middleware for every request, before routing. */
|
|
81
|
+
use(...mw) {
|
|
82
|
+
this.global.push(...mw);
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
/** @internal */
|
|
86
|
+
add(r) {
|
|
87
|
+
this.router.add(r.method, r.path, r);
|
|
88
|
+
this._records.push(r);
|
|
89
|
+
this.spec = undefined;
|
|
90
|
+
}
|
|
91
|
+
routes() {
|
|
92
|
+
return [...this._records];
|
|
93
|
+
}
|
|
94
|
+
openapi() {
|
|
95
|
+
return (this.spec ??= buildOpenAPI(this._records, this.options));
|
|
96
|
+
}
|
|
97
|
+
/** Runs every route's examples against the app, without a socket. */
|
|
98
|
+
check(opts) {
|
|
99
|
+
return runChecks(this, opts);
|
|
100
|
+
}
|
|
101
|
+
/** Sends a request straight into the app. Good for tests: no port, no network. */
|
|
102
|
+
async inject(opts) {
|
|
103
|
+
const headers = {};
|
|
104
|
+
for (const [k, v] of Object.entries(opts.headers ?? {}))
|
|
105
|
+
headers[k.toLowerCase()] = v;
|
|
106
|
+
let body;
|
|
107
|
+
if (opts.body !== undefined) {
|
|
108
|
+
if (Buffer.isBuffer(opts.body))
|
|
109
|
+
body = opts.body;
|
|
110
|
+
else if (typeof opts.body === "string")
|
|
111
|
+
body = Buffer.from(opts.body);
|
|
112
|
+
else {
|
|
113
|
+
body = Buffer.from(JSON.stringify(opts.body));
|
|
114
|
+
headers["content-type"] ??= "application/json";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const res = await this.handle({ method: (opts.method ?? "GET").toUpperCase(), url: opts.url, headers, body });
|
|
118
|
+
const text = res.body === undefined ? "" : res.body.toString();
|
|
119
|
+
const json = /json/.test(res.headers["content-type"] ?? "");
|
|
120
|
+
return { status: res.status, headers: res.headers, text, body: json && text ? JSON.parse(text) : text };
|
|
121
|
+
}
|
|
122
|
+
// ----- the request path -----
|
|
123
|
+
/** @internal */
|
|
124
|
+
async handle(raw) {
|
|
125
|
+
const started = performance.now();
|
|
126
|
+
const url = new URL(raw.url, "http://inkan.local");
|
|
127
|
+
const own = this.builtin(raw, url);
|
|
128
|
+
if (own)
|
|
129
|
+
return own;
|
|
130
|
+
const headers = {};
|
|
131
|
+
for (const [k, v] of Object.entries(raw.headers)) {
|
|
132
|
+
if (v !== undefined)
|
|
133
|
+
headers[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : v;
|
|
134
|
+
}
|
|
135
|
+
const out = { status: 0, headers: {} };
|
|
136
|
+
const notes = [];
|
|
137
|
+
let result;
|
|
138
|
+
const ctx = {
|
|
139
|
+
method: raw.method,
|
|
140
|
+
path: url.pathname,
|
|
141
|
+
url,
|
|
142
|
+
params: {},
|
|
143
|
+
query: queryObject(url.searchParams),
|
|
144
|
+
headers,
|
|
145
|
+
body: undefined,
|
|
146
|
+
state: {},
|
|
147
|
+
status: (code) => void (out.status = code),
|
|
148
|
+
header: (name, value) => void (out.headers[name.toLowerCase()] = value),
|
|
149
|
+
reply: (status, body, h) => new Reply(status, body, h),
|
|
150
|
+
req: raw.req,
|
|
151
|
+
res: raw.res,
|
|
152
|
+
};
|
|
153
|
+
let route;
|
|
154
|
+
const dispatch = async () => {
|
|
155
|
+
const m = this.router.match(raw.method, url.pathname);
|
|
156
|
+
if (m.kind === "none")
|
|
157
|
+
throw problem(404, "not-found", `No route for ${raw.method} ${url.pathname}`);
|
|
158
|
+
if (m.kind === "method") {
|
|
159
|
+
const p = problem(405, "method-not-allowed", `${url.pathname} does not take ${raw.method}`);
|
|
160
|
+
p.headers.allow = m.allow.join(", ");
|
|
161
|
+
throw p;
|
|
162
|
+
}
|
|
163
|
+
const r = m.route;
|
|
164
|
+
route = r;
|
|
165
|
+
ctx.route = { method: r.method, path: r.path };
|
|
166
|
+
validateInput(ctx, r, m.params, raw);
|
|
167
|
+
await compose(r.use, async () => {
|
|
168
|
+
result = await r.handler(ctx);
|
|
169
|
+
})(ctx);
|
|
170
|
+
};
|
|
171
|
+
let res;
|
|
172
|
+
try {
|
|
173
|
+
await compose(this.global, dispatch)(ctx);
|
|
174
|
+
res = this.respond(result, out, route, notes);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
res = this.fail(err, ctx, url.pathname, notes);
|
|
178
|
+
}
|
|
179
|
+
if (raw.method === "HEAD")
|
|
180
|
+
res.body = undefined;
|
|
181
|
+
const ms = Math.round((performance.now() - started) * 10) / 10;
|
|
182
|
+
if (this.options.log) {
|
|
183
|
+
const flag = notes.length ? ` ! ${notes.join("; ")}` : "";
|
|
184
|
+
console.log(` ${raw.method.padEnd(6)} ${url.pathname}${url.search} ${res.status} ${ms}ms${flag}`);
|
|
185
|
+
}
|
|
186
|
+
if (this.options.inspector)
|
|
187
|
+
this.remember(raw, url, res, route, ms, notes, headers);
|
|
188
|
+
return res;
|
|
189
|
+
}
|
|
190
|
+
respond(result, out, route, notes) {
|
|
191
|
+
const responses = route?.spec.response ?? {};
|
|
192
|
+
let status = out.status;
|
|
193
|
+
let body = result;
|
|
194
|
+
let headers = { ...out.headers };
|
|
195
|
+
if (result instanceof Reply) {
|
|
196
|
+
status = result.status;
|
|
197
|
+
body = result.body;
|
|
198
|
+
headers = { ...headers, ...lower(result.headers) };
|
|
199
|
+
}
|
|
200
|
+
if (!status) {
|
|
201
|
+
// No body means 204. Otherwise the first 2xx the contract lists, or 200.
|
|
202
|
+
const declared = Object.keys(responses).map(Number).filter((s) => s >= 200 && s < 300 && s !== 204).sort();
|
|
203
|
+
status = body === undefined ? 204 : (declared[0] ?? 200);
|
|
204
|
+
}
|
|
205
|
+
if (route && Object.keys(responses).length && status !== 204) {
|
|
206
|
+
const schema = contractFor(route, status);
|
|
207
|
+
if (!schema)
|
|
208
|
+
notes.push(`status ${status} is not in the contract`);
|
|
209
|
+
else if (this.options.validateResponses) {
|
|
210
|
+
const r = schema.safeParse(body);
|
|
211
|
+
if (!r.ok) {
|
|
212
|
+
notes.push("response broke the contract");
|
|
213
|
+
const lines = r.issues.map((i) => `${i.path || "(body)"} ${i.message}`).join("\n ");
|
|
214
|
+
console.error(`inkan: ${route.method} ${route.path} answered ${status} with a body that breaks its contract:\n ${lines}`);
|
|
215
|
+
throw problem(500, "response-contract", "The handler answered with a body that does not match its contract", {
|
|
216
|
+
errors: r.issues.map((i) => ({ in: "response", path: i.path, message: i.message })),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
body = r.value; // this also drops keys the contract does not list, so nothing leaks by accident
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return encode(status, body, headers);
|
|
223
|
+
}
|
|
224
|
+
fail(err, ctx, path, notes) {
|
|
225
|
+
let p;
|
|
226
|
+
if (err instanceof HttpProblem)
|
|
227
|
+
p = err;
|
|
228
|
+
else {
|
|
229
|
+
if (this.options.onError)
|
|
230
|
+
this.options.onError(err, ctx);
|
|
231
|
+
else
|
|
232
|
+
console.error(err);
|
|
233
|
+
const detail = this.dev && err instanceof Error ? err.message : "Something went wrong on our side";
|
|
234
|
+
p = problem(500, "internal", detail);
|
|
235
|
+
}
|
|
236
|
+
if (p.type === "validation")
|
|
237
|
+
notes.push("input broke the contract");
|
|
238
|
+
const body = { ...p.toJSON(), instance: path };
|
|
239
|
+
return {
|
|
240
|
+
status: p.status,
|
|
241
|
+
headers: { "content-type": "application/problem+json", ...lower(p.headers) },
|
|
242
|
+
body: JSON.stringify(body),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
builtin(raw, url) {
|
|
246
|
+
if (raw.method !== "GET" && raw.method !== "HEAD")
|
|
247
|
+
return;
|
|
248
|
+
const { docs, openapi, inspector } = this.options;
|
|
249
|
+
const p = url.pathname;
|
|
250
|
+
if (openapi && p === openapi)
|
|
251
|
+
return encode(200, this.openapi(), {});
|
|
252
|
+
if (docs && (p === docs || p === docs + "/")) {
|
|
253
|
+
const page = docsPage({ title: this.options.title ?? "API", specUrl: openapi || "", inspector: inspector || "" });
|
|
254
|
+
return encode(200, page, {
|
|
255
|
+
"content-type": "text/html; charset=utf-8",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (inspector && (p === inspector || p.startsWith(inspector + "/"))) {
|
|
259
|
+
if (!isLoopback(raw.remote))
|
|
260
|
+
return; // a plain 404 for everybody else
|
|
261
|
+
if (p === inspector + "/log.json") {
|
|
262
|
+
const since = Number(url.searchParams.get("since") ?? 0);
|
|
263
|
+
return encode(200, this.log.filter((e) => e.id > since), { "cache-control": "no-store" });
|
|
264
|
+
}
|
|
265
|
+
return encode(200, inspectorPage({ title: this.options.title ?? "API", base: inspector, docs: docs || "" }), {
|
|
266
|
+
"content-type": "text/html; charset=utf-8",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
remember(raw, url, res, route, ms, notes, headers) {
|
|
271
|
+
const shown = {};
|
|
272
|
+
for (const [k, v] of Object.entries(headers))
|
|
273
|
+
shown[k] = REDACT.has(k) ? "•••" : v;
|
|
274
|
+
this.log.push({
|
|
275
|
+
id: ++this.logId,
|
|
276
|
+
at: new Date().toISOString(),
|
|
277
|
+
method: raw.method,
|
|
278
|
+
path: url.pathname + url.search,
|
|
279
|
+
route: route?.path,
|
|
280
|
+
status: res.status,
|
|
281
|
+
ms,
|
|
282
|
+
notes,
|
|
283
|
+
request: { headers: shown, body: raw.body?.length ? clip(raw.body.toString()) : undefined },
|
|
284
|
+
response: { body: res.body === undefined ? undefined : clip(res.body.toString()) },
|
|
285
|
+
});
|
|
286
|
+
if (this.log.length > 200)
|
|
287
|
+
this.log.shift();
|
|
288
|
+
}
|
|
289
|
+
// ----- the socket -----
|
|
290
|
+
/** A plain Node request listener, for `http.createServer` or anything that takes one. */
|
|
291
|
+
get listener() {
|
|
292
|
+
return (req, res) => void this.serve(req, res);
|
|
293
|
+
}
|
|
294
|
+
async serve(req, res) {
|
|
295
|
+
const limit = this.options.bodyLimit;
|
|
296
|
+
const chunks = [];
|
|
297
|
+
let size = 0;
|
|
298
|
+
let tooLarge = Number(req.headers["content-length"] ?? 0) > limit;
|
|
299
|
+
if (!tooLarge && req.method !== "GET" && req.method !== "HEAD") {
|
|
300
|
+
for await (const chunk of req) {
|
|
301
|
+
size += chunk.length;
|
|
302
|
+
if (size > limit) {
|
|
303
|
+
tooLarge = true;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
chunks.push(chunk);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
let out;
|
|
310
|
+
if (tooLarge) {
|
|
311
|
+
const p = problem(413, "body-too-large", `Request bodies may be at most ${limit} bytes`);
|
|
312
|
+
out = {
|
|
313
|
+
status: 413,
|
|
314
|
+
headers: { "content-type": "application/problem+json", connection: "close" },
|
|
315
|
+
body: JSON.stringify({ ...p.toJSON(), instance: req.url }),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
out = await this.handle({
|
|
320
|
+
method: req.method ?? "GET",
|
|
321
|
+
url: req.url ?? "/",
|
|
322
|
+
headers: req.headers,
|
|
323
|
+
body: chunks.length ? Buffer.concat(chunks) : undefined,
|
|
324
|
+
remote: req.socket.remoteAddress,
|
|
325
|
+
req,
|
|
326
|
+
res,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
if (res.headersSent || res.writableEnded)
|
|
330
|
+
return; // a handler wrote to `res` itself
|
|
331
|
+
res.writeHead(out.status, out.headers);
|
|
332
|
+
res.end(out.body);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Starts listening. The port comes from the argument, then $PORT, then 3000,
|
|
336
|
+
* so it runs under warden, a PaaS or a container without changes.
|
|
337
|
+
*/
|
|
338
|
+
listen(port, host) {
|
|
339
|
+
const server = createServer(this.listener);
|
|
340
|
+
if (process.env.INKAN_NO_LISTEN)
|
|
341
|
+
return Promise.resolve(server); // the CLI loads the app only to read it
|
|
342
|
+
const p = port ?? (process.env.PORT ? Number(process.env.PORT) : 3000);
|
|
343
|
+
const h = host ?? process.env.HOST;
|
|
344
|
+
return new Promise((resolve, reject) => {
|
|
345
|
+
server.once("error", reject);
|
|
346
|
+
server.listen(p, h, () => {
|
|
347
|
+
server.off("error", reject);
|
|
348
|
+
if (this.options.log)
|
|
349
|
+
this.banner(server);
|
|
350
|
+
if (this.options.gracefulShutdown)
|
|
351
|
+
shutdownOnSignal(server);
|
|
352
|
+
resolve(server);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
banner(server) {
|
|
357
|
+
const addr = server.address();
|
|
358
|
+
const port = typeof addr === "object" && addr ? addr.port : addr;
|
|
359
|
+
const base = `http://localhost:${port}`;
|
|
360
|
+
const n = this._records.length;
|
|
361
|
+
const ex = this._records.reduce((s, r) => s + (r.spec.examples?.length ?? 0), 0);
|
|
362
|
+
const name = [this.options.title, this.options.version].filter(Boolean).join(" ");
|
|
363
|
+
const lines = [` 印 inkan${name ? " · " + name : ""}`, ` ├ ${base}`];
|
|
364
|
+
if (this.options.docs)
|
|
365
|
+
lines.push(` ├ docs ${base}${this.options.docs}`);
|
|
366
|
+
if (this.options.inspector)
|
|
367
|
+
lines.push(` ├ inspector ${base}${this.options.inspector}`);
|
|
368
|
+
lines.push(` └ ${n} route${n === 1 ? "" : "s"}, ${ex} example${ex === 1 ? "" : "s"}`);
|
|
369
|
+
console.log("\n" + lines.join("\n") + "\n");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
export const inkan = (options) => new App(options);
|
|
373
|
+
/**
|
|
374
|
+
* The schema a status answers with. A route that takes input also promises
|
|
375
|
+
* a 400 problem for input that breaks the contract, without saying so.
|
|
376
|
+
*/
|
|
377
|
+
export function contractFor(route, status) {
|
|
378
|
+
const { spec } = route;
|
|
379
|
+
const declared = spec.response?.[status];
|
|
380
|
+
if (declared)
|
|
381
|
+
return declared;
|
|
382
|
+
if (status === 400 && (spec.params || spec.query || spec.headers || spec.body))
|
|
383
|
+
return t.problem();
|
|
384
|
+
}
|
|
385
|
+
// ---------- helpers ----------
|
|
386
|
+
function compose(mw, last) {
|
|
387
|
+
return async (ctx) => {
|
|
388
|
+
let index = -1;
|
|
389
|
+
const run = async (i) => {
|
|
390
|
+
if (i <= index)
|
|
391
|
+
throw new Error("next() was called twice in one middleware");
|
|
392
|
+
index = i;
|
|
393
|
+
if (i === mw.length)
|
|
394
|
+
return last();
|
|
395
|
+
await mw[i](ctx, () => run(i + 1));
|
|
396
|
+
};
|
|
397
|
+
await run(0);
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function queryObject(sp) {
|
|
401
|
+
const o = {};
|
|
402
|
+
for (const [k, v] of sp) {
|
|
403
|
+
const prev = o[k];
|
|
404
|
+
o[k] = prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v];
|
|
405
|
+
}
|
|
406
|
+
return o;
|
|
407
|
+
}
|
|
408
|
+
const lower = (h) => Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]));
|
|
409
|
+
function readBody(raw, contentType) {
|
|
410
|
+
if (!raw.body?.length)
|
|
411
|
+
return { kind: "none", value: undefined };
|
|
412
|
+
const ct = contentType.split(";")[0].trim().toLowerCase();
|
|
413
|
+
const text = () => raw.body.toString("utf8");
|
|
414
|
+
if (ct === "application/json" || ct.endsWith("+json")) {
|
|
415
|
+
try {
|
|
416
|
+
return { kind: "json", value: JSON.parse(text()) };
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
throw problem(400, "invalid-json", `The body is not valid JSON: ${e.message}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (ct === "application/x-www-form-urlencoded")
|
|
423
|
+
return { kind: "form", value: queryObject(new URLSearchParams(text())) };
|
|
424
|
+
if (ct.startsWith("text/") || ct === "")
|
|
425
|
+
return { kind: "text", value: text() };
|
|
426
|
+
return { kind: "binary", value: raw.body };
|
|
427
|
+
}
|
|
428
|
+
function validateInput(ctx, route, params, raw) {
|
|
429
|
+
const { spec } = route;
|
|
430
|
+
const errors = [];
|
|
431
|
+
const take = (where, schema, value, coerce) => {
|
|
432
|
+
if (!schema)
|
|
433
|
+
return value;
|
|
434
|
+
const r = schema.safeParse(value, { coerce });
|
|
435
|
+
if (r.ok)
|
|
436
|
+
return r.value;
|
|
437
|
+
errors.push(...r.issues.map((i) => ({ in: where, path: i.path, message: i.message })));
|
|
438
|
+
return value;
|
|
439
|
+
};
|
|
440
|
+
// read before a header schema strips the headers it does not list
|
|
441
|
+
const contentType = ctx.headers["content-type"] ?? "";
|
|
442
|
+
ctx.params = take("params", spec.params, params, true);
|
|
443
|
+
ctx.query = take("query", spec.query, ctx.query, true);
|
|
444
|
+
ctx.headers = take("headers", spec.headers, ctx.headers, true);
|
|
445
|
+
const body = readBody(raw, contentType);
|
|
446
|
+
if (spec.body && (body.kind === "binary" || body.kind === "text")) {
|
|
447
|
+
throw problem(415, "unsupported-media-type", "Send the body as application/json or application/x-www-form-urlencoded");
|
|
448
|
+
}
|
|
449
|
+
ctx.body = take("body", spec.body, body.value, body.kind === "form");
|
|
450
|
+
if (errors.length) {
|
|
451
|
+
const where = [...new Set(errors.map((e) => e.in))].join(" and ");
|
|
452
|
+
throw new HttpProblem(400, "validation", `The ${where} does not match the contract`, { errors });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function encode(status, body, headers) {
|
|
456
|
+
const h = { ...headers };
|
|
457
|
+
if (body === undefined || body === null || status === 204 || status === 304) {
|
|
458
|
+
return { status, headers: h, body: undefined };
|
|
459
|
+
}
|
|
460
|
+
if (typeof body === "string") {
|
|
461
|
+
h["content-type"] ??= "text/plain; charset=utf-8";
|
|
462
|
+
return { status, headers: h, body };
|
|
463
|
+
}
|
|
464
|
+
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
|
|
465
|
+
h["content-type"] ??= "application/octet-stream";
|
|
466
|
+
return { status, headers: h, body: Buffer.from(body) };
|
|
467
|
+
}
|
|
468
|
+
h["content-type"] ??= "application/json; charset=utf-8";
|
|
469
|
+
return { status, headers: h, body: JSON.stringify(body) };
|
|
470
|
+
}
|
|
471
|
+
let shuttingDown = false;
|
|
472
|
+
function shutdownOnSignal(server) {
|
|
473
|
+
const stop = (signal) => {
|
|
474
|
+
if (shuttingDown)
|
|
475
|
+
process.exit(1); // a second ctrl+c means now
|
|
476
|
+
shuttingDown = true;
|
|
477
|
+
console.log(`\n ${signal}: finishing open requests…`);
|
|
478
|
+
server.close(() => process.exit(0));
|
|
479
|
+
server.closeIdleConnections();
|
|
480
|
+
setTimeout(() => {
|
|
481
|
+
server.closeAllConnections();
|
|
482
|
+
process.exit(0);
|
|
483
|
+
}, 10_000).unref();
|
|
484
|
+
};
|
|
485
|
+
process.once("SIGINT", () => stop("SIGINT"));
|
|
486
|
+
process.once("SIGTERM", () => stop("SIGTERM"));
|
|
487
|
+
}
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type App } from "./app.ts";
|
|
2
|
+
export type CheckOptions = {
|
|
3
|
+
/** Runs before every example, e.g. to reset an in-memory store. */
|
|
4
|
+
beforeEach?: () => unknown;
|
|
5
|
+
/** Only routes whose `METHOD /path` contains this text. */
|
|
6
|
+
only?: string;
|
|
7
|
+
};
|
|
8
|
+
export type CheckResult = {
|
|
9
|
+
method: string;
|
|
10
|
+
path: string;
|
|
11
|
+
example: string;
|
|
12
|
+
ok: boolean;
|
|
13
|
+
status: number;
|
|
14
|
+
ms: number;
|
|
15
|
+
problems: string[];
|
|
16
|
+
};
|
|
17
|
+
export type CheckReport = {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
passed: number;
|
|
20
|
+
failed: number;
|
|
21
|
+
results: CheckResult[];
|
|
22
|
+
/** Routes without a single example: nothing holds them to their contract. */
|
|
23
|
+
unchecked: string[];
|
|
24
|
+
};
|
|
25
|
+
/** Is everything in `expected` also in `actual`? Returns where it is not. */
|
|
26
|
+
export declare function partialMatch(expected: unknown, actual: unknown, path?: string): string | undefined;
|
|
27
|
+
export declare function runChecks(app: App, opts?: CheckOptions): Promise<CheckReport>;
|
|
28
|
+
export declare function formatReport(report: CheckReport, title?: string, color?: boolean): string;
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Every example in a route is also a test. `check` sends each one into the
|
|
2
|
+
// app and holds the answer against the contract: the status, the response
|
|
3
|
+
// schema and whatever the example says it `expect`s.
|
|
4
|
+
import { contractFor } from "./app.js";
|
|
5
|
+
function fill(path, params = {}) {
|
|
6
|
+
return path
|
|
7
|
+
.split("/")
|
|
8
|
+
.map((seg) => {
|
|
9
|
+
if (!seg.startsWith(":") && !seg.startsWith("*"))
|
|
10
|
+
return seg;
|
|
11
|
+
const name = seg.slice(1) || "rest";
|
|
12
|
+
const v = params[name];
|
|
13
|
+
if (v === undefined)
|
|
14
|
+
throw new Error(`the example has no value for ${seg}`);
|
|
15
|
+
return seg.startsWith("*") ? String(v) : encodeURIComponent(String(v));
|
|
16
|
+
})
|
|
17
|
+
.join("/");
|
|
18
|
+
}
|
|
19
|
+
function queryString(q = {}) {
|
|
20
|
+
const sp = new URLSearchParams();
|
|
21
|
+
for (const [k, v] of Object.entries(q)) {
|
|
22
|
+
for (const x of Array.isArray(v) ? v : [v])
|
|
23
|
+
if (x !== undefined)
|
|
24
|
+
sp.append(k, String(x));
|
|
25
|
+
}
|
|
26
|
+
const s = sp.toString();
|
|
27
|
+
return s ? `?${s}` : "";
|
|
28
|
+
}
|
|
29
|
+
/** Is everything in `expected` also in `actual`? Returns where it is not. */
|
|
30
|
+
export function partialMatch(expected, actual, path = "body") {
|
|
31
|
+
if (expected !== null && typeof expected === "object") {
|
|
32
|
+
if (Array.isArray(expected)) {
|
|
33
|
+
if (!Array.isArray(actual))
|
|
34
|
+
return `${path}: expected an array, got ${JSON.stringify(actual)}`;
|
|
35
|
+
if (expected.length !== actual.length)
|
|
36
|
+
return `${path}: expected ${expected.length} items, got ${actual.length}`;
|
|
37
|
+
for (const [i, x] of expected.entries()) {
|
|
38
|
+
const miss = partialMatch(x, actual[i], `${path}[${i}]`);
|
|
39
|
+
if (miss)
|
|
40
|
+
return miss;
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (actual === null || typeof actual !== "object")
|
|
45
|
+
return `${path}: expected an object, got ${JSON.stringify(actual)}`;
|
|
46
|
+
for (const [k, x] of Object.entries(expected)) {
|
|
47
|
+
const miss = partialMatch(x, actual[k], `${path}.${k}`);
|
|
48
|
+
if (miss)
|
|
49
|
+
return miss;
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!Object.is(expected, actual))
|
|
54
|
+
return `${path}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`;
|
|
55
|
+
}
|
|
56
|
+
function expectedStatus(r, ex) {
|
|
57
|
+
if (ex.status !== undefined)
|
|
58
|
+
return ex.status;
|
|
59
|
+
const ok = Object.keys(r.spec.response ?? {}).map(Number).filter((s) => s >= 200 && s < 300).sort();
|
|
60
|
+
return ok[0];
|
|
61
|
+
}
|
|
62
|
+
async function runOne(app, r, ex, i) {
|
|
63
|
+
const name = ex.name ?? `example ${i + 1}`;
|
|
64
|
+
const started = performance.now();
|
|
65
|
+
const problems = [];
|
|
66
|
+
let status = 0;
|
|
67
|
+
try {
|
|
68
|
+
const res = await app.inject({
|
|
69
|
+
method: r.method,
|
|
70
|
+
url: fill(r.path, ex.params) + queryString(ex.query),
|
|
71
|
+
headers: ex.headers,
|
|
72
|
+
body: ex.body,
|
|
73
|
+
});
|
|
74
|
+
status = res.status;
|
|
75
|
+
const want = expectedStatus(r, ex);
|
|
76
|
+
if (want !== undefined ? status !== want : status >= 300) {
|
|
77
|
+
problems.push(`answered ${status}, expected ${want ?? "a 2xx"}`);
|
|
78
|
+
if (res.body?.detail)
|
|
79
|
+
problems.push(` ${res.body.type}: ${res.body.detail}`);
|
|
80
|
+
for (const e of res.body?.errors ?? [])
|
|
81
|
+
problems.push(` ${e.in} ${e.path || ""} ${e.message}`.replace(/\s+/g, " "));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const declared = Object.keys(r.spec.response ?? {}).length > 0;
|
|
85
|
+
const schema = contractFor(r, status);
|
|
86
|
+
if (status === 204) {
|
|
87
|
+
if (res.text)
|
|
88
|
+
problems.push("answered 204 with a body");
|
|
89
|
+
}
|
|
90
|
+
else if (schema) {
|
|
91
|
+
const v = schema.safeParse(res.body === "" ? undefined : res.body);
|
|
92
|
+
if (!v.ok)
|
|
93
|
+
for (const issue of v.issues)
|
|
94
|
+
problems.push(`response ${issue.path || "(body)"} ${issue.message}`);
|
|
95
|
+
}
|
|
96
|
+
else if (declared) {
|
|
97
|
+
problems.push(`status ${status} is not in the contract`);
|
|
98
|
+
}
|
|
99
|
+
if (ex.expect !== undefined) {
|
|
100
|
+
const miss = partialMatch(ex.expect, res.body);
|
|
101
|
+
if (miss)
|
|
102
|
+
problems.push(miss);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
problems.push(err.message);
|
|
108
|
+
}
|
|
109
|
+
const ms = Math.round((performance.now() - started) * 10) / 10;
|
|
110
|
+
return { method: r.method, path: r.path, example: name, ok: problems.length === 0, status, ms, problems };
|
|
111
|
+
}
|
|
112
|
+
export async function runChecks(app, opts = {}) {
|
|
113
|
+
const results = [];
|
|
114
|
+
const unchecked = [];
|
|
115
|
+
const log = app.options.log;
|
|
116
|
+
app.options.log = false;
|
|
117
|
+
try {
|
|
118
|
+
for (const r of app.routes()) {
|
|
119
|
+
const label = `${r.method} ${r.path}`;
|
|
120
|
+
if (opts.only && !label.includes(opts.only))
|
|
121
|
+
continue;
|
|
122
|
+
const examples = r.spec.examples ?? [];
|
|
123
|
+
if (!examples.length)
|
|
124
|
+
unchecked.push(label);
|
|
125
|
+
for (const [i, ex] of examples.entries()) {
|
|
126
|
+
await opts.beforeEach?.();
|
|
127
|
+
results.push(await runOne(app, r, ex, i));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
app.options.log = log;
|
|
133
|
+
}
|
|
134
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
135
|
+
return { ok: failed === 0, passed: results.length - failed, failed, results, unchecked };
|
|
136
|
+
}
|
|
137
|
+
export function formatReport(report, title = "", color = false) {
|
|
138
|
+
const c = (code, s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
139
|
+
const lines = ["", ` ${c("31", "印")} inkan check${title ? " · " + title : ""}`, ""];
|
|
140
|
+
let last = "";
|
|
141
|
+
for (const r of report.results) {
|
|
142
|
+
const route = `${r.method} ${r.path}`;
|
|
143
|
+
if (route !== last) {
|
|
144
|
+
lines.push(` ${c("1", r.method.padEnd(6))} ${r.path}`);
|
|
145
|
+
last = route;
|
|
146
|
+
}
|
|
147
|
+
const mark = r.ok ? c("32", "✓") : c("31", "✗");
|
|
148
|
+
lines.push(` ${mark} ${r.example.padEnd(34)} ${String(r.status || "---").padEnd(4)} ${c("2", r.ms + "ms")}`);
|
|
149
|
+
for (const p of r.problems)
|
|
150
|
+
lines.push(` ${c("31", p)}`);
|
|
151
|
+
}
|
|
152
|
+
if (report.unchecked.length) {
|
|
153
|
+
lines.push("", ` ${c("33", "no examples, so nothing holds them to their contract:")}`);
|
|
154
|
+
for (const u of report.unchecked)
|
|
155
|
+
lines.push(` · ${u}`);
|
|
156
|
+
}
|
|
157
|
+
const total = report.results.length;
|
|
158
|
+
const summary = [
|
|
159
|
+
`${total} example${total === 1 ? "" : "s"}`,
|
|
160
|
+
c("32", `${report.passed} sealed`),
|
|
161
|
+
report.failed ? c("31", `${report.failed} broken`) : "",
|
|
162
|
+
report.unchecked.length ? c("33", `${report.unchecked.length} unchecked`) : "",
|
|
163
|
+
].filter(Boolean);
|
|
164
|
+
lines.push("", " " + summary.join(" · "), "");
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
package/dist/cli.d.ts
ADDED