@di-framework/di-framework-http 0.0.0-prerelease.308 → 0.0.0-prerelease.310
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/README.md +25 -26
- package/dist/index.d.ts +5 -5
- package/dist/index.js +238 -19
- package/dist/src/cli.js +638 -1
- package/dist/src/decorators.d.ts +2 -0
- package/dist/src/openapi.d.ts +1 -0
- package/dist/src/typed-router.d.ts +4 -4
- package/index.ts +5 -5
- package/package.json +2 -2
- package/src/cli.test.ts +34 -43
- package/src/cli.ts +11 -14
- package/src/decorators.test.ts +62 -13
- package/src/decorators.ts +67 -11
- package/src/openapi.test.ts +129 -46
- package/src/openapi.ts +119 -15
- package/src/registry.ts +1 -1
- package/src/typed-router.test.ts +72 -77
- package/src/typed-router.ts +16 -42
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export * from
|
|
3
|
-
export * from
|
|
4
|
-
export * from
|
|
5
|
-
export { default as registry } from
|
|
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.
|
|
3
|
+
"version": "0.0.0-prerelease.310",
|
|
4
4
|
"description": "Extends di-framework with HTTP features",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"typescript": "^5"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@di-framework/di-framework": "^0.0.0-prerelease.
|
|
43
|
+
"@di-framework/di-framework": "^0.0.0-prerelease.310",
|
|
44
44
|
"typescript": "^5"
|
|
45
45
|
}
|
|
46
46
|
}
|
package/src/cli.test.ts
CHANGED
|
@@ -1,85 +1,76 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from
|
|
2
|
-
import { spawnSync } from
|
|
3
|
-
import fs from
|
|
4
|
-
import path from
|
|
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
5
|
|
|
6
|
-
const CLI_PATH = path.resolve(import.meta.dir,
|
|
7
|
-
const TEST_OUTPUT =
|
|
6
|
+
const CLI_PATH = path.resolve(import.meta.dir, 'cli.ts');
|
|
7
|
+
const TEST_OUTPUT = 'test.openapi.json';
|
|
8
8
|
|
|
9
|
-
describe(
|
|
9
|
+
describe('CLI', () => {
|
|
10
10
|
afterEach(() => {
|
|
11
11
|
if (fs.existsSync(TEST_OUTPUT)) {
|
|
12
12
|
fs.unlinkSync(TEST_OUTPUT);
|
|
13
13
|
}
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
it(
|
|
17
|
-
const { stdout } = spawnSync(
|
|
18
|
-
expect(stdout.toString()).toContain(
|
|
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
19
|
});
|
|
20
20
|
|
|
21
|
-
it(
|
|
22
|
-
const { stderr, status } = spawnSync(
|
|
21
|
+
it('should error when --controllers is missing', () => {
|
|
22
|
+
const { stderr, status } = spawnSync('bun', [CLI_PATH, 'generate']);
|
|
23
23
|
expect(status).toBe(1);
|
|
24
|
-
expect(stderr.toString()).toContain(
|
|
25
|
-
"Error: --controllers path is required",
|
|
26
|
-
);
|
|
24
|
+
expect(stderr.toString()).toContain('Error: --controllers path is required');
|
|
27
25
|
});
|
|
28
26
|
|
|
29
|
-
it(
|
|
27
|
+
it('should generate OpenAPI spec with --controllers and --output', () => {
|
|
30
28
|
const controllersPath = path.resolve(
|
|
31
29
|
import.meta.dir,
|
|
32
|
-
|
|
30
|
+
'../../examples/packages/http-router/index.ts',
|
|
33
31
|
);
|
|
34
|
-
const { stdout, status } = spawnSync(
|
|
32
|
+
const { stdout, status } = spawnSync('bun', [
|
|
35
33
|
CLI_PATH,
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
'generate',
|
|
35
|
+
'--controllers',
|
|
38
36
|
controllersPath,
|
|
39
|
-
|
|
37
|
+
'--output',
|
|
40
38
|
TEST_OUTPUT,
|
|
41
39
|
]);
|
|
42
40
|
|
|
43
41
|
expect(status).toBe(0);
|
|
44
|
-
expect(stdout.toString()).toContain(
|
|
45
|
-
"Successfully generated OpenAPI spec at test.openapi.json",
|
|
46
|
-
);
|
|
42
|
+
expect(stdout.toString()).toContain('Successfully generated OpenAPI spec at test.openapi.json');
|
|
47
43
|
expect(fs.existsSync(TEST_OUTPUT)).toBe(true);
|
|
48
44
|
|
|
49
|
-
const spec = JSON.parse(fs.readFileSync(TEST_OUTPUT,
|
|
50
|
-
expect(spec.openapi).toBe(
|
|
51
|
-
expect(spec.paths[
|
|
52
|
-
expect(spec.paths[
|
|
45
|
+
const spec = JSON.parse(fs.readFileSync(TEST_OUTPUT, 'utf8'));
|
|
46
|
+
expect(spec.openapi).toBe('3.1.0');
|
|
47
|
+
expect(spec.paths['/echo']).toBeDefined();
|
|
48
|
+
expect(spec.paths['/echo'].post).toBeDefined();
|
|
53
49
|
});
|
|
54
50
|
|
|
55
|
-
it(
|
|
56
|
-
const DEFAULT_OUTPUT =
|
|
51
|
+
it('should use default output path (openapi.json) if --output is missing', () => {
|
|
52
|
+
const DEFAULT_OUTPUT = 'openapi.json';
|
|
57
53
|
if (fs.existsSync(DEFAULT_OUTPUT)) fs.unlinkSync(DEFAULT_OUTPUT);
|
|
58
54
|
|
|
59
55
|
const controllersPath = path.resolve(
|
|
60
56
|
import.meta.dir,
|
|
61
|
-
|
|
57
|
+
'../../examples/packages/http-router/index.ts',
|
|
62
58
|
);
|
|
63
|
-
const { status } = spawnSync(
|
|
64
|
-
CLI_PATH,
|
|
65
|
-
"generate",
|
|
66
|
-
"--controllers",
|
|
67
|
-
controllersPath,
|
|
68
|
-
]);
|
|
59
|
+
const { status } = spawnSync('bun', [CLI_PATH, 'generate', '--controllers', controllersPath]);
|
|
69
60
|
|
|
70
61
|
expect(status).toBe(0);
|
|
71
62
|
expect(fs.existsSync(DEFAULT_OUTPUT)).toBe(true);
|
|
72
63
|
fs.unlinkSync(DEFAULT_OUTPUT);
|
|
73
64
|
});
|
|
74
65
|
|
|
75
|
-
it(
|
|
76
|
-
const { stderr, status } = spawnSync(
|
|
66
|
+
it('should error with invalid controllers path', () => {
|
|
67
|
+
const { stderr, status } = spawnSync('bun', [
|
|
77
68
|
CLI_PATH,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
69
|
+
'generate',
|
|
70
|
+
'--controllers',
|
|
71
|
+
'./non-existent-file.ts',
|
|
81
72
|
]);
|
|
82
73
|
expect(status).toBe(1);
|
|
83
|
-
expect(stderr.toString()).toContain(
|
|
74
|
+
expect(stderr.toString()).toContain('Error generating OpenAPI spec');
|
|
84
75
|
});
|
|
85
76
|
});
|
package/src/cli.ts
CHANGED
|
@@ -1,34 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import fs from
|
|
3
|
-
import path from
|
|
4
|
-
import { generateOpenAPI } from
|
|
5
|
-
import registry from
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { generateOpenAPI } from './openapi.ts';
|
|
5
|
+
import registry from './registry.ts';
|
|
6
6
|
|
|
7
7
|
const args = process.argv.slice(2);
|
|
8
8
|
const command = args[0];
|
|
9
9
|
|
|
10
|
-
if (command ===
|
|
11
|
-
const outputArg = args.indexOf(
|
|
12
|
-
const outputPath = outputArg !== -1 ? args[outputArg + 1] :
|
|
10
|
+
if (command === 'generate') {
|
|
11
|
+
const outputArg = args.indexOf('--output');
|
|
12
|
+
const outputPath = outputArg !== -1 ? args[outputArg + 1] : 'openapi.json';
|
|
13
13
|
|
|
14
|
-
const controllersArg = args.indexOf(
|
|
14
|
+
const controllersArg = args.indexOf('--controllers');
|
|
15
15
|
if (controllersArg === -1) {
|
|
16
|
-
console.error(
|
|
16
|
+
console.error('Error: --controllers path is required');
|
|
17
17
|
process.exit(1);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
async function run() {
|
|
21
21
|
try {
|
|
22
22
|
// Import the user's controllers to trigger decorator registration
|
|
23
|
-
const controllersPathResolved = path.resolve(
|
|
24
|
-
process.cwd(),
|
|
25
|
-
args[controllersArg + 1]!,
|
|
26
|
-
);
|
|
23
|
+
const controllersPathResolved = path.resolve(process.cwd(), args[controllersArg + 1]!);
|
|
27
24
|
await import(controllersPathResolved);
|
|
28
25
|
|
|
29
26
|
const spec = generateOpenAPI(
|
|
30
27
|
{
|
|
31
|
-
title:
|
|
28
|
+
title: 'API Documentation',
|
|
32
29
|
},
|
|
33
30
|
registry,
|
|
34
31
|
);
|
package/src/decorators.test.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { describe, it, expect } from
|
|
2
|
-
import { Controller, Endpoint } from
|
|
3
|
-
import registry from
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import { Controller, Endpoint, SCHEMAS } from './decorators.ts';
|
|
3
|
+
import registry from './registry.ts';
|
|
4
|
+
import { useContainer } from '@di-framework/di-framework/container';
|
|
5
|
+
import { Component } from '@di-framework/di-framework/decorators';
|
|
4
6
|
|
|
5
|
-
describe(
|
|
6
|
-
it(
|
|
7
|
+
describe('Decorators', () => {
|
|
8
|
+
it('should register a controller and its endpoints', () => {
|
|
7
9
|
const initialSize = registry.getTargets().size;
|
|
8
10
|
|
|
9
11
|
@Controller()
|
|
10
12
|
class TestController {
|
|
11
|
-
@Endpoint({ summary:
|
|
12
|
-
static test = { isEndpoint: true, path:
|
|
13
|
+
@Endpoint({ summary: 'Test endpoint' })
|
|
14
|
+
static test = { isEndpoint: true, path: '/test', method: 'get' };
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
expect(registry.getTargets().has(TestController)).toBe(true);
|
|
@@ -18,25 +20,25 @@ describe("Decorators", () => {
|
|
|
18
20
|
// @ts-ignore
|
|
19
21
|
expect(TestController.test.isEndpoint).toBe(true);
|
|
20
22
|
// @ts-ignore
|
|
21
|
-
expect(TestController.test.metadata.summary).toBe(
|
|
23
|
+
expect(TestController.test.metadata.summary).toBe('Test endpoint');
|
|
22
24
|
|
|
23
25
|
expect(registry.getTargets().size).toBeGreaterThan(initialSize);
|
|
24
26
|
});
|
|
25
27
|
|
|
26
|
-
it(
|
|
27
|
-
@Endpoint({ summary:
|
|
28
|
+
it('should work as a class decorator for Endpoint', () => {
|
|
29
|
+
@Endpoint({ summary: 'Class level' })
|
|
28
30
|
class ClassEndpoint {}
|
|
29
31
|
|
|
30
32
|
expect(registry.getTargets().has(ClassEndpoint)).toBe(true);
|
|
31
33
|
// @ts-ignore
|
|
32
34
|
expect(ClassEndpoint.isEndpoint).toBe(true);
|
|
33
35
|
// @ts-ignore
|
|
34
|
-
expect(ClassEndpoint.metadata.summary).toBe(
|
|
36
|
+
expect(ClassEndpoint.metadata.summary).toBe('Class level');
|
|
35
37
|
});
|
|
36
38
|
|
|
37
|
-
it(
|
|
39
|
+
it('should work on instance methods (via prototype)', () => {
|
|
38
40
|
class InstanceController {
|
|
39
|
-
@Endpoint({ summary:
|
|
41
|
+
@Endpoint({ summary: 'Instance method' })
|
|
40
42
|
method() {}
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -44,4 +46,51 @@ describe("Decorators", () => {
|
|
|
44
46
|
// @ts-ignore
|
|
45
47
|
expect(InstanceController.prototype.method.isEndpoint).toBe(true);
|
|
46
48
|
});
|
|
49
|
+
|
|
50
|
+
it('should inject static properties on Controller classes', () => {
|
|
51
|
+
class InjectedService {
|
|
52
|
+
value = 42;
|
|
53
|
+
}
|
|
54
|
+
useContainer().register(InjectedService);
|
|
55
|
+
|
|
56
|
+
@Controller()
|
|
57
|
+
class InjectedController {
|
|
58
|
+
@Component(InjectedService)
|
|
59
|
+
static service: InjectedService;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
expect(InjectedController.service).toBeDefined();
|
|
63
|
+
expect(InjectedController.service.value).toBe(42);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should extract schemas from Endpoint metadata and store them via SCHEMAS symbol', () => {
|
|
67
|
+
class SchemaEndpoint {
|
|
68
|
+
@Endpoint({
|
|
69
|
+
responses: {
|
|
70
|
+
'200': {
|
|
71
|
+
description: 'A successful response',
|
|
72
|
+
content: {
|
|
73
|
+
'application/json': {
|
|
74
|
+
schema: { $ref: '#/components/schemas/MyModel' },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
requestBody: {
|
|
80
|
+
content: {
|
|
81
|
+
'application/json': {
|
|
82
|
+
schema: { $ref: '#/components/schemas/MyRequest' },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
static myEndpoint() {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const schemas = (SchemaEndpoint as any)[SCHEMAS];
|
|
91
|
+
expect(schemas).toBeDefined();
|
|
92
|
+
expect(schemas.has('MyModel')).toBe(true);
|
|
93
|
+
expect(schemas.has('MyRequest')).toBe(true);
|
|
94
|
+
expect(schemas.size).toBe(2);
|
|
95
|
+
});
|
|
47
96
|
});
|
package/src/decorators.ts
CHANGED
|
@@ -1,24 +1,67 @@
|
|
|
1
|
-
import registry from
|
|
2
|
-
import { Container as ContainerDecorator } from
|
|
1
|
+
import registry from './registry.ts';
|
|
2
|
+
import { Container as ContainerDecorator } from '@di-framework/di-framework/decorators';
|
|
3
|
+
import { getOwnMetadata, useContainer } from '@di-framework/di-framework/container';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const INJECT_METADATA_KEY = 'di:inject';
|
|
6
|
+
export const SCHEMAS = Symbol.for('proseva:component-schemas');
|
|
7
|
+
|
|
8
|
+
type UnknownRecord = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
function isRecord(value: unknown): value is UnknownRecord {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ContainerLike {
|
|
15
|
+
resolve(token: unknown): unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Controller(options: { singleton?: boolean; container?: any } = {}) {
|
|
7
19
|
// Compose DI registration with OpenAPI registry marking
|
|
8
|
-
const
|
|
20
|
+
const containerDecorator = ContainerDecorator(options);
|
|
9
21
|
return function (target: any) {
|
|
10
22
|
// Mark for HTTP/OpenAPI purposes
|
|
11
23
|
target.isController = true;
|
|
12
24
|
registry.addTarget(target);
|
|
13
25
|
|
|
14
26
|
// Also register with the DI container (same instance as core framework)
|
|
15
|
-
|
|
27
|
+
containerDecorator(target);
|
|
28
|
+
|
|
29
|
+
const container: ContainerLike = (options.container ?? useContainer()) as ContainerLike;
|
|
30
|
+
|
|
31
|
+
const rawMetadata: unknown = getOwnMetadata(INJECT_METADATA_KEY, target) as unknown;
|
|
32
|
+
const injectMetadata: UnknownRecord = isRecord(rawMetadata) ? rawMetadata : {};
|
|
33
|
+
|
|
34
|
+
for (const [propName, targetType] of Object.entries(injectMetadata)) {
|
|
35
|
+
if (!propName.startsWith('param_') && targetType) {
|
|
36
|
+
(target as UnknownRecord)[propName] = container.resolve(targetType);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
16
39
|
};
|
|
17
40
|
}
|
|
18
41
|
|
|
42
|
+
/** Extract all `#/components/schemas/<Name>` references from a metadata tree. */
|
|
43
|
+
function extractSchemaRefs(obj: unknown, out: Set<string>): void {
|
|
44
|
+
if (typeof obj !== 'object' || obj === null) return;
|
|
45
|
+
|
|
46
|
+
if (Array.isArray(obj)) {
|
|
47
|
+
for (const item of obj) extractSchemaRefs(item, out);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
52
|
+
if (key === '$ref' && typeof value === 'string') {
|
|
53
|
+
const match = /^#\/components\/schemas\/(.+)$/.exec(value);
|
|
54
|
+
if (match?.[1]) out.add(match[1]);
|
|
55
|
+
} else {
|
|
56
|
+
extractSchemaRefs(value, out);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
19
61
|
export function Endpoint(metadata?: {
|
|
20
62
|
summary?: string;
|
|
21
63
|
description?: string;
|
|
64
|
+
parameters?: unknown[];
|
|
22
65
|
requestBody?: any;
|
|
23
66
|
responses?: Record<string, any>;
|
|
24
67
|
}) {
|
|
@@ -28,22 +71,35 @@ export function Endpoint(metadata?: {
|
|
|
28
71
|
|
|
29
72
|
// For static methods on a class, target is the constructor.
|
|
30
73
|
// If it's a static method, target itself is the constructor.
|
|
31
|
-
const constructor =
|
|
32
|
-
typeof target === "function" ? target : target.constructor;
|
|
74
|
+
const constructor = typeof target === 'function' ? target : target.constructor;
|
|
33
75
|
registry.addTarget(constructor);
|
|
34
76
|
|
|
35
77
|
// We'll let the generator discover the details from the property for now,
|
|
36
78
|
// or we could register it explicitly here if we had path/method info.
|
|
37
79
|
// Since TypedRouter adds path/method to the handler, we keep it as is
|
|
38
80
|
// but we can ensure the metadata is attached.
|
|
39
|
-
property
|
|
81
|
+
if (property) {
|
|
82
|
+
property.isEndpoint = true;
|
|
83
|
+
if (metadata) {
|
|
84
|
+
property.metadata = metadata;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Also attach schemas if metadata is provided
|
|
40
89
|
if (metadata) {
|
|
41
|
-
|
|
90
|
+
const existing: Set<string> =
|
|
91
|
+
(constructor as Record<symbol, Set<string>>)[SCHEMAS] ?? new Set<string>();
|
|
92
|
+
extractSchemaRefs(metadata, existing);
|
|
93
|
+
(constructor as Record<symbol, Set<string>>)[SCHEMAS] = existing;
|
|
42
94
|
}
|
|
43
95
|
} else {
|
|
44
96
|
target.isEndpoint = true;
|
|
45
97
|
if (metadata) {
|
|
46
98
|
target.metadata = metadata;
|
|
99
|
+
const existing: Set<string> =
|
|
100
|
+
(target as Record<symbol, Set<string>>)[SCHEMAS] ?? new Set<string>();
|
|
101
|
+
extractSchemaRefs(metadata, existing);
|
|
102
|
+
(target as Record<symbol, Set<string>>)[SCHEMAS] = existing;
|
|
47
103
|
}
|
|
48
104
|
registry.addTarget(target);
|
|
49
105
|
}
|