@di-framework/di-framework-http 0.0.0-prerelease.301 → 0.0.0-prerelease.304
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/dist/index.js +13 -58
- package/dist/src/cli.js +9 -57
- package/index.ts +5 -0
- package/package.json +5 -3
- package/src/cli.test.ts +85 -0
- package/src/cli.ts +53 -0
- package/src/decorators.test.ts +47 -0
- package/src/decorators.ts +51 -0
- package/src/openapi.test.ts +126 -0
- package/src/openapi.ts +59 -0
- package/src/registry.ts +34 -0
- package/src/typed-router.test.ts +195 -0
- package/src/typed-router.ts +162 -0
package/dist/index.js
CHANGED
|
@@ -1,54 +1,3 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
var __create = Object.create;
|
|
3
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
-
var __defProp = Object.defineProperty;
|
|
5
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
-
var __toESM = (mod, isNodeMode, target) => {
|
|
8
|
-
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
9
|
-
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
10
|
-
for (let key of __getOwnPropNames(mod))
|
|
11
|
-
if (!__hasOwnProp.call(to, key))
|
|
12
|
-
__defProp(to, key, {
|
|
13
|
-
get: () => mod[key],
|
|
14
|
-
enumerable: true
|
|
15
|
-
});
|
|
16
|
-
return to;
|
|
17
|
-
};
|
|
18
|
-
var __export = (target, all) => {
|
|
19
|
-
for (var name in all)
|
|
20
|
-
__defProp(target, name, {
|
|
21
|
-
get: all[name],
|
|
22
|
-
enumerable: true,
|
|
23
|
-
configurable: true,
|
|
24
|
-
set: (newValue) => all[name] = () => newValue
|
|
25
|
-
});
|
|
26
|
-
};
|
|
27
|
-
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
28
|
-
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
29
|
-
|
|
30
|
-
// src/registry.ts
|
|
31
|
-
var exports_registry = {};
|
|
32
|
-
__export(exports_registry, {
|
|
33
|
-
default: () => registry_default,
|
|
34
|
-
Registry: () => Registry
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
class Registry {
|
|
38
|
-
targets = new Set;
|
|
39
|
-
addTarget(target) {
|
|
40
|
-
this.targets.add(target);
|
|
41
|
-
}
|
|
42
|
-
getTargets() {
|
|
43
|
-
return this.targets;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
var registry, registry_default;
|
|
47
|
-
var init_registry = __esm(() => {
|
|
48
|
-
registry = new Registry;
|
|
49
|
-
registry_default = registry;
|
|
50
|
-
});
|
|
51
|
-
|
|
52
1
|
// src/typed-router.ts
|
|
53
2
|
import {
|
|
54
3
|
Router,
|
|
@@ -120,8 +69,19 @@ function TypedRouter(opts) {
|
|
|
120
69
|
});
|
|
121
70
|
return wrapper;
|
|
122
71
|
}
|
|
123
|
-
// src/
|
|
124
|
-
|
|
72
|
+
// src/registry.ts
|
|
73
|
+
class Registry {
|
|
74
|
+
targets = new Set;
|
|
75
|
+
addTarget(target) {
|
|
76
|
+
this.targets.add(target);
|
|
77
|
+
}
|
|
78
|
+
getTargets() {
|
|
79
|
+
return this.targets;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
var GLOBAL_KEY = Symbol.for("@di-framework/http-registry");
|
|
83
|
+
var registry = globalThis[GLOBAL_KEY] ?? (globalThis[GLOBAL_KEY] = new Registry);
|
|
84
|
+
var registry_default = registry;
|
|
125
85
|
|
|
126
86
|
// ../di-framework/dist/container.js
|
|
127
87
|
var INJECT_METADATA_KEY = "di:inject";
|
|
@@ -530,7 +490,6 @@ function Endpoint(metadata) {
|
|
|
530
490
|
};
|
|
531
491
|
}
|
|
532
492
|
// src/openapi.ts
|
|
533
|
-
init_registry();
|
|
534
493
|
function generateOpenAPI(options = {}, registryToUse = registry_default) {
|
|
535
494
|
const spec = {
|
|
536
495
|
openapi: "3.1.0",
|
|
@@ -570,10 +529,6 @@ function generateOpenAPI(options = {}, registryToUse = registry_default) {
|
|
|
570
529
|
}
|
|
571
530
|
return spec;
|
|
572
531
|
}
|
|
573
|
-
|
|
574
|
-
// index.ts
|
|
575
|
-
init_registry();
|
|
576
|
-
init_registry();
|
|
577
532
|
export {
|
|
578
533
|
registry_default as registry,
|
|
579
534
|
json,
|
package/dist/src/cli.js
CHANGED
|
@@ -1,40 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
|
-
var __create = Object.create;
|
|
4
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
5
|
-
var __defProp = Object.defineProperty;
|
|
6
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __toESM = (mod, isNodeMode, target) => {
|
|
9
|
-
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
10
|
-
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
11
|
-
for (let key of __getOwnPropNames(mod))
|
|
12
|
-
if (!__hasOwnProp.call(to, key))
|
|
13
|
-
__defProp(to, key, {
|
|
14
|
-
get: () => mod[key],
|
|
15
|
-
enumerable: true
|
|
16
|
-
});
|
|
17
|
-
return to;
|
|
18
|
-
};
|
|
19
|
-
var __export = (target, all) => {
|
|
20
|
-
for (var name in all)
|
|
21
|
-
__defProp(target, name, {
|
|
22
|
-
get: all[name],
|
|
23
|
-
enumerable: true,
|
|
24
|
-
configurable: true,
|
|
25
|
-
set: (newValue) => all[name] = () => newValue
|
|
26
|
-
});
|
|
27
|
-
};
|
|
28
|
-
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
29
|
-
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
30
2
|
|
|
31
|
-
// src/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
default: () => registry_default,
|
|
35
|
-
Registry: () => Registry
|
|
36
|
-
});
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
37
6
|
|
|
7
|
+
// src/registry.ts
|
|
38
8
|
class Registry {
|
|
39
9
|
targets = new Set;
|
|
40
10
|
addTarget(target) {
|
|
@@ -44,18 +14,11 @@ class Registry {
|
|
|
44
14
|
return this.targets;
|
|
45
15
|
}
|
|
46
16
|
}
|
|
47
|
-
var registry
|
|
48
|
-
var
|
|
49
|
-
|
|
50
|
-
registry_default = registry;
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
// src/cli.ts
|
|
54
|
-
import fs from "fs";
|
|
55
|
-
import path from "path";
|
|
17
|
+
var GLOBAL_KEY = Symbol.for("@di-framework/http-registry");
|
|
18
|
+
var registry = globalThis[GLOBAL_KEY] ?? (globalThis[GLOBAL_KEY] = new Registry);
|
|
19
|
+
var registry_default = registry;
|
|
56
20
|
|
|
57
21
|
// src/openapi.ts
|
|
58
|
-
init_registry();
|
|
59
22
|
function generateOpenAPI(options = {}, registryToUse = registry_default) {
|
|
60
23
|
const spec = {
|
|
61
24
|
openapi: "3.1.0",
|
|
@@ -107,24 +70,13 @@ if (command === "generate") {
|
|
|
107
70
|
console.error("Error: --controllers path is required");
|
|
108
71
|
process.exit(1);
|
|
109
72
|
}
|
|
110
|
-
const controllersPath = path.resolve(process.cwd(), args[controllersArg + 1]);
|
|
111
73
|
async function run() {
|
|
112
74
|
try {
|
|
113
75
|
const controllersPathResolved = path.resolve(process.cwd(), args[controllersArg + 1]);
|
|
114
|
-
|
|
115
|
-
let registryToUse = imported.default || imported.registry;
|
|
116
|
-
if (!registryToUse || typeof registryToUse.getTargets !== "function") {
|
|
117
|
-
try {
|
|
118
|
-
const dfHttp = await import("@di-framework/di-framework-http");
|
|
119
|
-
registryToUse = dfHttp.default || dfHttp.registry;
|
|
120
|
-
} catch {
|
|
121
|
-
const regModule = await Promise.resolve().then(() => (init_registry(), exports_registry));
|
|
122
|
-
registryToUse = regModule.default;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
76
|
+
await import(controllersPathResolved);
|
|
125
77
|
const spec = generateOpenAPI({
|
|
126
78
|
title: "API Documentation"
|
|
127
|
-
},
|
|
79
|
+
}, registry_default);
|
|
128
80
|
fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2));
|
|
129
81
|
console.log(`Successfully generated OpenAPI spec at ${outputPath}`);
|
|
130
82
|
} catch (error) {
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@di-framework/di-framework-http",
|
|
3
|
-
"version": "0.0.0-prerelease.
|
|
3
|
+
"version": "0.0.0-prerelease.304",
|
|
4
4
|
"description": "Extends di-framework with HTTP features",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"type": "module",
|
|
23
23
|
"files": [
|
|
24
24
|
"dist",
|
|
25
|
-
"README.md"
|
|
25
|
+
"README.md",
|
|
26
|
+
"index.ts",
|
|
27
|
+
"src"
|
|
26
28
|
],
|
|
27
29
|
"scripts": {
|
|
28
30
|
"build": "rm -rf dist && bun build ./index.ts ./src/cli.ts --outdir ./dist --target node --external itty-router && tsc --emitDeclarationOnly --declaration --outDir dist",
|
|
@@ -38,7 +40,7 @@
|
|
|
38
40
|
"typescript": "^5"
|
|
39
41
|
},
|
|
40
42
|
"peerDependencies": {
|
|
41
|
-
"@di-framework/di-framework": "0.0.0-prerelease
|
|
43
|
+
"@di-framework/di-framework": "0.0.0-prerelease.303",
|
|
42
44
|
"typescript": "^5"
|
|
43
45
|
}
|
|
44
46
|
}
|
package/src/cli.test.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "bun:test";
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
const CLI_PATH = path.resolve(import.meta.dir, "cli.ts");
|
|
7
|
+
const TEST_OUTPUT = "test.openapi.json";
|
|
8
|
+
|
|
9
|
+
describe("CLI", () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (fs.existsSync(TEST_OUTPUT)) {
|
|
12
|
+
fs.unlinkSync(TEST_OUTPUT);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should show help when no command is provided", () => {
|
|
17
|
+
const { stdout } = spawnSync("bun", [CLI_PATH]);
|
|
18
|
+
expect(stdout.toString()).toContain("Usage: di-framework-http generate");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should error when --controllers is missing", () => {
|
|
22
|
+
const { stderr, status } = spawnSync("bun", [CLI_PATH, "generate"]);
|
|
23
|
+
expect(status).toBe(1);
|
|
24
|
+
expect(stderr.toString()).toContain(
|
|
25
|
+
"Error: --controllers path is required",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should generate OpenAPI spec with --controllers and --output", () => {
|
|
30
|
+
const controllersPath = path.resolve(
|
|
31
|
+
import.meta.dir,
|
|
32
|
+
"../../examples/http-router/index.ts",
|
|
33
|
+
);
|
|
34
|
+
const { stdout, status } = spawnSync("bun", [
|
|
35
|
+
CLI_PATH,
|
|
36
|
+
"generate",
|
|
37
|
+
"--controllers",
|
|
38
|
+
controllersPath,
|
|
39
|
+
"--output",
|
|
40
|
+
TEST_OUTPUT,
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
expect(status).toBe(0);
|
|
44
|
+
expect(stdout.toString()).toContain(
|
|
45
|
+
"Successfully generated OpenAPI spec at test.openapi.json",
|
|
46
|
+
);
|
|
47
|
+
expect(fs.existsSync(TEST_OUTPUT)).toBe(true);
|
|
48
|
+
|
|
49
|
+
const spec = JSON.parse(fs.readFileSync(TEST_OUTPUT, "utf8"));
|
|
50
|
+
expect(spec.openapi).toBe("3.1.0");
|
|
51
|
+
expect(spec.paths["/echo"]).toBeDefined();
|
|
52
|
+
expect(spec.paths["/echo"].post).toBeDefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should use default output path (openapi.json) if --output is missing", () => {
|
|
56
|
+
const DEFAULT_OUTPUT = "openapi.json";
|
|
57
|
+
if (fs.existsSync(DEFAULT_OUTPUT)) fs.unlinkSync(DEFAULT_OUTPUT);
|
|
58
|
+
|
|
59
|
+
const controllersPath = path.resolve(
|
|
60
|
+
import.meta.dir,
|
|
61
|
+
"../../examples/http-router/index.ts",
|
|
62
|
+
);
|
|
63
|
+
const { status } = spawnSync("bun", [
|
|
64
|
+
CLI_PATH,
|
|
65
|
+
"generate",
|
|
66
|
+
"--controllers",
|
|
67
|
+
controllersPath,
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
expect(status).toBe(0);
|
|
71
|
+
expect(fs.existsSync(DEFAULT_OUTPUT)).toBe(true);
|
|
72
|
+
fs.unlinkSync(DEFAULT_OUTPUT);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should error with invalid controllers path", () => {
|
|
76
|
+
const { stderr, status } = spawnSync("bun", [
|
|
77
|
+
CLI_PATH,
|
|
78
|
+
"generate",
|
|
79
|
+
"--controllers",
|
|
80
|
+
"./non-existent-file.ts",
|
|
81
|
+
]);
|
|
82
|
+
expect(status).toBe(1);
|
|
83
|
+
expect(stderr.toString()).toContain("Error generating OpenAPI spec");
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { generateOpenAPI } from "./openapi.ts";
|
|
5
|
+
import registry from "./registry.ts";
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const command = args[0];
|
|
9
|
+
|
|
10
|
+
if (command === "generate") {
|
|
11
|
+
const outputArg = args.indexOf("--output");
|
|
12
|
+
const outputPath = outputArg !== -1 ? args[outputArg + 1] : "openapi.json";
|
|
13
|
+
|
|
14
|
+
const controllersArg = args.indexOf("--controllers");
|
|
15
|
+
if (controllersArg === -1) {
|
|
16
|
+
console.error("Error: --controllers path is required");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function run() {
|
|
21
|
+
try {
|
|
22
|
+
// Import the user's controllers to trigger decorator registration
|
|
23
|
+
const controllersPathResolved = path.resolve(
|
|
24
|
+
process.cwd(),
|
|
25
|
+
args[controllersArg + 1]!,
|
|
26
|
+
);
|
|
27
|
+
await import(controllersPathResolved);
|
|
28
|
+
|
|
29
|
+
const spec = generateOpenAPI(
|
|
30
|
+
{
|
|
31
|
+
title: "API Documentation",
|
|
32
|
+
},
|
|
33
|
+
registry,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
fs.writeFileSync(outputPath!, JSON.stringify(spec, null, 2));
|
|
37
|
+
console.log(`Successfully generated OpenAPI spec at ${outputPath}`);
|
|
38
|
+
} catch (error: any) {
|
|
39
|
+
console.error(`Error generating OpenAPI spec: ${error.message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
run();
|
|
45
|
+
} else {
|
|
46
|
+
console.log(`
|
|
47
|
+
Usage: di-framework-http generate --controllers <path-to-controllers> [options]
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
--controllers <path> Path to a file that imports all your decorated controllers
|
|
51
|
+
--output <path> Path to save the generated JSON (default: openapi.json)
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { Controller, Endpoint } from "./decorators.ts";
|
|
3
|
+
import registry from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
describe("Decorators", () => {
|
|
6
|
+
it("should register a controller and its endpoints", () => {
|
|
7
|
+
const initialSize = registry.getTargets().size;
|
|
8
|
+
|
|
9
|
+
@Controller()
|
|
10
|
+
class TestController {
|
|
11
|
+
@Endpoint({ summary: "Test endpoint" })
|
|
12
|
+
static test = { isEndpoint: true, path: "/test", method: "get" };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
expect(registry.getTargets().has(TestController)).toBe(true);
|
|
16
|
+
// @ts-ignore
|
|
17
|
+
expect(TestController.isController).toBe(true);
|
|
18
|
+
// @ts-ignore
|
|
19
|
+
expect(TestController.test.isEndpoint).toBe(true);
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
expect(TestController.test.metadata.summary).toBe("Test endpoint");
|
|
22
|
+
|
|
23
|
+
expect(registry.getTargets().size).toBeGreaterThan(initialSize);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should work as a class decorator for Endpoint", () => {
|
|
27
|
+
@Endpoint({ summary: "Class level" })
|
|
28
|
+
class ClassEndpoint {}
|
|
29
|
+
|
|
30
|
+
expect(registry.getTargets().has(ClassEndpoint)).toBe(true);
|
|
31
|
+
// @ts-ignore
|
|
32
|
+
expect(ClassEndpoint.isEndpoint).toBe(true);
|
|
33
|
+
// @ts-ignore
|
|
34
|
+
expect(ClassEndpoint.metadata.summary).toBe("Class level");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should work on instance methods (via prototype)", () => {
|
|
38
|
+
class InstanceController {
|
|
39
|
+
@Endpoint({ summary: "Instance method" })
|
|
40
|
+
method() {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
expect(registry.getTargets().has(InstanceController)).toBe(true);
|
|
44
|
+
// @ts-ignore
|
|
45
|
+
expect(InstanceController.prototype.method.isEndpoint).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import registry from "./registry.ts";
|
|
2
|
+
import { Container as ContainerDecorator } from "@di-framework/di-framework/decorators";
|
|
3
|
+
|
|
4
|
+
export function Controller(
|
|
5
|
+
options: { singleton?: boolean; container?: any } = {},
|
|
6
|
+
) {
|
|
7
|
+
// Compose DI registration with OpenAPI registry marking
|
|
8
|
+
const container = ContainerDecorator(options);
|
|
9
|
+
return function (target: any) {
|
|
10
|
+
// Mark for HTTP/OpenAPI purposes
|
|
11
|
+
target.isController = true;
|
|
12
|
+
registry.addTarget(target);
|
|
13
|
+
|
|
14
|
+
// Also register with the DI container (same instance as core framework)
|
|
15
|
+
container(target);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Endpoint(metadata?: {
|
|
20
|
+
summary?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
requestBody?: any;
|
|
23
|
+
responses?: Record<string, any>;
|
|
24
|
+
}) {
|
|
25
|
+
return function (target: any, propertyKey?: string) {
|
|
26
|
+
if (propertyKey) {
|
|
27
|
+
const property = target[propertyKey];
|
|
28
|
+
|
|
29
|
+
// For static methods on a class, target is the constructor.
|
|
30
|
+
// If it's a static method, target itself is the constructor.
|
|
31
|
+
const constructor =
|
|
32
|
+
typeof target === "function" ? target : target.constructor;
|
|
33
|
+
registry.addTarget(constructor);
|
|
34
|
+
|
|
35
|
+
// We'll let the generator discover the details from the property for now,
|
|
36
|
+
// or we could register it explicitly here if we had path/method info.
|
|
37
|
+
// Since TypedRouter adds path/method to the handler, we keep it as is
|
|
38
|
+
// but we can ensure the metadata is attached.
|
|
39
|
+
property.isEndpoint = true;
|
|
40
|
+
if (metadata) {
|
|
41
|
+
property.metadata = metadata;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
target.isEndpoint = true;
|
|
45
|
+
if (metadata) {
|
|
46
|
+
target.metadata = metadata;
|
|
47
|
+
}
|
|
48
|
+
registry.addTarget(target);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { generateOpenAPI } from "./openapi.ts";
|
|
3
|
+
|
|
4
|
+
describe("generateOpenAPI", () => {
|
|
5
|
+
it("should generate a default OpenAPI spec with default options", () => {
|
|
6
|
+
const spec = generateOpenAPI({}, { getTargets: () => new Set() } as any);
|
|
7
|
+
|
|
8
|
+
expect(spec.openapi).toBe("3.1.0");
|
|
9
|
+
expect(spec.info.title).toBe("Generated API");
|
|
10
|
+
expect(spec.info.version).toBe("1.0.0");
|
|
11
|
+
expect(spec.info.description).toBe(
|
|
12
|
+
"API documentation generated by @di-framework/di-framework-http.",
|
|
13
|
+
);
|
|
14
|
+
expect(spec.paths).toEqual({});
|
|
15
|
+
expect(spec.components.schemas).toEqual({});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should use provided options in the OpenAPI spec", () => {
|
|
19
|
+
const options = {
|
|
20
|
+
title: "Custom API",
|
|
21
|
+
version: "2.0.0",
|
|
22
|
+
description: "Custom description",
|
|
23
|
+
};
|
|
24
|
+
const spec = generateOpenAPI(options, {
|
|
25
|
+
getTargets: () => new Set(),
|
|
26
|
+
} as any);
|
|
27
|
+
|
|
28
|
+
expect(spec.info.title).toBe("Custom API");
|
|
29
|
+
expect(spec.info.version).toBe("2.0.0");
|
|
30
|
+
expect(spec.info.description).toBe("Custom description");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should correctly map a registry with controllers and endpoints", () => {
|
|
34
|
+
// Mocking the structure that decorators create
|
|
35
|
+
const mockController = class TestController {};
|
|
36
|
+
// @ts-ignore
|
|
37
|
+
mockController.post = () => {};
|
|
38
|
+
// @ts-ignore
|
|
39
|
+
mockController.post.isEndpoint = true;
|
|
40
|
+
// @ts-ignore
|
|
41
|
+
mockController.post.path = "/test";
|
|
42
|
+
// @ts-ignore
|
|
43
|
+
mockController.post.method = "post";
|
|
44
|
+
// @ts-ignore
|
|
45
|
+
mockController.post.metadata = {
|
|
46
|
+
summary: "Test Summary",
|
|
47
|
+
description: "Test Description",
|
|
48
|
+
requestBody: { content: { "application/json": {} } },
|
|
49
|
+
responses: { "201": { description: "Created" } },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
53
|
+
const spec = generateOpenAPI({}, registry);
|
|
54
|
+
|
|
55
|
+
expect(spec.paths["/test"]).toBeDefined();
|
|
56
|
+
expect(spec.paths["/test"].post).toBeDefined();
|
|
57
|
+
const operation = spec.paths["/test"].post;
|
|
58
|
+
expect(operation.summary).toBe("Test Summary");
|
|
59
|
+
expect(operation.description).toBe("Test Description");
|
|
60
|
+
expect(operation.operationId).toBe("TestController.post");
|
|
61
|
+
expect(operation.requestBody).toBeDefined();
|
|
62
|
+
expect(operation.responses["201"]).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should handle endpoints without metadata", () => {
|
|
66
|
+
const mockController = class SimpleController {};
|
|
67
|
+
// @ts-ignore
|
|
68
|
+
mockController.get = () => {};
|
|
69
|
+
// @ts-ignore
|
|
70
|
+
mockController.get.isEndpoint = true;
|
|
71
|
+
// @ts-ignore
|
|
72
|
+
mockController.get.path = "/simple";
|
|
73
|
+
// @ts-ignore
|
|
74
|
+
mockController.get.method = "get";
|
|
75
|
+
|
|
76
|
+
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
77
|
+
const spec = generateOpenAPI({}, registry);
|
|
78
|
+
|
|
79
|
+
const operation = spec.paths["/simple"].get;
|
|
80
|
+
expect(operation.summary).toBe("get"); // defaults to property key
|
|
81
|
+
expect(operation.responses["200"]).toBeDefined(); // default response
|
|
82
|
+
expect(operation.responses["200"].description).toBe("OK");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should handle multiple endpoints on the same path", () => {
|
|
86
|
+
const mockController = class MultiController {};
|
|
87
|
+
// @ts-ignore
|
|
88
|
+
mockController.get = () => {};
|
|
89
|
+
// @ts-ignore
|
|
90
|
+
mockController.get.isEndpoint = true;
|
|
91
|
+
// @ts-ignore
|
|
92
|
+
mockController.get.path = "/resource";
|
|
93
|
+
// @ts-ignore
|
|
94
|
+
mockController.get.method = "get";
|
|
95
|
+
|
|
96
|
+
// @ts-ignore
|
|
97
|
+
mockController.post = () => {};
|
|
98
|
+
// @ts-ignore
|
|
99
|
+
mockController.post.isEndpoint = true;
|
|
100
|
+
// @ts-ignore
|
|
101
|
+
mockController.post.path = "/resource";
|
|
102
|
+
// @ts-ignore
|
|
103
|
+
mockController.post.method = "post";
|
|
104
|
+
|
|
105
|
+
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
106
|
+
const spec = generateOpenAPI({}, registry);
|
|
107
|
+
|
|
108
|
+
expect(spec.paths["/resource"].get).toBeDefined();
|
|
109
|
+
expect(spec.paths["/resource"].post).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should handle unknown path and method defaults", () => {
|
|
113
|
+
const mockController = class WeirdController {};
|
|
114
|
+
// @ts-ignore
|
|
115
|
+
mockController.weird = () => {};
|
|
116
|
+
// @ts-ignore
|
|
117
|
+
mockController.weird.isEndpoint = true;
|
|
118
|
+
// Note: missing path and method
|
|
119
|
+
|
|
120
|
+
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
121
|
+
const spec = generateOpenAPI({}, registry);
|
|
122
|
+
|
|
123
|
+
expect(spec.paths["/unknown"]).toBeDefined();
|
|
124
|
+
expect(spec.paths["/unknown"].get).toBeDefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import registry from "./registry.ts";
|
|
2
|
+
|
|
3
|
+
export type OpenAPIOptions = {
|
|
4
|
+
title?: string;
|
|
5
|
+
version?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
outputPath?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function generateOpenAPI(
|
|
11
|
+
options: OpenAPIOptions = {},
|
|
12
|
+
registryToUse = registry,
|
|
13
|
+
) {
|
|
14
|
+
const spec: any = {
|
|
15
|
+
openapi: "3.1.0",
|
|
16
|
+
info: {
|
|
17
|
+
title: options.title || "Generated API",
|
|
18
|
+
version: options.version || "1.0.0",
|
|
19
|
+
description:
|
|
20
|
+
options.description ||
|
|
21
|
+
"API documentation generated by @di-framework/di-framework-http.",
|
|
22
|
+
},
|
|
23
|
+
paths: {},
|
|
24
|
+
components: {
|
|
25
|
+
schemas: {},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const targets = registryToUse.getTargets();
|
|
30
|
+
|
|
31
|
+
for (const target of targets) {
|
|
32
|
+
// Look for endpoints on the target (static properties)
|
|
33
|
+
for (const key of Object.getOwnPropertyNames(target)) {
|
|
34
|
+
const property = target[key];
|
|
35
|
+
if (property && property.isEndpoint) {
|
|
36
|
+
const path = property.path || "/unknown";
|
|
37
|
+
const method = property.method || "get";
|
|
38
|
+
|
|
39
|
+
if (!spec.paths[path]) {
|
|
40
|
+
spec.paths[path] = {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
spec.paths[path][method] = {
|
|
44
|
+
summary: property.metadata?.summary || key,
|
|
45
|
+
description: property.metadata?.description,
|
|
46
|
+
operationId: `${target.name}.${key}`,
|
|
47
|
+
requestBody: property.metadata?.requestBody,
|
|
48
|
+
responses: property.metadata?.responses || {
|
|
49
|
+
"200": {
|
|
50
|
+
description: "OK",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return spec;
|
|
59
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface EndpointMetadata {
|
|
2
|
+
summary?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
requestBody?: any;
|
|
5
|
+
responses?: Record<string, any>;
|
|
6
|
+
[key: string]: any;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RegisteredEndpoint {
|
|
10
|
+
target: any;
|
|
11
|
+
propertyKey: string;
|
|
12
|
+
path: string;
|
|
13
|
+
method: string;
|
|
14
|
+
metadata: EndpointMetadata;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class Registry {
|
|
18
|
+
private targets = new Set<any>();
|
|
19
|
+
|
|
20
|
+
addTarget(target: any) {
|
|
21
|
+
this.targets.add(target);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getTargets() {
|
|
25
|
+
return this.targets;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const GLOBAL_KEY = Symbol.for("@di-framework/http-registry");
|
|
30
|
+
|
|
31
|
+
const registry: Registry =
|
|
32
|
+
(globalThis as any)[GLOBAL_KEY] ?? ((globalThis as any)[GLOBAL_KEY] = new Registry());
|
|
33
|
+
|
|
34
|
+
export default registry;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
TypedRouter,
|
|
4
|
+
json,
|
|
5
|
+
type Multipart,
|
|
6
|
+
type RequestSpec,
|
|
7
|
+
type ResponseSpec,
|
|
8
|
+
} from "./typed-router.ts";
|
|
9
|
+
|
|
10
|
+
describe("TypedRouter", () => {
|
|
11
|
+
it("should handle GET requests", async () => {
|
|
12
|
+
const router = TypedRouter();
|
|
13
|
+
router.get("/test", () => json({ ok: true }));
|
|
14
|
+
|
|
15
|
+
const req = new Request("http://localhost/test");
|
|
16
|
+
const res = await router.fetch(req);
|
|
17
|
+
expect(res.status).toBe(200);
|
|
18
|
+
const body = await res.json();
|
|
19
|
+
expect(body).toEqual({ ok: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should handle POST requests with JSON content-type", async () => {
|
|
23
|
+
const router = TypedRouter();
|
|
24
|
+
router.post("/test", (req) => json({ received: req.content }));
|
|
25
|
+
|
|
26
|
+
const req = new Request("http://localhost/test", {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ hello: "world" }),
|
|
30
|
+
});
|
|
31
|
+
const res = await router.fetch(req);
|
|
32
|
+
expect(res.status).toBe(200);
|
|
33
|
+
const body = await res.json();
|
|
34
|
+
expect(body).toEqual({ received: { hello: "world" } });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should reject POST requests without application/json", async () => {
|
|
38
|
+
const router = TypedRouter();
|
|
39
|
+
router.post("/test", () => json({ ok: true }));
|
|
40
|
+
|
|
41
|
+
const req = new Request("http://localhost/test", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "Content-Type": "text/plain" },
|
|
44
|
+
body: "hello",
|
|
45
|
+
});
|
|
46
|
+
const res = await router.fetch(req);
|
|
47
|
+
expect(res.status).toBe(415);
|
|
48
|
+
const body = (await res.json()) as any;
|
|
49
|
+
expect(body.error).toBe("Content-Type must be application/json");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should attach path and method to the handler", () => {
|
|
53
|
+
const router = TypedRouter();
|
|
54
|
+
const handler = router.get("/metadata", () => json({}));
|
|
55
|
+
|
|
56
|
+
expect(handler.path).toBe("/metadata");
|
|
57
|
+
expect(handler.method).toBe("get");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should support extra arguments in fetch", async () => {
|
|
61
|
+
type Env = { BINDING: string };
|
|
62
|
+
const router = TypedRouter<[Env]>();
|
|
63
|
+
router.get("/env", (req, env) => json({ binding: env.BINDING }));
|
|
64
|
+
|
|
65
|
+
const req = new Request("http://localhost/env");
|
|
66
|
+
const res = await router.fetch(req, { BINDING: "value" });
|
|
67
|
+
const body = await res.json();
|
|
68
|
+
expect(body).toEqual({ binding: "value" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should support other HTTP methods", async () => {
|
|
72
|
+
const router = TypedRouter();
|
|
73
|
+
router.put("/put", () => json({ method: "PUT" }));
|
|
74
|
+
router.delete("/delete", () => json({ method: "DELETE" }));
|
|
75
|
+
router.patch("/patch", () => json({ method: "PATCH" }));
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
(
|
|
79
|
+
(await (
|
|
80
|
+
await router.fetch(
|
|
81
|
+
new Request("http://localhost/put", {
|
|
82
|
+
method: "PUT",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: "{}",
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
).json()) as any
|
|
88
|
+
).method,
|
|
89
|
+
).toBe("PUT");
|
|
90
|
+
expect(
|
|
91
|
+
(
|
|
92
|
+
(await (
|
|
93
|
+
await router.fetch(
|
|
94
|
+
new Request("http://localhost/delete", { method: "DELETE" }),
|
|
95
|
+
)
|
|
96
|
+
).json()) as any
|
|
97
|
+
).method,
|
|
98
|
+
).toBe("DELETE");
|
|
99
|
+
expect(
|
|
100
|
+
(
|
|
101
|
+
(await (
|
|
102
|
+
await router.fetch(
|
|
103
|
+
new Request("http://localhost/patch", {
|
|
104
|
+
method: "PATCH",
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
body: "{}",
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
).json()) as any
|
|
110
|
+
).method,
|
|
111
|
+
).toBe("PATCH");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should handle multipart POST requests with { multipart: true }", async () => {
|
|
115
|
+
const router = TypedRouter();
|
|
116
|
+
router.post<
|
|
117
|
+
RequestSpec<Multipart<{ file: File }>>,
|
|
118
|
+
ResponseSpec<{ ok: boolean }>
|
|
119
|
+
>(
|
|
120
|
+
"/upload",
|
|
121
|
+
(req) => {
|
|
122
|
+
return json({ ok: req.content instanceof FormData });
|
|
123
|
+
},
|
|
124
|
+
{ multipart: true },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const formData = new FormData();
|
|
128
|
+
formData.append("file", new Blob(["hello"]), "test.txt");
|
|
129
|
+
|
|
130
|
+
const req = new Request("http://localhost/upload", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: formData,
|
|
133
|
+
});
|
|
134
|
+
const res = await router.fetch(req);
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
const body = (await res.json()) as any;
|
|
137
|
+
expect(body.ok).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should parse FormData as req.content in multipart handlers", async () => {
|
|
141
|
+
const router = TypedRouter();
|
|
142
|
+
router.post(
|
|
143
|
+
"/upload",
|
|
144
|
+
(req) => {
|
|
145
|
+
const content = req.content as FormData;
|
|
146
|
+
const name = content.get("name");
|
|
147
|
+
return json({ name });
|
|
148
|
+
},
|
|
149
|
+
{ multipart: true },
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const formData = new FormData();
|
|
153
|
+
formData.append("name", "test-file");
|
|
154
|
+
|
|
155
|
+
const req = new Request("http://localhost/upload", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
body: formData,
|
|
158
|
+
});
|
|
159
|
+
const res = await router.fetch(req);
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
const body = (await res.json()) as any;
|
|
162
|
+
expect(body.name).toBe("test-file");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("should not enforce JSON content-type on multipart routes", async () => {
|
|
166
|
+
const router = TypedRouter();
|
|
167
|
+
router.post("/upload", () => json({ ok: true }), { multipart: true });
|
|
168
|
+
|
|
169
|
+
const formData = new FormData();
|
|
170
|
+
formData.append("field", "value");
|
|
171
|
+
|
|
172
|
+
const req = new Request("http://localhost/upload", {
|
|
173
|
+
method: "POST",
|
|
174
|
+
body: formData,
|
|
175
|
+
});
|
|
176
|
+
const res = await router.fetch(req);
|
|
177
|
+
// Should NOT return 415
|
|
178
|
+
expect(res.status).toBe(200);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should still reject non-JSON on non-multipart POST routes (backward compat)", async () => {
|
|
182
|
+
const router = TypedRouter();
|
|
183
|
+
router.post("/json-only", () => json({ ok: true }));
|
|
184
|
+
|
|
185
|
+
const formData = new FormData();
|
|
186
|
+
formData.append("field", "value");
|
|
187
|
+
|
|
188
|
+
const req = new Request("http://localhost/json-only", {
|
|
189
|
+
method: "POST",
|
|
190
|
+
body: formData,
|
|
191
|
+
});
|
|
192
|
+
const res = await router.fetch(req);
|
|
193
|
+
expect(res.status).toBe(415);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Router,
|
|
3
|
+
withContent,
|
|
4
|
+
json as ittyJson,
|
|
5
|
+
type IRequest,
|
|
6
|
+
} from "itty-router";
|
|
7
|
+
|
|
8
|
+
/** Marker for body "shape + content-type" */
|
|
9
|
+
export type Json<T> = { readonly __kind: "json"; readonly __type?: T };
|
|
10
|
+
export type Multipart<T> = {
|
|
11
|
+
readonly __kind: "multipart";
|
|
12
|
+
readonly __type?: T;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Spec types you’ll use in generics */
|
|
16
|
+
export type RequestSpec<BodySpec = unknown> = { readonly __req?: BodySpec };
|
|
17
|
+
export type ResponseSpec<Body = unknown> = { readonly __res?: Body };
|
|
18
|
+
|
|
19
|
+
/** Map a BodySpec to the actual req.content type */
|
|
20
|
+
type ContentOf<BodySpec> =
|
|
21
|
+
BodySpec extends Json<infer T>
|
|
22
|
+
? T
|
|
23
|
+
: BodySpec extends Multipart<infer _T>
|
|
24
|
+
? FormData
|
|
25
|
+
: unknown;
|
|
26
|
+
|
|
27
|
+
/** The actual request type your handlers receive */
|
|
28
|
+
export type TypedRequest<ReqSpec> = IRequest & {
|
|
29
|
+
content: ContentOf<ReqSpec extends RequestSpec<infer B> ? B : never>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Typed response helper (phantom only) */
|
|
33
|
+
export type TypedResponse<ResSpec> = globalThis.Response & {
|
|
34
|
+
readonly __typedRes?: ResSpec;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** HandlerController type derived from the Request/Response specs */
|
|
38
|
+
export type HandlerController<ReqSpec, ResSpec, Args extends any[] = any[]> = (
|
|
39
|
+
req: TypedRequest<ReqSpec>,
|
|
40
|
+
...args: Args
|
|
41
|
+
) => TypedResponse<ResSpec> | Promise<TypedResponse<ResSpec>>;
|
|
42
|
+
|
|
43
|
+
/** A typed json() that returns a Response annotated with Response<T> */
|
|
44
|
+
export function json<T>(
|
|
45
|
+
data: T,
|
|
46
|
+
init?: ResponseInit,
|
|
47
|
+
): TypedResponse<ResponseSpec<T>> {
|
|
48
|
+
return ittyJson(data as any, init) as any;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type RouteOptions = { multipart?: boolean };
|
|
52
|
+
|
|
53
|
+
export type TypedRoute<Args extends any[] = any[]> = <
|
|
54
|
+
ReqSpec = RequestSpec<unknown>,
|
|
55
|
+
ResSpec = ResponseSpec<unknown>,
|
|
56
|
+
>(
|
|
57
|
+
path: string,
|
|
58
|
+
controller: HandlerController<ReqSpec, ResSpec, Args>,
|
|
59
|
+
options?: RouteOptions,
|
|
60
|
+
) => TypedRouterType<Args> & {
|
|
61
|
+
path: string;
|
|
62
|
+
method: string;
|
|
63
|
+
reqSpec: ReqSpec;
|
|
64
|
+
resSpec: ResSpec;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type TypedRouterType<Args extends any[] = any[]> = {
|
|
68
|
+
get: TypedRoute<Args>;
|
|
69
|
+
post: TypedRoute<Args>;
|
|
70
|
+
put: TypedRoute<Args>;
|
|
71
|
+
delete: TypedRoute<Args>;
|
|
72
|
+
patch: TypedRoute<Args>;
|
|
73
|
+
head: TypedRoute<Args>;
|
|
74
|
+
options: TypedRoute<Args>;
|
|
75
|
+
// Add other methods as needed, or use a more generic approach
|
|
76
|
+
fetch: (request: Request, ...args: Args) => Promise<Response>;
|
|
77
|
+
[key: string]: any;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function TypedRouter<Args extends any[] = any[]>(
|
|
81
|
+
opts?: Parameters<typeof Router>[0],
|
|
82
|
+
): TypedRouterType<Args> {
|
|
83
|
+
const r = Router(opts);
|
|
84
|
+
|
|
85
|
+
function enforceJson(req: globalThis.Request) {
|
|
86
|
+
const ct = (req.headers.get("content-type") ?? "").toLowerCase();
|
|
87
|
+
if (!ct.includes("application/json") && !ct.includes("+json")) {
|
|
88
|
+
return ittyJson(
|
|
89
|
+
{ error: "Content-Type must be application/json" },
|
|
90
|
+
{ status: 415 },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function withFormData(req: IRequest) {
|
|
97
|
+
try {
|
|
98
|
+
(req as any).content = await (req as globalThis.Request).formData();
|
|
99
|
+
} catch {
|
|
100
|
+
// leave content undefined if parsing fails
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const methodsToProxy = [
|
|
105
|
+
"get",
|
|
106
|
+
"post",
|
|
107
|
+
"put",
|
|
108
|
+
"delete",
|
|
109
|
+
"patch",
|
|
110
|
+
"head",
|
|
111
|
+
"options",
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const wrapper: any = new Proxy(r, {
|
|
115
|
+
get(target, prop, receiver) {
|
|
116
|
+
if (typeof prop === "string" && methodsToProxy.includes(prop)) {
|
|
117
|
+
return (
|
|
118
|
+
path: string,
|
|
119
|
+
controller: HandlerController<any, any, Args>,
|
|
120
|
+
options?: RouteOptions,
|
|
121
|
+
) => {
|
|
122
|
+
const handler = (...args: any[]) => {
|
|
123
|
+
const req = args[0] as IRequest & { content?: unknown };
|
|
124
|
+
const extraArgs = args.slice(1) as Args;
|
|
125
|
+
if (prop === "post" || prop === "put" || prop === "patch") {
|
|
126
|
+
if (!options?.multipart) {
|
|
127
|
+
const ctErr = enforceJson(req);
|
|
128
|
+
if (ctErr) return ctErr;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return controller(req as any, ...extraArgs);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const middleware = options?.multipart ? withFormData : withContent;
|
|
135
|
+
target[prop](path, middleware, handler);
|
|
136
|
+
|
|
137
|
+
const routeInfo = {
|
|
138
|
+
path,
|
|
139
|
+
method: prop,
|
|
140
|
+
handler,
|
|
141
|
+
// These are just markers for the generator to know they are there
|
|
142
|
+
// We can't easily pass the actual type at runtime but we can pass names if we had them
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
Object.assign(handler, routeInfo);
|
|
146
|
+
|
|
147
|
+
return handler;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const value = Reflect.get(target, prop, receiver);
|
|
151
|
+
if (typeof value === "function") {
|
|
152
|
+
return (...args: any[]) => {
|
|
153
|
+
const result = value.apply(target, args);
|
|
154
|
+
return result === target ? wrapper : result;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return wrapper;
|
|
162
|
+
}
|