@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.
@@ -1,36 +1,37 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { generateOpenAPI } from "./openapi.ts";
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { generateOpenAPI } from './openapi.ts';
3
+ import { SCHEMAS } from './decorators.ts';
3
4
 
4
- describe("generateOpenAPI", () => {
5
- it("should generate a default OpenAPI spec with default options", () => {
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("3.1.0");
9
- expect(spec.info.title).toBe("Generated API");
10
- expect(spec.info.version).toBe("1.0.0");
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
- "API documentation generated by @di-framework/di-framework-http.",
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("should use provided options in the OpenAPI spec", () => {
19
+ it('should use provided options in the OpenAPI spec', () => {
19
20
  const options = {
20
- title: "Custom API",
21
- version: "2.0.0",
22
- description: "Custom 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("Custom API");
29
- expect(spec.info.version).toBe("2.0.0");
30
- expect(spec.info.description).toBe("Custom description");
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("should correctly map a registry with controllers and endpoints", () => {
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 = "/test";
42
+ mockController.post.path = '/test';
42
43
  // @ts-ignore
43
- mockController.post.method = "post";
44
+ mockController.post.method = 'post';
44
45
  // @ts-ignore
45
46
  mockController.post.metadata = {
46
- summary: "Test Summary",
47
- description: "Test Description",
48
- requestBody: { content: { "application/json": {} } },
49
- responses: { "201": { description: "Created" } },
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["/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");
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["201"]).toBeDefined();
63
+ expect(operation.responses['201']).toBeDefined();
63
64
  });
64
65
 
65
- it("should handle endpoints without metadata", () => {
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 = "/simple";
73
+ mockController.get.path = '/simple';
73
74
  // @ts-ignore
74
- mockController.get.method = "get";
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["/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");
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("should handle multiple endpoints on the same path", () => {
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 = "/resource";
93
+ mockController.get.path = '/resource';
93
94
  // @ts-ignore
94
- mockController.get.method = "get";
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 = "/resource";
102
+ mockController.post.path = '/resource';
102
103
  // @ts-ignore
103
- mockController.post.method = "post";
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["/resource"].get).toBeDefined();
109
- expect(spec.paths["/resource"].post).toBeDefined();
109
+ expect(spec.paths['/resource'].get).toBeDefined();
110
+ expect(spec.paths['/resource'].post).toBeDefined();
110
111
  });
111
112
 
112
- it("should handle unknown path and method defaults", () => {
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["/unknown"]).toBeDefined();
124
- expect(spec.paths["/unknown"].get).toBeDefined();
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 "./registry.ts";
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
- export function generateOpenAPI(
11
- options: OpenAPIOptions = {},
12
- registryToUse = registry,
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: "3.1.0",
73
+ openapi: '3.1.0',
16
74
  info: {
17
- title: options.title || "Generated API",
18
- version: options.version || "1.0.0",
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 || "/unknown";
37
- const method = property.method || "get";
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
- "200": {
50
- description: "OK",
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("@di-framework/http-registry");
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());