@di-framework/di-framework-http 0.0.0-prerelease.302 → 0.0.0-prerelease.305

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 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/decorators.ts
124
- init_registry();
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/registry.ts
32
- var exports_registry = {};
33
- __export(exports_registry, {
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, registry_default;
48
- var init_registry = __esm(() => {
49
- registry = new Registry;
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
- const imported = await import(controllersPathResolved);
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
- }, registryToUse);
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
@@ -0,0 +1,5 @@
1
+ export * from "./src/typed-router.ts";
2
+ export * from "./src/decorators.ts";
3
+ export * from "./src/openapi.ts";
4
+ export * from "./src/registry.ts";
5
+ export { default as registry } from "./src/registry.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@di-framework/di-framework-http",
3
- "version": "0.0.0-prerelease.302",
3
+ "version": "0.0.0-prerelease.305",
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.301",
43
+ "@di-framework/di-framework": "^0.0.0-prerelease.305",
42
44
  "typescript": "^5"
43
45
  }
44
46
  }
@@ -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
+ }
@@ -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
+ }