@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/typed-router.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
2
|
import {
|
|
3
3
|
TypedRouter,
|
|
4
4
|
json,
|
|
@@ -7,119 +7,114 @@ import {
|
|
|
7
7
|
type ResponseSpec,
|
|
8
8
|
type PathParams,
|
|
9
9
|
type QueryParams,
|
|
10
|
-
} from
|
|
10
|
+
} from './typed-router.ts';
|
|
11
11
|
|
|
12
|
-
describe(
|
|
13
|
-
it(
|
|
12
|
+
describe('TypedRouter', () => {
|
|
13
|
+
it('should handle GET requests', async () => {
|
|
14
14
|
const router = TypedRouter();
|
|
15
|
-
router.get(
|
|
15
|
+
router.get('/test', () => json({ ok: true }));
|
|
16
16
|
|
|
17
|
-
const req = new Request(
|
|
17
|
+
const req = new Request('http://localhost/test');
|
|
18
18
|
const res = await router.fetch(req);
|
|
19
19
|
expect(res.status).toBe(200);
|
|
20
20
|
const body = await res.json();
|
|
21
21
|
expect(body).toEqual({ ok: true });
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
it(
|
|
24
|
+
it('should handle POST requests with JSON content-type', async () => {
|
|
25
25
|
const router = TypedRouter();
|
|
26
|
-
router.post(
|
|
26
|
+
router.post('/test', (req) => json({ received: req.content }));
|
|
27
27
|
|
|
28
|
-
const req = new Request(
|
|
29
|
-
method:
|
|
30
|
-
headers: {
|
|
31
|
-
body: JSON.stringify({ hello:
|
|
28
|
+
const req = new Request('http://localhost/test', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ hello: 'world' }),
|
|
32
32
|
});
|
|
33
33
|
const res = await router.fetch(req);
|
|
34
34
|
expect(res.status).toBe(200);
|
|
35
35
|
const body = await res.json();
|
|
36
|
-
expect(body).toEqual({ received: { hello:
|
|
36
|
+
expect(body).toEqual({ received: { hello: 'world' } });
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
it(
|
|
39
|
+
it('should reject POST requests without application/json', async () => {
|
|
40
40
|
const router = TypedRouter();
|
|
41
|
-
router.post(
|
|
41
|
+
router.post('/test', () => json({ ok: true }));
|
|
42
42
|
|
|
43
|
-
const req = new Request(
|
|
44
|
-
method:
|
|
45
|
-
headers: {
|
|
46
|
-
body:
|
|
43
|
+
const req = new Request('http://localhost/test', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
46
|
+
body: 'hello',
|
|
47
47
|
});
|
|
48
48
|
const res = await router.fetch(req);
|
|
49
49
|
expect(res.status).toBe(415);
|
|
50
50
|
const body = (await res.json()) as any;
|
|
51
|
-
expect(body.error).toBe(
|
|
51
|
+
expect(body.error).toBe('Content-Type must be application/json');
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
it(
|
|
54
|
+
it('should attach path and method to the handler', () => {
|
|
55
55
|
const router = TypedRouter();
|
|
56
|
-
const handler = router.get(
|
|
56
|
+
const handler = router.get('/metadata', () => json({}));
|
|
57
57
|
|
|
58
|
-
expect(handler.path).toBe(
|
|
59
|
-
expect(handler.method).toBe(
|
|
58
|
+
expect(handler.path).toBe('/metadata');
|
|
59
|
+
expect(handler.method).toBe('get');
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it(
|
|
62
|
+
it('should support extra arguments in fetch', async () => {
|
|
63
63
|
type Env = { BINDING: string };
|
|
64
64
|
const router = TypedRouter<[Env]>();
|
|
65
|
-
router.get(
|
|
65
|
+
router.get('/env', (req, env) => json({ binding: env.BINDING }));
|
|
66
66
|
|
|
67
|
-
const req = new Request(
|
|
68
|
-
const res = await router.fetch(req, { BINDING:
|
|
67
|
+
const req = new Request('http://localhost/env');
|
|
68
|
+
const res = await router.fetch(req, { BINDING: 'value' });
|
|
69
69
|
const body = await res.json();
|
|
70
|
-
expect(body).toEqual({ binding:
|
|
70
|
+
expect(body).toEqual({ binding: 'value' });
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
it(
|
|
73
|
+
it('should support other HTTP methods', async () => {
|
|
74
74
|
const router = TypedRouter();
|
|
75
|
-
router.put(
|
|
76
|
-
router.delete(
|
|
77
|
-
router.patch(
|
|
75
|
+
router.put('/put', () => json({ method: 'PUT' }));
|
|
76
|
+
router.delete('/delete', () => json({ method: 'DELETE' }));
|
|
77
|
+
router.patch('/patch', () => json({ method: 'PATCH' }));
|
|
78
78
|
|
|
79
79
|
expect(
|
|
80
80
|
(
|
|
81
81
|
(await (
|
|
82
82
|
await router.fetch(
|
|
83
|
-
new Request(
|
|
84
|
-
method:
|
|
85
|
-
headers: {
|
|
86
|
-
body:
|
|
83
|
+
new Request('http://localhost/put', {
|
|
84
|
+
method: 'PUT',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: '{}',
|
|
87
87
|
}),
|
|
88
88
|
)
|
|
89
89
|
).json()) as any
|
|
90
90
|
).method,
|
|
91
|
-
).toBe(
|
|
91
|
+
).toBe('PUT');
|
|
92
92
|
expect(
|
|
93
93
|
(
|
|
94
94
|
(await (
|
|
95
|
-
await router.fetch(
|
|
96
|
-
new Request("http://localhost/delete", { method: "DELETE" }),
|
|
97
|
-
)
|
|
95
|
+
await router.fetch(new Request('http://localhost/delete', { method: 'DELETE' }))
|
|
98
96
|
).json()) as any
|
|
99
97
|
).method,
|
|
100
|
-
).toBe(
|
|
98
|
+
).toBe('DELETE');
|
|
101
99
|
expect(
|
|
102
100
|
(
|
|
103
101
|
(await (
|
|
104
102
|
await router.fetch(
|
|
105
|
-
new Request(
|
|
106
|
-
method:
|
|
107
|
-
headers: {
|
|
108
|
-
body:
|
|
103
|
+
new Request('http://localhost/patch', {
|
|
104
|
+
method: 'PATCH',
|
|
105
|
+
headers: { 'Content-Type': 'application/json' },
|
|
106
|
+
body: '{}',
|
|
109
107
|
}),
|
|
110
108
|
)
|
|
111
109
|
).json()) as any
|
|
112
110
|
).method,
|
|
113
|
-
).toBe(
|
|
111
|
+
).toBe('PATCH');
|
|
114
112
|
});
|
|
115
113
|
|
|
116
|
-
it(
|
|
114
|
+
it('should handle multipart POST requests with { multipart: true }', async () => {
|
|
117
115
|
const router = TypedRouter();
|
|
118
|
-
router.post<
|
|
119
|
-
|
|
120
|
-
ResponseSpec<{ ok: boolean }>
|
|
121
|
-
>(
|
|
122
|
-
"/upload",
|
|
116
|
+
router.post<RequestSpec<Multipart<{ file: File }>>, ResponseSpec<{ ok: boolean }>>(
|
|
117
|
+
'/upload',
|
|
123
118
|
(req) => {
|
|
124
119
|
return json({ ok: req.content instanceof FormData });
|
|
125
120
|
},
|
|
@@ -127,10 +122,10 @@ describe("TypedRouter", () => {
|
|
|
127
122
|
);
|
|
128
123
|
|
|
129
124
|
const formData = new FormData();
|
|
130
|
-
formData.append(
|
|
125
|
+
formData.append('file', new Blob(['hello']), 'test.txt');
|
|
131
126
|
|
|
132
|
-
const req = new Request(
|
|
133
|
-
method:
|
|
127
|
+
const req = new Request('http://localhost/upload', {
|
|
128
|
+
method: 'POST',
|
|
134
129
|
body: formData,
|
|
135
130
|
});
|
|
136
131
|
const res = await router.fetch(req);
|
|
@@ -139,40 +134,40 @@ describe("TypedRouter", () => {
|
|
|
139
134
|
expect(body.ok).toBe(true);
|
|
140
135
|
});
|
|
141
136
|
|
|
142
|
-
it(
|
|
137
|
+
it('should parse FormData as req.content in multipart handlers', async () => {
|
|
143
138
|
const router = TypedRouter();
|
|
144
139
|
router.post(
|
|
145
|
-
|
|
140
|
+
'/upload',
|
|
146
141
|
(req) => {
|
|
147
142
|
const content = req.content as FormData;
|
|
148
|
-
const name = content.get(
|
|
143
|
+
const name = content.get('name');
|
|
149
144
|
return json({ name });
|
|
150
145
|
},
|
|
151
146
|
{ multipart: true },
|
|
152
147
|
);
|
|
153
148
|
|
|
154
149
|
const formData = new FormData();
|
|
155
|
-
formData.append(
|
|
150
|
+
formData.append('name', 'test-file');
|
|
156
151
|
|
|
157
|
-
const req = new Request(
|
|
158
|
-
method:
|
|
152
|
+
const req = new Request('http://localhost/upload', {
|
|
153
|
+
method: 'POST',
|
|
159
154
|
body: formData,
|
|
160
155
|
});
|
|
161
156
|
const res = await router.fetch(req);
|
|
162
157
|
expect(res.status).toBe(200);
|
|
163
158
|
const body = (await res.json()) as any;
|
|
164
|
-
expect(body.name).toBe(
|
|
159
|
+
expect(body.name).toBe('test-file');
|
|
165
160
|
});
|
|
166
161
|
|
|
167
|
-
it(
|
|
162
|
+
it('should not enforce JSON content-type on multipart routes', async () => {
|
|
168
163
|
const router = TypedRouter();
|
|
169
|
-
router.post(
|
|
164
|
+
router.post('/upload', () => json({ ok: true }), { multipart: true });
|
|
170
165
|
|
|
171
166
|
const formData = new FormData();
|
|
172
|
-
formData.append(
|
|
167
|
+
formData.append('field', 'value');
|
|
173
168
|
|
|
174
|
-
const req = new Request(
|
|
175
|
-
method:
|
|
169
|
+
const req = new Request('http://localhost/upload', {
|
|
170
|
+
method: 'POST',
|
|
176
171
|
body: formData,
|
|
177
172
|
});
|
|
178
173
|
const res = await router.fetch(req);
|
|
@@ -180,37 +175,37 @@ describe("TypedRouter", () => {
|
|
|
180
175
|
expect(res.status).toBe(200);
|
|
181
176
|
});
|
|
182
177
|
|
|
183
|
-
it(
|
|
178
|
+
it('should still reject non-JSON on non-multipart POST routes (backward compat)', async () => {
|
|
184
179
|
const router = TypedRouter();
|
|
185
|
-
router.post(
|
|
180
|
+
router.post('/json-only', () => json({ ok: true }));
|
|
186
181
|
|
|
187
182
|
const formData = new FormData();
|
|
188
|
-
formData.append(
|
|
183
|
+
formData.append('field', 'value');
|
|
189
184
|
|
|
190
|
-
const req = new Request(
|
|
191
|
-
method:
|
|
185
|
+
const req = new Request('http://localhost/json-only', {
|
|
186
|
+
method: 'POST',
|
|
192
187
|
body: formData,
|
|
193
188
|
});
|
|
194
189
|
const res = await router.fetch(req);
|
|
195
190
|
expect(res.status).toBe(415);
|
|
196
191
|
});
|
|
197
192
|
|
|
198
|
-
it(
|
|
193
|
+
it('should type params and query from RequestSpec', async () => {
|
|
199
194
|
const router = TypedRouter();
|
|
200
195
|
router.get<
|
|
201
196
|
RequestSpec<PathParams<{ id: string }> & QueryParams<{ search?: string }>>,
|
|
202
197
|
ResponseSpec<{ id: string; search?: string }>
|
|
203
|
-
>(
|
|
198
|
+
>('/item/:id', (req) => {
|
|
204
199
|
// Type testing:
|
|
205
200
|
const id: string = req.params.id;
|
|
206
201
|
const search: string | undefined = req.query.search;
|
|
207
202
|
return json({ id, search });
|
|
208
203
|
});
|
|
209
204
|
|
|
210
|
-
const req = new Request(
|
|
205
|
+
const req = new Request('http://localhost/item/123?search=test');
|
|
211
206
|
const res = await router.fetch(req);
|
|
212
207
|
expect(res.status).toBe(200);
|
|
213
208
|
const body = (await res.json()) as any;
|
|
214
|
-
expect(body).toEqual({ id:
|
|
209
|
+
expect(body).toEqual({ id: '123', search: 'test' });
|
|
215
210
|
});
|
|
216
211
|
});
|
package/src/typed-router.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Router,
|
|
3
|
-
withContent,
|
|
4
|
-
json as ittyJson,
|
|
5
|
-
type IRequest,
|
|
6
|
-
} from "itty-router";
|
|
1
|
+
import { Router, withContent, json as ittyJson, type IRequest } from 'itty-router';
|
|
7
2
|
|
|
8
3
|
/** Marker for body "shape + content-type" */
|
|
9
|
-
export type Json<T> = { readonly __kind:
|
|
4
|
+
export type Json<T> = { readonly __kind: 'json'; readonly __type?: T };
|
|
10
5
|
export type Multipart<T> = {
|
|
11
|
-
readonly __kind:
|
|
6
|
+
readonly __kind: 'multipart';
|
|
12
7
|
readonly __type?: T;
|
|
13
8
|
};
|
|
14
9
|
export type PathParams<T> = { readonly __pathParams?: T };
|
|
@@ -20,22 +15,15 @@ export type ResponseSpec<Body = unknown> = { readonly __res?: Body };
|
|
|
20
15
|
|
|
21
16
|
/** Map a BodySpec to the actual req.content type */
|
|
22
17
|
type ContentOf<BodySpec> =
|
|
23
|
-
BodySpec extends Json<infer T>
|
|
24
|
-
? T
|
|
25
|
-
: BodySpec extends Multipart<infer _T>
|
|
26
|
-
? FormData
|
|
27
|
-
: unknown;
|
|
18
|
+
BodySpec extends Json<infer T> ? T : BodySpec extends Multipart<infer _T> ? FormData : unknown;
|
|
28
19
|
|
|
29
|
-
type PathParamsOf<BodySpec> = BodySpec extends PathParams<infer T>
|
|
30
|
-
? T
|
|
31
|
-
: Record<string, string>;
|
|
20
|
+
type PathParamsOf<BodySpec> = BodySpec extends PathParams<infer T> ? T : Record<string, string>;
|
|
32
21
|
|
|
33
|
-
type QueryParamsOf<BodySpec> =
|
|
34
|
-
? T
|
|
35
|
-
: Record<string, string | string[] | undefined>;
|
|
22
|
+
type QueryParamsOf<BodySpec> =
|
|
23
|
+
BodySpec extends QueryParams<infer T> ? T : Record<string, string | string[] | undefined>;
|
|
36
24
|
|
|
37
25
|
/** The actual request type your handlers receive */
|
|
38
|
-
export type TypedRequest<ReqSpec> = Omit<IRequest,
|
|
26
|
+
export type TypedRequest<ReqSpec> = Omit<IRequest, 'params' | 'query'> & {
|
|
39
27
|
content: ContentOf<ReqSpec extends RequestSpec<infer B> ? B : never>;
|
|
40
28
|
params: PathParamsOf<ReqSpec extends RequestSpec<infer B> ? B : never>;
|
|
41
29
|
query: QueryParamsOf<ReqSpec extends RequestSpec<infer B> ? B : never>;
|
|
@@ -53,10 +41,7 @@ export type HandlerController<ReqSpec, ResSpec, Args extends any[] = any[]> = (
|
|
|
53
41
|
) => TypedResponse<ResSpec> | Promise<TypedResponse<ResSpec>>;
|
|
54
42
|
|
|
55
43
|
/** A typed json() that returns a Response annotated with Response<T> */
|
|
56
|
-
export function json<T>(
|
|
57
|
-
data: T,
|
|
58
|
-
init?: ResponseInit,
|
|
59
|
-
): TypedResponse<ResponseSpec<T>> {
|
|
44
|
+
export function json<T>(data: T, init?: ResponseInit): TypedResponse<ResponseSpec<T>> {
|
|
60
45
|
return ittyJson(data as any, init) as any;
|
|
61
46
|
}
|
|
62
47
|
|
|
@@ -95,12 +80,9 @@ export function TypedRouter<Args extends any[] = any[]>(
|
|
|
95
80
|
const r = Router(opts);
|
|
96
81
|
|
|
97
82
|
function enforceJson(req: globalThis.Request) {
|
|
98
|
-
const ct = (req.headers.get(
|
|
99
|
-
if (!ct.includes(
|
|
100
|
-
return ittyJson(
|
|
101
|
-
{ error: "Content-Type must be application/json" },
|
|
102
|
-
{ status: 415 },
|
|
103
|
-
);
|
|
83
|
+
const ct = (req.headers.get('content-type') ?? '').toLowerCase();
|
|
84
|
+
if (!ct.includes('application/json') && !ct.includes('+json')) {
|
|
85
|
+
return ittyJson({ error: 'Content-Type must be application/json' }, { status: 415 });
|
|
104
86
|
}
|
|
105
87
|
return null;
|
|
106
88
|
}
|
|
@@ -113,19 +95,11 @@ export function TypedRouter<Args extends any[] = any[]>(
|
|
|
113
95
|
}
|
|
114
96
|
}
|
|
115
97
|
|
|
116
|
-
const methodsToProxy = [
|
|
117
|
-
"get",
|
|
118
|
-
"post",
|
|
119
|
-
"put",
|
|
120
|
-
"delete",
|
|
121
|
-
"patch",
|
|
122
|
-
"head",
|
|
123
|
-
"options",
|
|
124
|
-
];
|
|
98
|
+
const methodsToProxy = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'];
|
|
125
99
|
|
|
126
100
|
const wrapper: any = new Proxy(r, {
|
|
127
101
|
get(target, prop, receiver) {
|
|
128
|
-
if (typeof prop ===
|
|
102
|
+
if (typeof prop === 'string' && methodsToProxy.includes(prop)) {
|
|
129
103
|
return (
|
|
130
104
|
path: string,
|
|
131
105
|
controller: HandlerController<any, any, Args>,
|
|
@@ -134,7 +108,7 @@ export function TypedRouter<Args extends any[] = any[]>(
|
|
|
134
108
|
const handler = (...args: any[]) => {
|
|
135
109
|
const req = args[0] as IRequest & { content?: unknown };
|
|
136
110
|
const extraArgs = args.slice(1) as Args;
|
|
137
|
-
if (prop ===
|
|
111
|
+
if (prop === 'post' || prop === 'put' || prop === 'patch') {
|
|
138
112
|
if (!options?.multipart) {
|
|
139
113
|
const ctErr = enforceJson(req);
|
|
140
114
|
if (ctErr) return ctErr;
|
|
@@ -160,7 +134,7 @@ export function TypedRouter<Args extends any[] = any[]>(
|
|
|
160
134
|
};
|
|
161
135
|
}
|
|
162
136
|
const value = Reflect.get(target, prop, receiver);
|
|
163
|
-
if (typeof value ===
|
|
137
|
+
if (typeof value === 'function') {
|
|
164
138
|
return (...args: any[]) => {
|
|
165
139
|
const result = value.apply(target, args);
|
|
166
140
|
return result === target ? wrapper : result;
|