@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/cli.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// inkan check | openapi | routes <entry>
|
|
3
|
+
// Loads the module that builds your app (listen() stays quiet while it does)
|
|
4
|
+
// and reads it, without opening a port.
|
|
5
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
6
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
7
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
8
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
return path;
|
|
12
|
+
};
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { formatReport } from "./check.js";
|
|
17
|
+
const HELP = `
|
|
18
|
+
印 inkan
|
|
19
|
+
|
|
20
|
+
inkan check <entry> [--only <text>] [--json] run every example against its contract
|
|
21
|
+
inkan openapi <entry> [-o <file>] write the OpenAPI 3.1 document
|
|
22
|
+
inkan routes <entry> list the routes
|
|
23
|
+
|
|
24
|
+
<entry> is the file that builds the app and exports it,
|
|
25
|
+
as \`export default app\` or \`export const app\`.
|
|
26
|
+
It may also export \`beforeEach\`, which check runs before every example.
|
|
27
|
+
`;
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const command = args.shift();
|
|
30
|
+
const option = (...names) => {
|
|
31
|
+
const i = args.findIndex((a) => names.includes(a));
|
|
32
|
+
if (i < 0)
|
|
33
|
+
return undefined;
|
|
34
|
+
return args.splice(i, 2)[1];
|
|
35
|
+
};
|
|
36
|
+
const has = (name) => {
|
|
37
|
+
const i = args.indexOf(name);
|
|
38
|
+
if (i >= 0)
|
|
39
|
+
args.splice(i, 1);
|
|
40
|
+
return i >= 0;
|
|
41
|
+
};
|
|
42
|
+
function fail(msg) {
|
|
43
|
+
console.error(`\n inkan: ${msg}\n`);
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
async function load(entry) {
|
|
47
|
+
if (!entry)
|
|
48
|
+
fail("Which file builds the app? For example: inkan check src/app.ts");
|
|
49
|
+
process.env.INKAN_NO_LISTEN = "1";
|
|
50
|
+
let mod;
|
|
51
|
+
try {
|
|
52
|
+
mod = await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(entry)).href));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (entry.endsWith(".ts") && err.code === "ERR_UNKNOWN_FILE_EXTENSION") {
|
|
56
|
+
fail(`Node ${process.versions.node} cannot load .ts files by itself. Use Node 22.18 or newer, or point at the built .js file.`);
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
const app = (mod.default ?? mod.app);
|
|
61
|
+
if (!app || typeof app.check !== "function")
|
|
62
|
+
fail(`${entry} has to export the app, as \`export default app\` or \`export const app\`.`);
|
|
63
|
+
return { app, beforeEach: mod.beforeEach };
|
|
64
|
+
}
|
|
65
|
+
const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
66
|
+
switch (command) {
|
|
67
|
+
case "check": {
|
|
68
|
+
const only = option("--only");
|
|
69
|
+
const json = has("--json");
|
|
70
|
+
const { app, beforeEach } = await load(args[0]);
|
|
71
|
+
const report = await app.check({ only, beforeEach });
|
|
72
|
+
if (json)
|
|
73
|
+
console.log(JSON.stringify(report, null, 2));
|
|
74
|
+
else
|
|
75
|
+
console.log(formatReport(report, [app.options.title, app.options.version].filter(Boolean).join(" "), color));
|
|
76
|
+
process.exit(report.ok ? 0 : 1);
|
|
77
|
+
}
|
|
78
|
+
case "openapi": {
|
|
79
|
+
const out = option("--out", "-o");
|
|
80
|
+
const { app } = await load(args[0]);
|
|
81
|
+
const doc = JSON.stringify(app.openapi(), null, 2) + "\n";
|
|
82
|
+
if (out) {
|
|
83
|
+
writeFileSync(out, doc);
|
|
84
|
+
console.log(` wrote ${out}`);
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
process.stdout.write(doc);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
case "routes": {
|
|
91
|
+
const { app } = await load(args[0]);
|
|
92
|
+
for (const r of app.routes()) {
|
|
93
|
+
const n = r.spec.examples?.length ?? 0;
|
|
94
|
+
const tail = [r.spec.summary, n ? `${n} example${n === 1 ? "" : "s"}` : "no examples"].filter(Boolean).join(" · ");
|
|
95
|
+
console.log(` ${r.method.padEnd(7)} ${r.path.padEnd(32)} ${tail}`);
|
|
96
|
+
}
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
case "-v":
|
|
100
|
+
case "--version": {
|
|
101
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
102
|
+
console.log(pkg.version);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
console.log(HELP);
|
|
107
|
+
process.exit(!command || ["help", "--help", "-h"].includes(command) ? 0 : 2);
|
|
108
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { inkan, App, Routes, routes, reply, Reply } from "./app.ts";
|
|
2
|
+
export type { AppOptions, Context, Example, Handler, InjectOptions, InjectResponse, LogEntry, Middleware, PathParams, Responses, RouteRecord, RouteSpec, } from "./app.ts";
|
|
3
|
+
export { t, Schema, ValidationError } from "./schema.ts";
|
|
4
|
+
export type { Infer, Issue, JsonSchema, SafeResult } from "./schema.ts";
|
|
5
|
+
export { problem, HttpProblem } from "./problem.ts";
|
|
6
|
+
export type { ProblemBody } from "./problem.ts";
|
|
7
|
+
export { buildOpenAPI } from "./openapi.ts";
|
|
8
|
+
export { formatReport, partialMatch } from "./check.ts";
|
|
9
|
+
export type { CheckOptions, CheckReport, CheckResult } from "./check.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { inkan, App, Routes, routes, reply, Reply } from "./app.js";
|
|
2
|
+
export { t, Schema, ValidationError } from "./schema.js";
|
|
3
|
+
export { problem, HttpProblem } from "./problem.js";
|
|
4
|
+
export { buildOpenAPI } from "./openapi.js";
|
|
5
|
+
export { formatReport, partialMatch } from "./check.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RouteRecord } from "./app.ts";
|
|
2
|
+
export type OpenAPIInfo = {
|
|
3
|
+
title?: string;
|
|
4
|
+
version?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
servers?: {
|
|
7
|
+
url: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}[];
|
|
10
|
+
};
|
|
11
|
+
export declare const toOpenAPIPath: (path: string) => string;
|
|
12
|
+
export declare function buildOpenAPI(records: RouteRecord[], info?: OpenAPIInfo): Record<string, unknown>;
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Writes an OpenAPI 3.1 document from the routes. Nothing is annotated twice:
|
|
2
|
+
// the schemas that validate a request are the ones that describe it.
|
|
3
|
+
import { STATUS_CODES } from "node:http";
|
|
4
|
+
import { ObjectSchema, t } from "./schema.js";
|
|
5
|
+
export const toOpenAPIPath = (path) => path.replace(/:(\w+)/g, "{$1}").replace(/\*(\w*)$/, (_, n) => `{${n || "rest"}}`);
|
|
6
|
+
const operationId = (r) => r.method.toLowerCase() +
|
|
7
|
+
r.path
|
|
8
|
+
.split("/")
|
|
9
|
+
.filter(Boolean)
|
|
10
|
+
.map((s) => {
|
|
11
|
+
const by = s.startsWith(":") || s.startsWith("*");
|
|
12
|
+
const word = s.replace(/^[:*]/, "").replace(/[^a-zA-Z0-9]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""));
|
|
13
|
+
return (by ? "By" : "") + word.charAt(0).toUpperCase() + word.slice(1);
|
|
14
|
+
})
|
|
15
|
+
.join("");
|
|
16
|
+
function parameters(where, schema, ctx, r) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const key = where === "path" ? "params" : where === "header" ? "headers" : "query";
|
|
19
|
+
if (schema instanceof ObjectSchema) {
|
|
20
|
+
for (const [name, s] of Object.entries(schema.shape)) {
|
|
21
|
+
const p = {
|
|
22
|
+
name,
|
|
23
|
+
in: where,
|
|
24
|
+
required: where === "path" || (!s.meta.optional && !s.meta.hasDefault),
|
|
25
|
+
schema: s._schema(ctx),
|
|
26
|
+
};
|
|
27
|
+
if (s.meta.description)
|
|
28
|
+
p.description = s.meta.description;
|
|
29
|
+
const examples = (r.spec.examples ?? [])
|
|
30
|
+
.map((e, i) => [e.name ?? `example${i + 1}`, e[key]?.[name]])
|
|
31
|
+
.filter(([, v]) => v !== undefined);
|
|
32
|
+
if (examples.length)
|
|
33
|
+
p.examples = Object.fromEntries(examples.map(([n, value]) => [n, { value }]));
|
|
34
|
+
out.push(p);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (where === "path") {
|
|
38
|
+
for (const m of r.path.matchAll(/[:*](\w*)/g)) {
|
|
39
|
+
out.push({ name: m[1] || "rest", in: "path", required: true, schema: { type: "string" } });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
export function buildOpenAPI(records, info = {}) {
|
|
45
|
+
const ctx = { components: new Map() };
|
|
46
|
+
const paths = {};
|
|
47
|
+
const problemRef = () => t.problem()._schema(ctx);
|
|
48
|
+
for (const r of records) {
|
|
49
|
+
const { spec } = r;
|
|
50
|
+
if (spec.hidden)
|
|
51
|
+
continue;
|
|
52
|
+
const op = { operationId: spec.operationId ?? operationId(r) };
|
|
53
|
+
if (spec.summary)
|
|
54
|
+
op.summary = spec.summary;
|
|
55
|
+
if (spec.description)
|
|
56
|
+
op.description = spec.description;
|
|
57
|
+
if (spec.tags)
|
|
58
|
+
op.tags = spec.tags;
|
|
59
|
+
if (spec.deprecated)
|
|
60
|
+
op.deprecated = true;
|
|
61
|
+
const params = [
|
|
62
|
+
...parameters("path", spec.params, ctx, r),
|
|
63
|
+
...parameters("query", spec.query, ctx, r),
|
|
64
|
+
...parameters("header", spec.headers, ctx, r),
|
|
65
|
+
];
|
|
66
|
+
if (params.length)
|
|
67
|
+
op.parameters = params;
|
|
68
|
+
if (spec.body) {
|
|
69
|
+
const examples = (spec.examples ?? []).filter((e) => e.body !== undefined);
|
|
70
|
+
const media = { schema: spec.body._schema(ctx) };
|
|
71
|
+
if (examples.length) {
|
|
72
|
+
media.examples = Object.fromEntries(examples.map((e, i) => [e.name ?? `example${i + 1}`, { value: e.body }]));
|
|
73
|
+
}
|
|
74
|
+
op.requestBody = { required: !spec.body.meta.optional, content: { "application/json": media } };
|
|
75
|
+
}
|
|
76
|
+
const responses = {};
|
|
77
|
+
for (const [status, schema] of Object.entries(spec.response ?? {})) {
|
|
78
|
+
const code = Number(status);
|
|
79
|
+
const res = { description: schema.meta.description ?? STATUS_CODES[code] ?? "" };
|
|
80
|
+
if (code !== 204) {
|
|
81
|
+
res.content = { [code >= 400 ? "application/problem+json" : "application/json"]: { schema: schema._schema(ctx) } };
|
|
82
|
+
}
|
|
83
|
+
responses[status] = res;
|
|
84
|
+
}
|
|
85
|
+
if ((spec.params || spec.query || spec.headers || spec.body) && !responses["400"]) {
|
|
86
|
+
responses["400"] = {
|
|
87
|
+
description: "The input does not match the contract",
|
|
88
|
+
content: { "application/problem+json": { schema: problemRef() } },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!Object.keys(responses).length)
|
|
92
|
+
responses["200"] = { description: "OK" };
|
|
93
|
+
op.responses = responses;
|
|
94
|
+
if (spec.examples?.length)
|
|
95
|
+
op["x-inkan-examples"] = spec.examples;
|
|
96
|
+
(paths[toOpenAPIPath(r.path)] ??= {})[r.method.toLowerCase()] = op;
|
|
97
|
+
}
|
|
98
|
+
const doc = {
|
|
99
|
+
openapi: "3.1.0",
|
|
100
|
+
info: {
|
|
101
|
+
title: info.title ?? "API",
|
|
102
|
+
version: info.version ?? "0.0.0",
|
|
103
|
+
...(info.description ? { description: info.description } : {}),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
if (info.servers)
|
|
107
|
+
doc.servers = info.servers;
|
|
108
|
+
doc.paths = paths;
|
|
109
|
+
if (ctx.components.size)
|
|
110
|
+
doc.components = { schemas: Object.fromEntries(ctx.components) };
|
|
111
|
+
return doc;
|
|
112
|
+
}
|
package/dist/pages.d.ts
ADDED
package/dist/pages.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// The two pages inkan serves itself: the docs and the request inspector.
|
|
2
|
+
// Plain HTML, CSS and JS in one string each. No CDN, nothing to fetch but the
|
|
3
|
+
// app's own JSON, so they work offline and behind any firewall.
|
|
4
|
+
const css = /* css */ `
|
|
5
|
+
:root{--paper:#f6f0e4;--paper-2:#ede3cf;--card:#fbf7ef;--ink:#2b2420;--ink-2:#74675b;--line:#d8c9b0;
|
|
6
|
+
--seal:#c4381f;--seal-soft:#f4d8cc;--ok:#3d7a4b;--warn:#a26d12;--get:#3d7a4b;--post:#2e5e9c;--put:#a26d12;
|
|
7
|
+
--patch:#76509b;--delete:#c4381f;--mono:ui-monospace,"Cascadia Code","JetBrains Mono",Menlo,Consolas,monospace;
|
|
8
|
+
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color-scheme:light}
|
|
9
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--paper:#1a1614;--paper-2:#231e1b;--card:#201b18;
|
|
10
|
+
--ink:#ece1cf;--ink-2:#a3958a;--line:#3b332d;--seal:#e65d3e;--seal-soft:#3a2119;--ok:#79c08a;--warn:#e0a84a;
|
|
11
|
+
--get:#79c08a;--post:#7fa8e6;--put:#e0a84a;--patch:#b897e0;--delete:#e65d3e;color-scheme:dark}}
|
|
12
|
+
*{box-sizing:border-box}html,body{margin:0}
|
|
13
|
+
body{background:var(--paper);color:var(--ink);font:15px/1.55 var(--sans);
|
|
14
|
+
background-image:radial-gradient(var(--line) .6px,transparent .6px);background-size:18px 18px}
|
|
15
|
+
a{color:inherit}code,pre,.mono{font-family:var(--mono);font-size:13px}
|
|
16
|
+
.top{display:flex;align-items:center;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);
|
|
17
|
+
background:var(--paper);position:sticky;top:0;z-index:5;flex-wrap:wrap}
|
|
18
|
+
.stamp{display:inline-grid;place-items:center;width:30px;height:30px;border:2px solid var(--seal);color:var(--seal);
|
|
19
|
+
border-radius:6px;font-weight:700;transform:rotate(-4deg);font-size:15px;line-height:1}
|
|
20
|
+
.top h1{font:600 16px var(--mono);margin:0}.top .ver{color:var(--ink-2);font:13px var(--mono)}
|
|
21
|
+
.top nav{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
|
|
22
|
+
.btn{font:12px var(--mono);border:1px solid var(--ink);background:var(--card);color:var(--ink);padding:5px 10px;
|
|
23
|
+
border-radius:3px;cursor:pointer;text-decoration:none;box-shadow:2px 2px 0 var(--line)}
|
|
24
|
+
.btn:hover{background:var(--paper-2)}.btn:active{transform:translate(1px,1px);box-shadow:1px 1px 0 var(--line)}
|
|
25
|
+
.btn.seal{border-color:var(--seal);color:var(--seal)}
|
|
26
|
+
.layout{display:grid;grid-template-columns:260px minmax(0,1fr);gap:26px;max-width:1180px;margin:0 auto;padding:24px 22px 80px}
|
|
27
|
+
aside{position:sticky;top:76px;align-self:start;max-height:calc(100vh - 96px);overflow:auto}
|
|
28
|
+
aside h4{font:600 11px var(--mono);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2);margin:18px 0 6px}
|
|
29
|
+
aside a{display:flex;gap:8px;align-items:baseline;padding:3px 6px;border-radius:3px;text-decoration:none;font:12.5px var(--mono)}
|
|
30
|
+
aside a:hover{background:var(--paper-2)}aside a .p{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
31
|
+
.m{font:700 10.5px var(--mono);min-width:44px;display:inline-block}
|
|
32
|
+
.m-get{color:var(--get)}.m-post{color:var(--post)}.m-put{color:var(--put)}.m-patch{color:var(--patch)}.m-delete{color:var(--delete)}
|
|
33
|
+
.intro{margin:0 0 22px;color:var(--ink-2);max-width:68ch}
|
|
34
|
+
.win{background:var(--card);border:1px solid var(--ink);border-radius:4px;box-shadow:4px 4px 0 var(--line);margin:0 0 26px;scroll-margin-top:84px}
|
|
35
|
+
.bar{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--ink);background:var(--paper-2);border-radius:4px 4px 0 0}
|
|
36
|
+
.bar code{font-size:14px;font-weight:600;word-break:break-all}.bar .dep{color:var(--warn);font:11px var(--mono)}
|
|
37
|
+
.bar .sealmark{margin-left:auto;opacity:0;transition:opacity .3s,transform .3s;transform:scale(1.6) rotate(-12deg)}
|
|
38
|
+
.win.sealed .sealmark{opacity:1;transform:scale(1) rotate(-4deg)}
|
|
39
|
+
.win.broken .bar{background:var(--seal-soft)}
|
|
40
|
+
.pad{padding:14px 16px}.sum{margin:0 0 4px;font-weight:600}.desc{margin:0 0 8px;color:var(--ink-2)}
|
|
41
|
+
h5{font:600 11px var(--mono);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2);margin:16px 0 6px}
|
|
42
|
+
table{border-collapse:collapse;width:100%;font-size:13.5px}
|
|
43
|
+
td,th{text-align:left;padding:5px 8px;border-bottom:1px dashed var(--line);vertical-align:top}
|
|
44
|
+
th{font:600 11px var(--mono);color:var(--ink-2)}
|
|
45
|
+
pre.type{margin:0;padding:10px 12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;overflow:auto;white-space:pre}
|
|
46
|
+
.k{color:var(--ink)}.ty{color:var(--post)}.lit{color:var(--ok)}.cm{color:var(--ink-2)}.ty-ref{color:var(--patch);font-weight:600}
|
|
47
|
+
.status{font:600 12px var(--mono);display:inline-block;min-width:34px}
|
|
48
|
+
.s2{color:var(--ok)}.s3{color:var(--post)}.s4{color:var(--warn)}.s5{color:var(--seal)}
|
|
49
|
+
.ex{border:1px dashed var(--line);border-radius:3px;margin:8px 0;padding:8px 10px}
|
|
50
|
+
.ex-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.ex-head .name{font-weight:600}
|
|
51
|
+
.ex-head .req{color:var(--ink-2);font:12px var(--mono);word-break:break-all}.ex-head .btn{margin-left:auto}
|
|
52
|
+
.ex pre{margin:8px 0 0;max-height:280px;overflow:auto;padding:8px 10px;background:var(--paper);border-radius:3px}
|
|
53
|
+
.verdict{font:600 12px var(--mono)}.verdict.ok{color:var(--ok)}.verdict.no{color:var(--seal)}
|
|
54
|
+
.muted{color:var(--ink-2)}.empty{padding:40px;text-align:center;color:var(--ink-2)}
|
|
55
|
+
input.filter{font:13px var(--mono);padding:6px 10px;border:1px solid var(--ink);border-radius:3px;background:var(--card);color:var(--ink);width:260px;max-width:100%}
|
|
56
|
+
.log td{font:12.5px var(--mono);white-space:nowrap}.log tr.row{cursor:pointer}.log tr.row:hover td{background:var(--paper-2)}
|
|
57
|
+
.log td.path{white-space:normal;word-break:break-all}.note{color:var(--seal)}
|
|
58
|
+
.log tr.detail td{white-space:normal;background:var(--paper)}.log tr.detail pre{white-space:pre-wrap;word-break:break-all;margin:4px 0 10px}
|
|
59
|
+
@media (max-width:820px){.layout{grid-template-columns:minmax(0,1fr);padding:16px}aside{position:static;max-height:none}.top{padding:12px 16px}.top nav{margin-left:0}}
|
|
60
|
+
`;
|
|
61
|
+
const esc = /* js */ `
|
|
62
|
+
function esc(s){return String(s).replace(/[&<>"']/g,function(c){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[c]})}
|
|
63
|
+
`;
|
|
64
|
+
const page = (title, cfg, body, script) => `<!doctype html>
|
|
65
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
66
|
+
<title>${title.replace(/[<>&"]/g, "")}</title>
|
|
67
|
+
<link rel="icon" href="data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="3" y="3" width="26" height="26" rx="6" fill="#c4381f"/><rect x="7" y="7" width="18" height="18" rx="3" fill="none" stroke="#fbf7ef" stroke-width="2"/><path d="M12 16.5l3 3 5.5-6.5" fill="none" stroke="#fbf7ef" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>')}">
|
|
68
|
+
<style>${css}</style></head><body>${body}
|
|
69
|
+
<script>var CFG=${JSON.stringify(cfg).replace(/</g, "\\u003c")};${esc}${script}</script></body></html>`;
|
|
70
|
+
// ---------- docs ----------
|
|
71
|
+
const docsScript = /* js */ `
|
|
72
|
+
var COMP = {};
|
|
73
|
+
var $ = function (s) { return document.querySelector(s); };
|
|
74
|
+
function lit(s) { return '<span class="lit">' + esc(s) + "</span>"; }
|
|
75
|
+
function ty(s) { return '<span class="ty">' + esc(s) + "</span>"; }
|
|
76
|
+
function note(s) {
|
|
77
|
+
var n = [];
|
|
78
|
+
if (s.format) n.push(s.format);
|
|
79
|
+
if (s.minimum !== undefined || s.maximum !== undefined) n.push((s.minimum !== undefined ? s.minimum : "") + ".." + (s.maximum !== undefined ? s.maximum : ""));
|
|
80
|
+
if (s.minLength !== undefined || s.maxLength !== undefined) n.push("length " + (s.minLength || 0) + ".." + (s.maxLength !== undefined ? s.maxLength : ""));
|
|
81
|
+
if (s.pattern) n.push("/" + s.pattern + "/");
|
|
82
|
+
if (s.default !== undefined) n.push("default " + JSON.stringify(s.default));
|
|
83
|
+
if (s.description) n.push(s.description);
|
|
84
|
+
return n;
|
|
85
|
+
}
|
|
86
|
+
function render(s, pad) {
|
|
87
|
+
pad = pad || "";
|
|
88
|
+
if (!s || !Object.keys(s).length) return ty("any");
|
|
89
|
+
if (s.$ref) { var name = s.$ref.split("/").pop(); return '<a class="ty-ref" href="#schema-' + esc(name) + '">' + esc(name) + "</a>"; }
|
|
90
|
+
if (s.anyOf) return s.anyOf.map(function (x) { return render(x, pad); }).join(" | ");
|
|
91
|
+
if (s.const !== undefined) return lit(JSON.stringify(s.const));
|
|
92
|
+
if (s.enum) return s.enum.map(function (v) { return lit(JSON.stringify(v)); }).join(" | ");
|
|
93
|
+
if (s.type === "null") return ty("null");
|
|
94
|
+
if (s.type === "array") { var inner = render(s.items, pad); return (/ \\| /.test(inner) ? "(" + inner + ")" : inner) + ty("[]"); }
|
|
95
|
+
if (s.type === "object") {
|
|
96
|
+
if (!s.properties) return s.additionalProperties ? ty("Record<string, ") + render(s.additionalProperties, pad) + ty(">") : ty("object");
|
|
97
|
+
var req = s.required || [], keys = Object.keys(s.properties);
|
|
98
|
+
if (!keys.length) return ty("{}");
|
|
99
|
+
var lines = keys.map(function (k) {
|
|
100
|
+
var p = s.properties[k], n = note(p);
|
|
101
|
+
return pad + " " + '<span class="k">' + esc(k) + (req.indexOf(k) < 0 ? "?" : "") + "</span>: " + render(p, pad + " ") +
|
|
102
|
+
(n.length ? ' <span class="cm">// ' + esc(n.join(", ")) + "</span>" : "");
|
|
103
|
+
});
|
|
104
|
+
return "{\\n" + lines.join("\\n") + "\\n" + pad + "}";
|
|
105
|
+
}
|
|
106
|
+
return ty(s.type === "integer" ? "int" : s.type || "any");
|
|
107
|
+
}
|
|
108
|
+
function block(schema) { return '<pre class="type">' + render(schema, "") + "</pre>"; }
|
|
109
|
+
function firstContent(c) { if (!c) return null; var k = Object.keys(c)[0]; return k ? c[k].schema : null; }
|
|
110
|
+
function expected(op, ex) {
|
|
111
|
+
if (ex.status !== undefined) return ex.status;
|
|
112
|
+
var ok = Object.keys(op.responses || {}).map(Number).filter(function (s) { return s >= 200 && s < 300; }).sort();
|
|
113
|
+
return ok[0];
|
|
114
|
+
}
|
|
115
|
+
function urlFor(path, ex) {
|
|
116
|
+
var u = path.replace(/\\{(\\w+)\\}/g, function (_, n) {
|
|
117
|
+
var v = (ex.params || {})[n]; return v === undefined ? "{" + n + "}" : encodeURIComponent(String(v));
|
|
118
|
+
});
|
|
119
|
+
var q = new URLSearchParams();
|
|
120
|
+
Object.keys(ex.query || {}).forEach(function (k) { [].concat(ex.query[k]).forEach(function (v) { q.append(k, String(v)); }); });
|
|
121
|
+
var qs = q.toString();
|
|
122
|
+
return u + (qs ? "?" + qs : "");
|
|
123
|
+
}
|
|
124
|
+
var ROUTES = [];
|
|
125
|
+
function card(path, method, op, idx) {
|
|
126
|
+
var id = "op-" + (op.operationId || idx);
|
|
127
|
+
var h = '<section class="win" id="' + esc(id) + '"><header class="bar"><span class="m m-' + method + '">' + method.toUpperCase() +
|
|
128
|
+
"</span><code>" + esc(path) + "</code>" + (op.deprecated ? '<span class="dep">deprecated</span>' : "") +
|
|
129
|
+
'<span class="stamp sealmark" title="every example answered as promised">印</span></header><div class="pad">';
|
|
130
|
+
if (op.summary) h += '<p class="sum">' + esc(op.summary) + "</p>";
|
|
131
|
+
if (op.description) h += '<p class="desc">' + esc(op.description) + "</p>";
|
|
132
|
+
if (op.parameters && op.parameters.length) {
|
|
133
|
+
h += "<h5>parameters</h5><table><tr><th>name</th><th>in</th><th>type</th><th></th></tr>";
|
|
134
|
+
op.parameters.forEach(function (p) {
|
|
135
|
+
h += "<tr><td><code>" + esc(p.name) + (p.required ? "" : "?") + "</code></td><td class=muted>" + esc(p.in) +
|
|
136
|
+
"</td><td><code>" + render(p.schema, "") + "</code></td><td class=muted>" + esc(note(p.schema || {}).concat(p.description || []).join(", ")) + "</td></tr>";
|
|
137
|
+
});
|
|
138
|
+
h += "</table>";
|
|
139
|
+
}
|
|
140
|
+
if (op.requestBody) h += "<h5>body</h5>" + block(firstContent(op.requestBody.content));
|
|
141
|
+
h += "<h5>responses</h5><table>";
|
|
142
|
+
Object.keys(op.responses || {}).forEach(function (s) {
|
|
143
|
+
var r = op.responses[s], sc = firstContent(r.content);
|
|
144
|
+
h += '<tr><td style="width:60px"><span class="status s' + s[0] + '">' + esc(s) + "</span></td><td>" + esc(r.description || "") +
|
|
145
|
+
(sc ? '<pre class="type" style="margin-top:6px">' + render(sc, "") + "</pre>" : "") + "</td></tr>";
|
|
146
|
+
});
|
|
147
|
+
h += "</table>";
|
|
148
|
+
var exs = op["x-inkan-examples"] || [];
|
|
149
|
+
if (exs.length) {
|
|
150
|
+
h += "<h5>examples · each one is also a test</h5>";
|
|
151
|
+
exs.forEach(function (ex, i) {
|
|
152
|
+
var want = expected(op, ex);
|
|
153
|
+
ROUTES.push({ id: id, path: path, method: method, op: op, ex: ex, el: id + "-ex" + i });
|
|
154
|
+
h += '<div class="ex" id="' + id + "-ex" + i + '"><div class="ex-head"><span class="name">' + esc(ex.name || "example " + (i + 1)) +
|
|
155
|
+
'</span><span class="req">' + method.toUpperCase() + " " + esc(urlFor(path, ex)) + (want ? " → " + want : "") +
|
|
156
|
+
'</span><button class="btn" data-run="' + (ROUTES.length - 1) + '">send</button></div>' +
|
|
157
|
+
(ex.body !== undefined ? "<pre>" + esc(JSON.stringify(ex.body, null, 2)) + "</pre>" : "") + '<div class="out"></div></div>';
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return h + "</div></section>";
|
|
161
|
+
}
|
|
162
|
+
function send(i) {
|
|
163
|
+
var r = ROUTES[i], ex = r.ex, el = document.getElementById(r.el), out = el.querySelector(".out");
|
|
164
|
+
var headers = Object.assign({}, ex.headers || {}), init = { method: r.method.toUpperCase(), headers: headers };
|
|
165
|
+
if (ex.body !== undefined) { headers["content-type"] = "application/json"; init.body = JSON.stringify(ex.body); }
|
|
166
|
+
var t0 = performance.now();
|
|
167
|
+
out.innerHTML = '<p class="muted mono">sending…</p>';
|
|
168
|
+
return fetch(urlFor(r.path, ex), init).then(function (res) {
|
|
169
|
+
return res.text().then(function (text) {
|
|
170
|
+
var ms = Math.round(performance.now() - t0), want = expected(r.op, ex);
|
|
171
|
+
var ok = want === undefined ? res.status < 300 : res.status === want, pretty = text;
|
|
172
|
+
try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch (e) {}
|
|
173
|
+
out.innerHTML = '<p class="mono" style="margin:8px 0 0"><span class="status s' + String(res.status)[0] + '">' + res.status +
|
|
174
|
+
'</span> <span class="muted">' + ms + 'ms</span> <span class="verdict ' + (ok ? "ok" : "no") + '">' +
|
|
175
|
+
(ok ? "✓ as promised" : "✗ expected " + (want || "a 2xx")) + "</span></p>" + (pretty ? "<pre>" + esc(pretty) + "</pre>" : "");
|
|
176
|
+
el.dataset.ok = ok ? "1" : "0";
|
|
177
|
+
stampCard(r.id);
|
|
178
|
+
return ok;
|
|
179
|
+
});
|
|
180
|
+
}).catch(function (e) { out.innerHTML = '<p class="verdict no">' + esc(e.message) + "</p>"; el.dataset.ok = "0"; stampCard(r.id); });
|
|
181
|
+
}
|
|
182
|
+
function stampCard(id) {
|
|
183
|
+
var win = document.getElementById(id), all = win.querySelectorAll(".ex"), done = 0, good = 0;
|
|
184
|
+
all.forEach(function (e) { if (e.dataset.ok) { done++; if (e.dataset.ok === "1") good++; } });
|
|
185
|
+
win.classList.toggle("sealed", done === all.length && good === all.length);
|
|
186
|
+
win.classList.toggle("broken", done > good);
|
|
187
|
+
}
|
|
188
|
+
function sendAll() {
|
|
189
|
+
var chain = Promise.resolve();
|
|
190
|
+
ROUTES.forEach(function (_, i) { chain = chain.then(function () { return send(i); }); });
|
|
191
|
+
}
|
|
192
|
+
function boot(spec) {
|
|
193
|
+
COMP = (spec.components || {}).schemas || {};
|
|
194
|
+
document.title = (spec.info.title || "API") + " · docs";
|
|
195
|
+
$("#title").textContent = spec.info.title || "API";
|
|
196
|
+
$("#ver").textContent = spec.info.version || "";
|
|
197
|
+
if (spec.info.description) $("#intro").textContent = spec.info.description;
|
|
198
|
+
var groups = {}, cards = [], idx = 0;
|
|
199
|
+
Object.keys(spec.paths).forEach(function (path) {
|
|
200
|
+
Object.keys(spec.paths[path]).forEach(function (method) {
|
|
201
|
+
var op = spec.paths[path][method], tag = (op.tags && op.tags[0]) || "routes";
|
|
202
|
+
(groups[tag] = groups[tag] || []).push({ path: path, method: method, op: op, idx: idx });
|
|
203
|
+
cards.push(card(path, method, op, idx++));
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
var nav = "";
|
|
207
|
+
Object.keys(groups).forEach(function (g) {
|
|
208
|
+
nav += "<h4>" + esc(g) + "</h4>";
|
|
209
|
+
groups[g].forEach(function (r) {
|
|
210
|
+
nav += '<a href="#op-' + esc(r.op.operationId || r.idx) + '"><span class="m m-' + r.method + '">' + r.method.toUpperCase() +
|
|
211
|
+
'</span><span class="p">' + esc(r.path) + "</span></a>";
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
var names = Object.keys(COMP);
|
|
215
|
+
if (names.length) {
|
|
216
|
+
nav += "<h4>schemas</h4>" + names.map(function (n) { return '<a href="#schema-' + esc(n) + '"><span class="p">' + esc(n) + "</span></a>"; }).join("");
|
|
217
|
+
cards.push(names.map(function (n) {
|
|
218
|
+
return '<section class="win" id="schema-' + esc(n) + '"><header class="bar"><code>' + esc(n) + '</code></header><div class="pad">' + block(COMP[n]) + "</div></section>";
|
|
219
|
+
}).join(""));
|
|
220
|
+
}
|
|
221
|
+
$("#nav").innerHTML = nav;
|
|
222
|
+
$("#main").innerHTML = cards.join("") || '<p class="empty">No routes yet.</p>';
|
|
223
|
+
document.addEventListener("click", function (e) { var b = e.target.closest("[data-run]"); if (b) send(Number(b.dataset.run)); });
|
|
224
|
+
if (!ROUTES.length) $("#all").remove();
|
|
225
|
+
}
|
|
226
|
+
$("#all").addEventListener("click", sendAll);
|
|
227
|
+
if (CFG.spec) fetch(CFG.spec).then(function (r) { return r.json(); }).then(boot);
|
|
228
|
+
else $("#main").innerHTML = '<p class="empty">The OpenAPI document is switched off.</p>';
|
|
229
|
+
`;
|
|
230
|
+
export function docsPage(cfg) {
|
|
231
|
+
const body = `
|
|
232
|
+
<header class="top"><span class="stamp">印</span><h1 id="title">${cfg.title.replace(/[<>&]/g, "")}</h1><span class="ver" id="ver"></span>
|
|
233
|
+
<nav><button class="btn seal" id="all" title="Sends every example. Examples that change data change it for real.">▶ send every example</button>
|
|
234
|
+
${cfg.specUrl ? `<a class="btn" href="${cfg.specUrl}">openapi.json</a>` : ""}
|
|
235
|
+
${cfg.inspector ? `<a class="btn" href="${cfg.inspector}">inspector</a>` : ""}</nav></header>
|
|
236
|
+
<div class="layout"><aside id="nav"></aside><main><p class="intro" id="intro">Every route below carries its contract. The examples are the same ones <code>inkan check</code> runs, so what you read here is what the server was tested to do.</p><div id="main"><p class="empty">loading…</p></div></main></div>`;
|
|
237
|
+
return page(cfg.title + " · docs", { spec: cfg.specUrl }, body, docsScript);
|
|
238
|
+
}
|
|
239
|
+
// ---------- inspector ----------
|
|
240
|
+
const inspectorScript = /* js */ `
|
|
241
|
+
var since = 0, paused = false, rows = [], open = {};
|
|
242
|
+
var $ = function (s) { return document.querySelector(s); };
|
|
243
|
+
function line(e) {
|
|
244
|
+
var t = e.at.slice(11, 19);
|
|
245
|
+
return '<tr class="row" data-id="' + e.id + '"><td class="muted">' + t + '</td><td><span class="m m-' + e.method.toLowerCase() + '">' + esc(e.method) +
|
|
246
|
+
'</span></td><td class="path">' + esc(e.path) + '</td><td><span class="status s' + String(e.status)[0] + '">' + e.status +
|
|
247
|
+
'</span></td><td class="muted">' + e.ms + "ms</td><td class=muted>" + esc(e.route || "—") + '</td><td class="note">' + esc(e.notes.join("; ")) + "</td></tr>" +
|
|
248
|
+
(open[e.id] ? '<tr class="detail"><td colspan="7">' + detail(e) + "</td></tr>" : "");
|
|
249
|
+
}
|
|
250
|
+
function pretty(s) { if (s === undefined) return "—"; try { return JSON.stringify(JSON.parse(s), null, 2); } catch (x) { return s; } }
|
|
251
|
+
function detail(e) {
|
|
252
|
+
return '<h5>request headers</h5><pre>' + esc(Object.keys(e.request.headers).map(function (k) { return k + ": " + e.request.headers[k]; }).join("\\n")) +
|
|
253
|
+
'</pre><h5>request body</h5><pre>' + esc(pretty(e.request.body)) + '</pre><h5>response body</h5><pre>' + esc(pretty(e.response.body)) + "</pre>";
|
|
254
|
+
}
|
|
255
|
+
function draw() {
|
|
256
|
+
var f = $("#filter").value.toLowerCase();
|
|
257
|
+
var shown = rows.filter(function (e) { return !f || (e.method + " " + e.path + " " + e.status + " " + e.notes.join(" ")).toLowerCase().indexOf(f) >= 0; });
|
|
258
|
+
$("#body").innerHTML = shown.length ? shown.map(line).join("") : '<tr><td colspan="7" class="empty">Nothing yet. Send a request and it shows up here.</td></tr>';
|
|
259
|
+
$("#count").textContent = rows.length + " request" + (rows.length === 1 ? "" : "s");
|
|
260
|
+
}
|
|
261
|
+
function poll() {
|
|
262
|
+
if (paused) return;
|
|
263
|
+
fetch(CFG.base + "/log.json?since=" + since).then(function (r) { return r.json(); }).then(function (list) {
|
|
264
|
+
if (!list.length) return;
|
|
265
|
+
since = list[list.length - 1].id;
|
|
266
|
+
rows = list.reverse().concat(rows).slice(0, 200);
|
|
267
|
+
draw();
|
|
268
|
+
}).catch(function () {});
|
|
269
|
+
}
|
|
270
|
+
$("#body").addEventListener("click", function (e) { var tr = e.target.closest("tr.row"); if (!tr) return; var id = tr.dataset.id; open[id] = !open[id]; draw(); });
|
|
271
|
+
$("#filter").addEventListener("input", draw);
|
|
272
|
+
$("#pause").addEventListener("click", function () { paused = !paused; this.textContent = paused ? "▶ resume" : "❚❚ pause"; });
|
|
273
|
+
$("#clear").addEventListener("click", function () { rows = []; open = {}; draw(); });
|
|
274
|
+
draw(); poll(); setInterval(poll, 1000);
|
|
275
|
+
`;
|
|
276
|
+
export function inspectorPage(cfg) {
|
|
277
|
+
const body = `
|
|
278
|
+
<header class="top"><span class="stamp">印</span><h1>${cfg.title.replace(/[<>&]/g, "")}</h1><span class="ver">inspector · only on this machine</span>
|
|
279
|
+
<nav><input class="filter" id="filter" placeholder="filter: path, status, note…">
|
|
280
|
+
<button class="btn" id="pause">❚❚ pause</button><button class="btn" id="clear">clear</button>
|
|
281
|
+
${cfg.docs ? `<a class="btn" href="${cfg.docs}">docs</a>` : ""}</nav></header>
|
|
282
|
+
<div style="max-width:1180px;margin:0 auto;padding:22px">
|
|
283
|
+
<p class="intro">The last 200 requests, newest first. A red note means a request or an answer broke the contract. Secret headers show as •••. <span id="count" class="mono"></span></p>
|
|
284
|
+
<section class="win"><div class="pad" style="overflow:auto"><table class="log"><thead><tr><th>time</th><th>method</th><th>path</th><th>status</th><th>took</th><th>route</th><th>notes</th></tr></thead><tbody id="body"></tbody></table></div></section></div>`;
|
|
285
|
+
return page(cfg.title + " · inspector", { base: cfg.base }, body, inspectorScript);
|
|
286
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Issue } from "./schema.ts";
|
|
2
|
+
export type ProblemBody = {
|
|
3
|
+
type: string;
|
|
4
|
+
title: string;
|
|
5
|
+
status: number;
|
|
6
|
+
detail?: string;
|
|
7
|
+
instance?: string;
|
|
8
|
+
[extra: string]: unknown;
|
|
9
|
+
};
|
|
10
|
+
export declare class HttpProblem extends Error {
|
|
11
|
+
status: number;
|
|
12
|
+
type: string;
|
|
13
|
+
title: string;
|
|
14
|
+
detail?: string;
|
|
15
|
+
extra: Record<string, unknown>;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
constructor(status: number, type: string, detail?: string, extra?: Record<string, unknown>, headers?: Record<string, string>);
|
|
18
|
+
toJSON(): ProblemBody;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Stops the handler with an error response.
|
|
22
|
+
*
|
|
23
|
+
* throw problem(404, "user-not-found", `No user with id ${id}`)
|
|
24
|
+
*/
|
|
25
|
+
export declare function problem(status: number, type: string, detail?: string, extra?: Record<string, unknown>): HttpProblem;
|
|
26
|
+
export declare const validationProblem: (where: string, issues: Issue[]) => HttpProblem;
|
package/dist/problem.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Errors as RFC 9457 problem documents: every failure has the same shape,
|
|
2
|
+
// a stable `type` code to switch on, and a human `detail`.
|
|
3
|
+
import { STATUS_CODES } from "node:http";
|
|
4
|
+
export class HttpProblem extends Error {
|
|
5
|
+
status;
|
|
6
|
+
type;
|
|
7
|
+
title;
|
|
8
|
+
detail;
|
|
9
|
+
extra;
|
|
10
|
+
headers;
|
|
11
|
+
constructor(status, type, detail, extra = {}, headers = {}) {
|
|
12
|
+
super(detail ?? type);
|
|
13
|
+
this.name = "HttpProblem";
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.type = type;
|
|
16
|
+
this.title = STATUS_CODES[status] ?? "Error";
|
|
17
|
+
this.detail = detail;
|
|
18
|
+
this.extra = extra;
|
|
19
|
+
this.headers = headers;
|
|
20
|
+
}
|
|
21
|
+
toJSON() {
|
|
22
|
+
const body = { type: this.type, title: this.title, status: this.status };
|
|
23
|
+
if (this.detail)
|
|
24
|
+
body.detail = this.detail;
|
|
25
|
+
return { ...body, ...this.extra };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Stops the handler with an error response.
|
|
30
|
+
*
|
|
31
|
+
* throw problem(404, "user-not-found", `No user with id ${id}`)
|
|
32
|
+
*/
|
|
33
|
+
export function problem(status, type, detail, extra) {
|
|
34
|
+
return new HttpProblem(status, type, detail, extra);
|
|
35
|
+
}
|
|
36
|
+
export const validationProblem = (where, issues) => new HttpProblem(400, "validation", `The ${where} does not match the contract`, {
|
|
37
|
+
errors: issues.map((i) => ({ in: where, path: i.path, message: i.message })),
|
|
38
|
+
});
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type Match<R> = {
|
|
2
|
+
kind: "found";
|
|
3
|
+
route: R;
|
|
4
|
+
params: Record<string, string>;
|
|
5
|
+
} | {
|
|
6
|
+
kind: "method";
|
|
7
|
+
allow: string[];
|
|
8
|
+
} | {
|
|
9
|
+
kind: "none";
|
|
10
|
+
};
|
|
11
|
+
export declare function splitPath(path: string): string[];
|
|
12
|
+
export declare class Router<R> {
|
|
13
|
+
private root;
|
|
14
|
+
add(method: string, path: string, route: R): void;
|
|
15
|
+
private put;
|
|
16
|
+
match(method: string, path: string): Match<R>;
|
|
17
|
+
private walk;
|
|
18
|
+
}
|