@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/src/openapi.test.ts
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
|
-
import { describe, it, expect } from
|
|
2
|
-
import { generateOpenAPI } from
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import { generateOpenAPI } from './openapi.ts';
|
|
3
|
+
import { SCHEMAS } from './decorators.ts';
|
|
3
4
|
|
|
4
|
-
describe(
|
|
5
|
-
it(
|
|
5
|
+
describe('generateOpenAPI', () => {
|
|
6
|
+
it('should generate a default OpenAPI spec with default options', () => {
|
|
6
7
|
const spec = generateOpenAPI({}, { getTargets: () => new Set() } as any);
|
|
7
8
|
|
|
8
|
-
expect(spec.openapi).toBe(
|
|
9
|
-
expect(spec.info.title).toBe(
|
|
10
|
-
expect(spec.info.version).toBe(
|
|
9
|
+
expect(spec.openapi).toBe('3.1.0');
|
|
10
|
+
expect(spec.info.title).toBe('Generated API');
|
|
11
|
+
expect(spec.info.version).toBe('1.0.0');
|
|
11
12
|
expect(spec.info.description).toBe(
|
|
12
|
-
|
|
13
|
+
'API documentation generated by @di-framework/di-framework-http.',
|
|
13
14
|
);
|
|
14
15
|
expect(spec.paths).toEqual({});
|
|
15
16
|
expect(spec.components.schemas).toEqual({});
|
|
16
17
|
});
|
|
17
18
|
|
|
18
|
-
it(
|
|
19
|
+
it('should use provided options in the OpenAPI spec', () => {
|
|
19
20
|
const options = {
|
|
20
|
-
title:
|
|
21
|
-
version:
|
|
22
|
-
description:
|
|
21
|
+
title: 'Custom API',
|
|
22
|
+
version: '2.0.0',
|
|
23
|
+
description: 'Custom description',
|
|
23
24
|
};
|
|
24
25
|
const spec = generateOpenAPI(options, {
|
|
25
26
|
getTargets: () => new Set(),
|
|
26
27
|
} as any);
|
|
27
28
|
|
|
28
|
-
expect(spec.info.title).toBe(
|
|
29
|
-
expect(spec.info.version).toBe(
|
|
30
|
-
expect(spec.info.description).toBe(
|
|
29
|
+
expect(spec.info.title).toBe('Custom API');
|
|
30
|
+
expect(spec.info.version).toBe('2.0.0');
|
|
31
|
+
expect(spec.info.description).toBe('Custom description');
|
|
31
32
|
});
|
|
32
33
|
|
|
33
|
-
it(
|
|
34
|
+
it('should correctly map a registry with controllers and endpoints', () => {
|
|
34
35
|
// Mocking the structure that decorators create
|
|
35
36
|
const mockController = class TestController {};
|
|
36
37
|
// @ts-ignore
|
|
@@ -38,78 +39,78 @@ describe("generateOpenAPI", () => {
|
|
|
38
39
|
// @ts-ignore
|
|
39
40
|
mockController.post.isEndpoint = true;
|
|
40
41
|
// @ts-ignore
|
|
41
|
-
mockController.post.path =
|
|
42
|
+
mockController.post.path = '/test';
|
|
42
43
|
// @ts-ignore
|
|
43
|
-
mockController.post.method =
|
|
44
|
+
mockController.post.method = 'post';
|
|
44
45
|
// @ts-ignore
|
|
45
46
|
mockController.post.metadata = {
|
|
46
|
-
summary:
|
|
47
|
-
description:
|
|
48
|
-
requestBody: { content: {
|
|
49
|
-
responses: {
|
|
47
|
+
summary: 'Test Summary',
|
|
48
|
+
description: 'Test Description',
|
|
49
|
+
requestBody: { content: { 'application/json': {} } },
|
|
50
|
+
responses: { '201': { description: 'Created' } },
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
53
54
|
const spec = generateOpenAPI({}, registry);
|
|
54
55
|
|
|
55
|
-
expect(spec.paths[
|
|
56
|
-
expect(spec.paths[
|
|
57
|
-
const operation = spec.paths[
|
|
58
|
-
expect(operation.summary).toBe(
|
|
59
|
-
expect(operation.description).toBe(
|
|
60
|
-
expect(operation.operationId).toBe(
|
|
56
|
+
expect(spec.paths['/test']).toBeDefined();
|
|
57
|
+
expect(spec.paths['/test'].post).toBeDefined();
|
|
58
|
+
const operation = spec.paths['/test'].post;
|
|
59
|
+
expect(operation.summary).toBe('Test Summary');
|
|
60
|
+
expect(operation.description).toBe('Test Description');
|
|
61
|
+
expect(operation.operationId).toBe('TestController.post');
|
|
61
62
|
expect(operation.requestBody).toBeDefined();
|
|
62
|
-
expect(operation.responses[
|
|
63
|
+
expect(operation.responses['201']).toBeDefined();
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
it(
|
|
66
|
+
it('should handle endpoints without metadata', () => {
|
|
66
67
|
const mockController = class SimpleController {};
|
|
67
68
|
// @ts-ignore
|
|
68
69
|
mockController.get = () => {};
|
|
69
70
|
// @ts-ignore
|
|
70
71
|
mockController.get.isEndpoint = true;
|
|
71
72
|
// @ts-ignore
|
|
72
|
-
mockController.get.path =
|
|
73
|
+
mockController.get.path = '/simple';
|
|
73
74
|
// @ts-ignore
|
|
74
|
-
mockController.get.method =
|
|
75
|
+
mockController.get.method = 'get';
|
|
75
76
|
|
|
76
77
|
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
77
78
|
const spec = generateOpenAPI({}, registry);
|
|
78
79
|
|
|
79
|
-
const operation = spec.paths[
|
|
80
|
-
expect(operation.summary).toBe(
|
|
81
|
-
expect(operation.responses[
|
|
82
|
-
expect(operation.responses[
|
|
80
|
+
const operation = spec.paths['/simple'].get;
|
|
81
|
+
expect(operation.summary).toBe('get'); // defaults to property key
|
|
82
|
+
expect(operation.responses['200']).toBeDefined(); // default response
|
|
83
|
+
expect(operation.responses['200'].description).toBe('OK');
|
|
83
84
|
});
|
|
84
85
|
|
|
85
|
-
it(
|
|
86
|
+
it('should handle multiple endpoints on the same path', () => {
|
|
86
87
|
const mockController = class MultiController {};
|
|
87
88
|
// @ts-ignore
|
|
88
89
|
mockController.get = () => {};
|
|
89
90
|
// @ts-ignore
|
|
90
91
|
mockController.get.isEndpoint = true;
|
|
91
92
|
// @ts-ignore
|
|
92
|
-
mockController.get.path =
|
|
93
|
+
mockController.get.path = '/resource';
|
|
93
94
|
// @ts-ignore
|
|
94
|
-
mockController.get.method =
|
|
95
|
+
mockController.get.method = 'get';
|
|
95
96
|
|
|
96
97
|
// @ts-ignore
|
|
97
98
|
mockController.post = () => {};
|
|
98
99
|
// @ts-ignore
|
|
99
100
|
mockController.post.isEndpoint = true;
|
|
100
101
|
// @ts-ignore
|
|
101
|
-
mockController.post.path =
|
|
102
|
+
mockController.post.path = '/resource';
|
|
102
103
|
// @ts-ignore
|
|
103
|
-
mockController.post.method =
|
|
104
|
+
mockController.post.method = 'post';
|
|
104
105
|
|
|
105
106
|
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
106
107
|
const spec = generateOpenAPI({}, registry);
|
|
107
108
|
|
|
108
|
-
expect(spec.paths[
|
|
109
|
-
expect(spec.paths[
|
|
109
|
+
expect(spec.paths['/resource'].get).toBeDefined();
|
|
110
|
+
expect(spec.paths['/resource'].post).toBeDefined();
|
|
110
111
|
});
|
|
111
112
|
|
|
112
|
-
it(
|
|
113
|
+
it('should handle unknown path and method defaults', () => {
|
|
113
114
|
const mockController = class WeirdController {};
|
|
114
115
|
// @ts-ignore
|
|
115
116
|
mockController.weird = () => {};
|
|
@@ -120,7 +121,89 @@ describe("generateOpenAPI", () => {
|
|
|
120
121
|
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
121
122
|
const spec = generateOpenAPI({}, registry);
|
|
122
123
|
|
|
123
|
-
expect(spec.paths[
|
|
124
|
-
expect(spec.paths[
|
|
124
|
+
expect(spec.paths['/unknown']).toBeDefined();
|
|
125
|
+
expect(spec.paths['/unknown'].get).toBeDefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should convert :param paths to {param} and inject path parameters', () => {
|
|
129
|
+
const mockController = class ParamController {};
|
|
130
|
+
// @ts-ignore
|
|
131
|
+
mockController.getUser = () => {};
|
|
132
|
+
// @ts-ignore
|
|
133
|
+
mockController.getUser.isEndpoint = true;
|
|
134
|
+
// @ts-ignore
|
|
135
|
+
mockController.getUser.path = '/users/:userId/posts/:postId';
|
|
136
|
+
// @ts-ignore
|
|
137
|
+
mockController.getUser.method = 'get';
|
|
138
|
+
|
|
139
|
+
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
140
|
+
const spec = generateOpenAPI({}, registry);
|
|
141
|
+
|
|
142
|
+
const openApiPath = '/users/{userId}/posts/{postId}';
|
|
143
|
+
expect(spec.paths[openApiPath]).toBeDefined();
|
|
144
|
+
const operation = spec.paths[openApiPath].get;
|
|
145
|
+
expect(operation.parameters).toBeDefined();
|
|
146
|
+
expect(operation.parameters.length).toBe(2);
|
|
147
|
+
expect(operation.parameters[0]).toEqual({
|
|
148
|
+
name: 'userId',
|
|
149
|
+
in: 'path',
|
|
150
|
+
required: true,
|
|
151
|
+
schema: { type: 'string' },
|
|
152
|
+
});
|
|
153
|
+
expect(operation.parameters[1].name).toBe('postId');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should combine automatic path parameters with decorator parameters', () => {
|
|
157
|
+
const mockController = class MixController {};
|
|
158
|
+
// @ts-ignore
|
|
159
|
+
mockController.get = () => {};
|
|
160
|
+
// @ts-ignore
|
|
161
|
+
mockController.get.isEndpoint = true;
|
|
162
|
+
// @ts-ignore
|
|
163
|
+
mockController.get.path = '/items/:id';
|
|
164
|
+
// @ts-ignore
|
|
165
|
+
mockController.get.method = 'get';
|
|
166
|
+
// @ts-ignore
|
|
167
|
+
mockController.get.metadata = {
|
|
168
|
+
parameters: [{ name: 'filter', in: 'query' }],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
172
|
+
const spec = generateOpenAPI({}, registry);
|
|
173
|
+
|
|
174
|
+
const operation = spec.paths['/items/{id}'].get;
|
|
175
|
+
expect(operation.parameters).toBeDefined();
|
|
176
|
+
expect(operation.parameters.length).toBe(2);
|
|
177
|
+
expect(operation.parameters[0].name).toBe('id');
|
|
178
|
+
expect(operation.parameters[0].in).toBe('path');
|
|
179
|
+
expect(operation.parameters[1].name).toBe('filter');
|
|
180
|
+
expect(operation.parameters[1].in).toBe('query');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should resolve referenced component schemas via the SCHEMAS symbol', () => {
|
|
184
|
+
const mockController = class SchemaController {};
|
|
185
|
+
// @ts-ignore
|
|
186
|
+
mockController[SCHEMAS] = new Set(['User', 'Post']);
|
|
187
|
+
|
|
188
|
+
const registry = { getTargets: () => new Set([mockController]) } as any;
|
|
189
|
+
|
|
190
|
+
// Provide the schema definitions in options
|
|
191
|
+
const schemas = {
|
|
192
|
+
User: {
|
|
193
|
+
type: 'object',
|
|
194
|
+
properties: { id: { type: 'string' }, profile: { $ref: '#/components/schemas/Profile' } },
|
|
195
|
+
},
|
|
196
|
+
Post: { type: 'object', properties: { title: { type: 'string' } } },
|
|
197
|
+
Profile: { type: 'object', properties: { age: { type: 'integer' } } }, // Transitive ref
|
|
198
|
+
Unused: { type: 'object' }, // Should not be included
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const spec = generateOpenAPI({ schemas }, registry);
|
|
202
|
+
|
|
203
|
+
const resolved = spec.components.schemas;
|
|
204
|
+
expect(resolved['User']).toBeDefined();
|
|
205
|
+
expect(resolved['Post']).toBeDefined();
|
|
206
|
+
expect(resolved['Profile']).toBeDefined(); // Transitively resolved!
|
|
207
|
+
expect(resolved['Unused']).toBeUndefined();
|
|
125
208
|
});
|
|
126
209
|
});
|
package/src/openapi.ts
CHANGED
|
@@ -1,24 +1,81 @@
|
|
|
1
|
-
import registry from
|
|
1
|
+
import registry from './registry.ts';
|
|
2
|
+
import { SCHEMAS } from './decorators.ts';
|
|
2
3
|
|
|
3
4
|
export type OpenAPIOptions = {
|
|
4
5
|
title?: string;
|
|
5
6
|
version?: string;
|
|
6
7
|
description?: string;
|
|
7
8
|
outputPath?: string;
|
|
9
|
+
schemas?: Record<string, unknown>;
|
|
8
10
|
};
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** Recursively extract `$ref` schema names from a value. */
|
|
13
|
+
function collectRefs(obj: unknown, out: Set<string>): void {
|
|
14
|
+
if (typeof obj !== 'object' || obj === null) return;
|
|
15
|
+
|
|
16
|
+
if (Array.isArray(obj)) {
|
|
17
|
+
for (const item of obj) collectRefs(item, out);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
22
|
+
if (key === '$ref' && typeof value === 'string') {
|
|
23
|
+
const match = /^#\/components\/schemas\/(.+)$/.exec(value);
|
|
24
|
+
if (match?.[1]) out.add(match[1]);
|
|
25
|
+
} else {
|
|
26
|
+
collectRefs(value, out);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a schema name and all schemas it transitively references.
|
|
33
|
+
* Writes resolved schemas into `resolved`.
|
|
34
|
+
*/
|
|
35
|
+
function resolveSchema(
|
|
36
|
+
name: string,
|
|
37
|
+
resolved: Record<string, unknown>,
|
|
38
|
+
schemas: Record<string, unknown>,
|
|
39
|
+
): void {
|
|
40
|
+
if (name in resolved) return;
|
|
41
|
+
|
|
42
|
+
const schema = schemas[name];
|
|
43
|
+
if (!schema) return;
|
|
44
|
+
|
|
45
|
+
resolved[name] = schema;
|
|
46
|
+
|
|
47
|
+
// Find transitive refs within the schema itself
|
|
48
|
+
const transitive = new Set<string>();
|
|
49
|
+
collectRefs(schema, transitive);
|
|
50
|
+
for (const dep of transitive) {
|
|
51
|
+
resolveSchema(dep, resolved, schemas);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Convert itty-router `:param` paths to OpenAPI `{param}` format. */
|
|
56
|
+
function toOpenAPIPath(path: string): string {
|
|
57
|
+
return path.replace(/:([a-zA-Z_]\w*)/g, '{$1}');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Extract path parameter names from an itty-router path. */
|
|
61
|
+
function extractPathParams(path: string): string[] {
|
|
62
|
+
const names: string[] = [];
|
|
63
|
+
const re = /:([a-zA-Z_]\w*)/g;
|
|
64
|
+
let m: RegExpExecArray | null;
|
|
65
|
+
while ((m = re.exec(path)) !== null) {
|
|
66
|
+
if (m[1]) names.push(m[1]);
|
|
67
|
+
}
|
|
68
|
+
return names;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function generateOpenAPI(options: OpenAPIOptions = {}, registryToUse = registry) {
|
|
14
72
|
const spec: any = {
|
|
15
|
-
openapi:
|
|
73
|
+
openapi: '3.1.0',
|
|
16
74
|
info: {
|
|
17
|
-
title: options.title ||
|
|
18
|
-
version: options.version ||
|
|
75
|
+
title: options.title || 'Generated API',
|
|
76
|
+
version: options.version || '1.0.0',
|
|
19
77
|
description:
|
|
20
|
-
options.description ||
|
|
21
|
-
"API documentation generated by @di-framework/di-framework-http.",
|
|
78
|
+
options.description || 'API documentation generated by @di-framework/di-framework-http.',
|
|
22
79
|
},
|
|
23
80
|
paths: {},
|
|
24
81
|
components: {
|
|
@@ -28,13 +85,19 @@ export function generateOpenAPI(
|
|
|
28
85
|
|
|
29
86
|
const targets = registryToUse.getTargets();
|
|
30
87
|
|
|
88
|
+
const metaParamsMap = new Map<string, unknown[]>();
|
|
89
|
+
|
|
31
90
|
for (const target of targets) {
|
|
32
91
|
// Look for endpoints on the target (static properties)
|
|
33
92
|
for (const key of Object.getOwnPropertyNames(target)) {
|
|
34
|
-
const property = target[key];
|
|
93
|
+
const property = (target as any)[key];
|
|
35
94
|
if (property && property.isEndpoint) {
|
|
36
|
-
const path = property.path ||
|
|
37
|
-
const method = property.method ||
|
|
95
|
+
const path = property.path || '/unknown';
|
|
96
|
+
const method = (property.method || 'get').toLowerCase();
|
|
97
|
+
|
|
98
|
+
if (property.metadata?.parameters) {
|
|
99
|
+
metaParamsMap.set(`${path}|${method}`, property.metadata.parameters as unknown[]);
|
|
100
|
+
}
|
|
38
101
|
|
|
39
102
|
if (!spec.paths[path]) {
|
|
40
103
|
spec.paths[path] = {};
|
|
@@ -46,8 +109,8 @@ export function generateOpenAPI(
|
|
|
46
109
|
operationId: `${target.name}.${key}`,
|
|
47
110
|
requestBody: property.metadata?.requestBody,
|
|
48
111
|
responses: property.metadata?.responses || {
|
|
49
|
-
|
|
50
|
-
description:
|
|
112
|
+
'200': {
|
|
113
|
+
description: 'OK',
|
|
51
114
|
},
|
|
52
115
|
},
|
|
53
116
|
};
|
|
@@ -55,5 +118,46 @@ export function generateOpenAPI(
|
|
|
55
118
|
}
|
|
56
119
|
}
|
|
57
120
|
|
|
121
|
+
// Rewrite paths: convert :param → {param} and inject parameters
|
|
122
|
+
const rewrittenPaths: Record<string, Record<string, unknown>> = {};
|
|
123
|
+
|
|
124
|
+
for (const [rawPath, methods] of Object.entries(spec.paths)) {
|
|
125
|
+
const openApiPath = toOpenAPIPath(rawPath);
|
|
126
|
+
const pathParamNames = extractPathParams(rawPath);
|
|
127
|
+
|
|
128
|
+
const autoParams = pathParamNames.map((name) => ({
|
|
129
|
+
name,
|
|
130
|
+
in: 'path',
|
|
131
|
+
required: true,
|
|
132
|
+
schema: { type: 'string' },
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
rewrittenPaths[openApiPath] ??= {};
|
|
136
|
+
|
|
137
|
+
for (const [method, operation] of Object.entries(methods as Record<string, any>)) {
|
|
138
|
+
const decoratorParams = metaParamsMap.get(`${rawPath}|${method}`) ?? [];
|
|
139
|
+
if (autoParams.length > 0 || decoratorParams.length > 0) {
|
|
140
|
+
operation.parameters = [...autoParams, ...decoratorParams];
|
|
141
|
+
}
|
|
142
|
+
rewrittenPaths[openApiPath][method] = operation;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
spec.paths = rewrittenPaths;
|
|
147
|
+
|
|
148
|
+
// Collect schemas from decorator Symbols
|
|
149
|
+
const resolved: Record<string, unknown> = {};
|
|
150
|
+
|
|
151
|
+
for (const target of targets) {
|
|
152
|
+
const refs: Set<string> | undefined = (target as Record<symbol, Set<string>>)[SCHEMAS];
|
|
153
|
+
if (!refs) continue;
|
|
154
|
+
|
|
155
|
+
for (const name of refs) {
|
|
156
|
+
resolveSchema(name, resolved, options.schemas || {});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
spec.components.schemas = resolved;
|
|
161
|
+
|
|
58
162
|
return spec;
|
|
59
163
|
}
|
package/src/registry.ts
CHANGED
|
@@ -26,7 +26,7 @@ export class Registry {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const GLOBAL_KEY = Symbol.for(
|
|
29
|
+
const GLOBAL_KEY = Symbol.for('@di-framework/http-registry');
|
|
30
30
|
|
|
31
31
|
const registry: Registry =
|
|
32
32
|
(globalThis as any)[GLOBAL_KEY] ?? ((globalThis as any)[GLOBAL_KEY] = new Registry());
|