@mpen/routekit 0.1.0 → 0.1.2
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/bin.d.mts +4 -0
- package/dist/client/react.d.mts +178 -0
- package/dist/client/react.mjs +142 -0
- package/dist/client.d.mts +433 -0
- package/dist/client.mjs +264 -0
- package/dist/content-BuDOmhH_.mjs +102 -0
- package/dist/core-CzUCxvGk.d.mts +140 -0
- package/dist/core-DbmQauwS.mjs +81 -0
- package/dist/handlers.d.mts +72 -0
- package/dist/handlers.mjs +153 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +1152 -0
- package/dist/middleware.d.mts +388 -0
- package/dist/middleware.mjs +1222 -0
- package/dist/request-Dn0zc-xm.mjs +1025 -0
- package/dist/response/content.d.mts +79 -0
- package/dist/response/content.mjs +2 -0
- package/dist/response/json-rpc.d.mts +1 -0
- package/dist/response/json-rpc.mjs +1 -0
- package/dist/response/problem/valibot.d.mts +230 -0
- package/dist/response/problem/valibot.mjs +258 -0
- package/dist/response/problem.d.mts +415 -0
- package/dist/response/problem.mjs +183 -0
- package/dist/response/status.d.mts +45 -0
- package/dist/response/status.mjs +2 -0
- package/dist/responses-B379Ep9Y.d.mts +296 -0
- package/dist/responses-BpVrgeYi.mjs +101 -0
- package/dist/router-Cwb7ak0J.d.mts +1819 -0
- package/dist/routes.d.mts +282 -0
- package/dist/routes.mjs +311 -0
- package/dist/status-C-8mw-FB.mjs +59 -0
- package/dist/valibot-D7liFYyB.d.mts +290 -0
- package/dist/valibot-Du97X-TS.mjs +326 -0
- package/package.json +8 -2
- package/src/bin/gen-api-client.test.ts +0 -70
- package/src/bin/gen-api-client.ts +0 -986
- package/src/client/headers.ts +0 -31
- package/src/client/index.ts +0 -8
- package/src/client/promise.ts +0 -11
- package/src/client/react/index.test.tsx +0 -266
- package/src/client/react/index.ts +0 -431
- package/src/client/responses.test.ts +0 -151
- package/src/client/responses.ts +0 -278
- package/src/client/transport.ts +0 -74
- package/src/client/transports/body-codec.ts +0 -61
- package/src/client/transports/fetch.ts +0 -113
- package/src/client/tsconfig.json +0 -9
- package/src/client/types.ts +0 -15
- package/src/client/url.ts +0 -31
- package/src/index.ts +0 -63
- package/src/router/fetch-types.ts +0 -13
- package/src/router/handlers/index.ts +0 -2
- package/src/router/handlers/openapi/index.ts +0 -2
- package/src/router/handlers/openapi/openapi.ts +0 -293
- package/src/router/integration/zod-openapi.test.ts +0 -74
- package/src/router/lib/charset.test.ts +0 -22
- package/src/router/lib/charset.ts +0 -133
- package/src/router/lib/collections.ts +0 -3
- package/src/router/lib/format.test.ts +0 -67
- package/src/router/lib/format.ts +0 -35
- package/src/router/lib/host.ts +0 -4
- package/src/router/lib/json-schema.ts +0 -6
- package/src/router/lib/media-type.test.ts +0 -122
- package/src/router/lib/media-type.ts +0 -289
- package/src/router/lib/pathname.test.ts +0 -18
- package/src/router/lib/pathname.ts +0 -19
- package/src/router/lib/route-names.ts +0 -70
- package/src/router/lib/route-normalize.test.ts +0 -36
- package/src/router/lib/route-normalize.ts +0 -67
- package/src/router/lib/schema-merge.ts +0 -56
- package/src/router/middleware/accept-ctx.test.ts +0 -33
- package/src/router/middleware/accept-ctx.ts +0 -12
- package/src/router/middleware/body-limit.test.ts +0 -112
- package/src/router/middleware/body-limit.ts +0 -121
- package/src/router/middleware/content-type-context.ts +0 -0
- package/src/router/middleware/cors.test.ts +0 -269
- package/src/router/middleware/cors.ts +0 -490
- package/src/router/middleware/csrf.test.ts +0 -106
- package/src/router/middleware/csrf.ts +0 -192
- package/src/router/middleware/define.ts +0 -249
- package/src/router/middleware/index.ts +0 -34
- package/src/router/middleware/jsxhtml-response.ts +0 -0
- package/src/router/middleware/oas-swagger.ts +0 -0
- package/src/router/middleware/rate-limit.test.ts +0 -886
- package/src/router/middleware/rate-limit.ts +0 -920
- package/src/router/middleware/request-id-ctx.test.ts +0 -183
- package/src/router/middleware/request-id-ctx.ts +0 -135
- package/src/router/middleware/request-logger-format.test.ts +0 -16
- package/src/router/middleware/request-logger-format.ts +0 -269
- package/src/router/middleware/request-logger.test.ts +0 -267
- package/src/router/middleware/request-logger.ts +0 -131
- package/src/router/middleware/start-time-ctx.ts +0 -5
- package/src/router/request.ts +0 -611
- package/src/router/response/core.ts +0 -181
- package/src/router/response/directives.ts +0 -233
- package/src/router/response/formats/content/bodyless.ts +0 -54
- package/src/router/response/formats/content/content.ts +0 -79
- package/src/router/response/formats/content/index.ts +0 -2
- package/src/router/response/formats/json-rpc/index.ts +0 -2
- package/src/router/response/formats/problem/badRequest.ts +0 -90
- package/src/router/response/formats/problem/conflict.ts +0 -90
- package/src/router/response/formats/problem/created.ts +0 -40
- package/src/router/response/formats/problem/index.ts +0 -27
- package/src/router/response/formats/problem/notFound.ts +0 -90
- package/src/router/response/formats/problem/permissionDenied.ts +0 -90
- package/src/router/response/formats/problem/problem.test.ts +0 -888
- package/src/router/response/formats/problem/rateLimited.ts +0 -90
- package/src/router/response/formats/problem/responses.ts +0 -219
- package/src/router/response/formats/problem/root-errors.ts +0 -48
- package/src/router/response/formats/problem/sessionExpired.ts +0 -90
- package/src/router/response/formats/problem/types.ts +0 -170
- package/src/router/response/formats/problem/unauthenticated.ts +0 -90
- package/src/router/response/formats/problem/valibot.ts +0 -410
- package/src/router/response/formats/status/index.ts +0 -1
- package/src/router/response/formats/status/responses.ts +0 -59
- package/src/router/response/formats/status/status.test.ts +0 -21
- package/src/router/response/framers.ts +0 -85
- package/src/router/response/index.ts +0 -28
- package/src/router/response/openapi.test.ts +0 -96
- package/src/router/response/openapi.ts +0 -1
- package/src/router/response/serializers.ts +0 -66
- package/src/router/response/stream.ts +0 -35
- package/src/router/router.test.ts +0 -1571
- package/src/router/router.ts +0 -1965
- package/src/router/routes/index.ts +0 -46
- package/src/router/routes/valibot/index.ts +0 -18
- package/src/router/routes/valibot/valibot.ts +0 -1393
- package/src/router/routes/valibot.test.ts +0 -286
- package/src/router/routes/zod/index.ts +0 -18
- package/src/router/routes/zod/zod.ts +0 -1318
- package/src/router/routes/zod.test.ts +0 -280
- package/src/router/server-interface.ts +0 -31
- package/src/router/types.ts +0 -657
|
@@ -1,1571 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env -S bun test
|
|
2
|
-
import { describe, expect, it, mock } from 'bun:test'
|
|
3
|
-
import { HttpMethod, HttpStatus } from '@mpen/http'
|
|
4
|
-
import { LogLevel, type LogActivity, type LogContext, type Logger } from '@mpen/logger'
|
|
5
|
-
import { Router } from './router'
|
|
6
|
-
import type { ContextMiddleware, Handler } from './types'
|
|
7
|
-
import { expectType, type TypeEqual } from '@mpen/ts-types'
|
|
8
|
-
import { requestIdCtx } from './middleware/request-id-ctx'
|
|
9
|
-
import { defineMiddleware, type DeclaredMiddleware } from './middleware/define'
|
|
10
|
-
import type { RouterHeadersInit } from './fetch-types'
|
|
11
|
-
import {
|
|
12
|
-
chunk,
|
|
13
|
-
head,
|
|
14
|
-
headers as responseHeaders,
|
|
15
|
-
response as logicalResponse,
|
|
16
|
-
status as responseStatus,
|
|
17
|
-
type ResponseBodySerializer,
|
|
18
|
-
} from './response'
|
|
19
|
-
import { noContent, text } from './response/formats/content'
|
|
20
|
-
import type { RequestBodyParser } from './request'
|
|
21
|
-
|
|
22
|
-
function makeRequest(
|
|
23
|
-
path: string,
|
|
24
|
-
method: HttpMethod = HttpMethod.GET,
|
|
25
|
-
headers?: RouterHeadersInit,
|
|
26
|
-
): Request {
|
|
27
|
-
const init: RequestInit = { method }
|
|
28
|
-
if (headers) init.headers = headers
|
|
29
|
-
return new Request(`https://example.com${path}`, init)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
type LogMethod = 'log' | 'info' | 'warn' | 'error' | 'table'
|
|
33
|
-
|
|
34
|
-
type LogCall = {
|
|
35
|
-
method: LogMethod
|
|
36
|
-
data: any[]
|
|
37
|
-
context: LogContext
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
type TestLogger = Logger & {
|
|
41
|
-
calls: LogCall[]
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function createTestLogger(): TestLogger {
|
|
45
|
-
const calls: LogCall[] = []
|
|
46
|
-
const create = (context: LogContext): TestLogger => {
|
|
47
|
-
const write =
|
|
48
|
-
(method: LogMethod) =>
|
|
49
|
-
(...data: any[]) => {
|
|
50
|
-
calls.push({ method, data, context })
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
calls,
|
|
55
|
-
withContext(addedContext) {
|
|
56
|
-
return create({ ...context, ...addedContext })
|
|
57
|
-
},
|
|
58
|
-
startActivity(name: string, addedContext?: LogContext): LogActivity {
|
|
59
|
-
const logger = create({ ...context, ...addedContext })
|
|
60
|
-
return {
|
|
61
|
-
logger,
|
|
62
|
-
end(options) {
|
|
63
|
-
const completed = logger.withContext({
|
|
64
|
-
...options?.context,
|
|
65
|
-
duration_ms: 0,
|
|
66
|
-
}) as TestLogger
|
|
67
|
-
const data = [`${name} completed`, ...(options?.data ?? [])]
|
|
68
|
-
switch (options?.level ?? LogLevel.INFO) {
|
|
69
|
-
case LogLevel.DEBUG:
|
|
70
|
-
completed.log(...data)
|
|
71
|
-
break
|
|
72
|
-
case LogLevel.INFO:
|
|
73
|
-
completed.info(...data)
|
|
74
|
-
break
|
|
75
|
-
case LogLevel.WARN:
|
|
76
|
-
completed.warn(...data)
|
|
77
|
-
break
|
|
78
|
-
case LogLevel.ERROR:
|
|
79
|
-
completed.error(...data)
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
|
-
log: write('log'),
|
|
85
|
-
info: write('info'),
|
|
86
|
-
warn: write('warn'),
|
|
87
|
-
error: write('error'),
|
|
88
|
-
table: write('table'),
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return create({})
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
describe('Router', () => {
|
|
96
|
-
it.skip('works with Bun.serve', async () => {
|
|
97
|
-
// failing when ran by Codex for some reason
|
|
98
|
-
const router = new Router().add({
|
|
99
|
-
method: HttpMethod.GET,
|
|
100
|
-
path: '/hello',
|
|
101
|
-
handler: () => new Response('world'),
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
const server = Bun.serve({
|
|
105
|
-
port: 0,
|
|
106
|
-
fetch: router.fetch.bind(router),
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
const response = await fetch(`http://localhost:${server.port}/hello`)
|
|
111
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
112
|
-
expect(await response.text()).toBe('world')
|
|
113
|
-
} finally {
|
|
114
|
-
server.stop(true).catch(console.error)
|
|
115
|
-
}
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('returns 404 for missing routes', async () => {
|
|
119
|
-
const router = new Router()
|
|
120
|
-
const response = await router.fetch(makeRequest('/missing'))
|
|
121
|
-
|
|
122
|
-
expect(response.status).toBe(HttpStatus.NOT_FOUND)
|
|
123
|
-
expect(await response.text()).toBe('Not Found')
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('provides the parsed URL to handlers', async () => {
|
|
127
|
-
const router = new Router()
|
|
128
|
-
router.add({
|
|
129
|
-
method: HttpMethod.GET,
|
|
130
|
-
path: '/search',
|
|
131
|
-
handler: ({ url }) => new Response(`${url.pathname}?q=${url.searchParams.get('q')}`),
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
const response = await router.fetch(makeRequest('/search?q=bun'))
|
|
135
|
-
|
|
136
|
-
expect(await response.text()).toBe('/search?q=bun')
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('provides path to handlers', async () => {
|
|
140
|
-
const router = new Router()
|
|
141
|
-
router.add({
|
|
142
|
-
method: HttpMethod.GET,
|
|
143
|
-
path: '/users/:id',
|
|
144
|
-
handler: ({ path }) => new Response((path as { id: string }).id),
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
const response = await router.fetch(makeRequest('/users/42'))
|
|
148
|
-
|
|
149
|
-
expect(await response.text()).toBe('42')
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('handles HEAD requests for streaming handlers', async () => {
|
|
153
|
-
const beforeHeader = mock()
|
|
154
|
-
const afterHeader = mock()
|
|
155
|
-
const handler: Handler = async function* () {
|
|
156
|
-
yield responseStatus(HttpStatus.CREATED)
|
|
157
|
-
beforeHeader()
|
|
158
|
-
yield responseHeaders({
|
|
159
|
-
'content-type': 'text/plain; charset=utf-8',
|
|
160
|
-
'x-stream': 'true',
|
|
161
|
-
})
|
|
162
|
-
afterHeader()
|
|
163
|
-
return 'hello'
|
|
164
|
-
}
|
|
165
|
-
const router = new Router()
|
|
166
|
-
router.add({ method: HttpMethod.GET, path: '/', handler })
|
|
167
|
-
|
|
168
|
-
const getResponse = await router.fetch(makeRequest('/'))
|
|
169
|
-
expect(getResponse.status).toBe(HttpStatus.CREATED)
|
|
170
|
-
expect(getResponse.headers.get('x-stream')).toBe('true')
|
|
171
|
-
expect(await getResponse.text()).toBe('hello')
|
|
172
|
-
|
|
173
|
-
const headResponse = await router.fetch(makeRequest('/', HttpMethod.HEAD))
|
|
174
|
-
expect(headResponse.status).toBe(HttpStatus.CREATED)
|
|
175
|
-
expect(headResponse.headers.get('x-stream')).toBe('true')
|
|
176
|
-
expect(await headResponse.text()).toBe('')
|
|
177
|
-
|
|
178
|
-
expect(beforeHeader).toHaveBeenCalledTimes(2)
|
|
179
|
-
expect(afterHeader).toHaveBeenCalledTimes(1)
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
it('defaults to 200 when headers are yielded before status', async () => {
|
|
183
|
-
const { resolve: resume, promise: deferred } = Promise.withResolvers()
|
|
184
|
-
const handler: Handler = async function* () {
|
|
185
|
-
yield responseHeaders({ 'x-stream': 'true' })
|
|
186
|
-
yield responseStatus(499) // ignored
|
|
187
|
-
await deferred
|
|
188
|
-
return new TextEncoder().encode('hello')
|
|
189
|
-
}
|
|
190
|
-
const router = new Router()
|
|
191
|
-
router.add({ method: HttpMethod.GET, path: '/', handler })
|
|
192
|
-
|
|
193
|
-
const responsePromise = router.fetch(makeRequest('/', HttpMethod.HEAD))
|
|
194
|
-
const response = await Promise.race([
|
|
195
|
-
responsePromise,
|
|
196
|
-
new Promise<Response>((_, reject) =>
|
|
197
|
-
setTimeout(() => reject(new Error('response did not resolve')), 50),
|
|
198
|
-
),
|
|
199
|
-
])
|
|
200
|
-
|
|
201
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
202
|
-
expect(response.headers.get('x-stream')).toBe('true')
|
|
203
|
-
expect(await response.text()).toBe('')
|
|
204
|
-
resume()
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
it('accepts typed metadata directives from streaming handlers', async () => {
|
|
208
|
-
const router = new Router()
|
|
209
|
-
router.add({
|
|
210
|
-
method: HttpMethod.GET,
|
|
211
|
-
path: '/status',
|
|
212
|
-
handler: async function* () {
|
|
213
|
-
yield responseStatus(HttpStatus.ACCEPTED)
|
|
214
|
-
return { ok: true }
|
|
215
|
-
},
|
|
216
|
-
})
|
|
217
|
-
router.add({
|
|
218
|
-
method: HttpMethod.GET,
|
|
219
|
-
path: '/headers',
|
|
220
|
-
handler: async function* () {
|
|
221
|
-
yield responseHeaders({ 'x-meta': 'yes' })
|
|
222
|
-
return { ok: true }
|
|
223
|
-
},
|
|
224
|
-
})
|
|
225
|
-
router.add({
|
|
226
|
-
method: HttpMethod.GET,
|
|
227
|
-
path: '/both',
|
|
228
|
-
handler: async function* () {
|
|
229
|
-
yield head(HttpStatus.CREATED, { 'x-both': 'true' })
|
|
230
|
-
return { ok: true }
|
|
231
|
-
},
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
const statusResponse = await router.fetch(makeRequest('/status'))
|
|
235
|
-
expect(statusResponse.status).toBe(HttpStatus.ACCEPTED)
|
|
236
|
-
expect(await statusResponse.json()).toEqual({ ok: true })
|
|
237
|
-
|
|
238
|
-
const headersResponse = await router.fetch(makeRequest('/headers'))
|
|
239
|
-
expect(headersResponse.status).toBe(HttpStatus.OK)
|
|
240
|
-
expect(headersResponse.headers.get('x-meta')).toBe('yes')
|
|
241
|
-
expect(await headersResponse.json()).toEqual({ ok: true })
|
|
242
|
-
|
|
243
|
-
const bothResponse = await router.fetch(makeRequest('/both'))
|
|
244
|
-
expect(bothResponse.status).toBe(HttpStatus.CREATED)
|
|
245
|
-
expect(bothResponse.headers.get('x-both')).toBe('true')
|
|
246
|
-
expect(await bothResponse.json()).toEqual({ ok: true })
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
it('streams yielded body chunks and appends the returned body', async () => {
|
|
250
|
-
const router = new Router()
|
|
251
|
-
router.add({
|
|
252
|
-
method: HttpMethod.GET,
|
|
253
|
-
path: '/stream',
|
|
254
|
-
handler: async function* () {
|
|
255
|
-
yield chunk('hello ')
|
|
256
|
-
yield chunk(new TextEncoder().encode('world'))
|
|
257
|
-
yield chunk(Buffer.from('!'))
|
|
258
|
-
yield chunk(' done')
|
|
259
|
-
},
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
const response = await router.fetch(makeRequest('/stream'))
|
|
263
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
264
|
-
expect(await response.text()).toBe('hello world! done')
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
it('streams response bodies written after returning a Response', async () => {
|
|
268
|
-
const { resolve: allowWrite, promise: writeAllowed } = Promise.withResolvers<void>()
|
|
269
|
-
const router = new Router()
|
|
270
|
-
router.add({
|
|
271
|
-
method: HttpMethod.GET,
|
|
272
|
-
path: '/late',
|
|
273
|
-
handler: () => {
|
|
274
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
275
|
-
async start(controller) {
|
|
276
|
-
await writeAllowed
|
|
277
|
-
controller.enqueue(new TextEncoder().encode('late body'))
|
|
278
|
-
controller.close()
|
|
279
|
-
},
|
|
280
|
-
})
|
|
281
|
-
return new Response(stream)
|
|
282
|
-
},
|
|
283
|
-
})
|
|
284
|
-
|
|
285
|
-
const responsePromise = router.fetch(makeRequest('/late'))
|
|
286
|
-
const response = await Promise.race([
|
|
287
|
-
responsePromise,
|
|
288
|
-
new Promise<Response>((_, reject) =>
|
|
289
|
-
setTimeout(() => reject(new Error('response did not resolve')), 50),
|
|
290
|
-
),
|
|
291
|
-
])
|
|
292
|
-
|
|
293
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
294
|
-
allowWrite()
|
|
295
|
-
expect(await response.text()).toBe('late body')
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
it('exposes request aborts so handlers can stop early', async () => {
|
|
299
|
-
const { resolve: handlerStarted, promise: handlerStartedPromise } =
|
|
300
|
-
Promise.withResolvers<void>()
|
|
301
|
-
const { resolve: allowCheck, promise: allowCheckPromise } = Promise.withResolvers<void>()
|
|
302
|
-
const controller = new AbortController()
|
|
303
|
-
let sawAbort = false
|
|
304
|
-
let finished = false
|
|
305
|
-
const router = new Router()
|
|
306
|
-
router.add({
|
|
307
|
-
method: HttpMethod.GET,
|
|
308
|
-
path: '/slow',
|
|
309
|
-
handler: async ({ request }) => {
|
|
310
|
-
handlerStarted()
|
|
311
|
-
await allowCheckPromise
|
|
312
|
-
if (request.signal.aborted) {
|
|
313
|
-
sawAbort = true
|
|
314
|
-
return new Response('aborted', { status: HttpStatus.CLIENT_CLOSED_REQUEST })
|
|
315
|
-
}
|
|
316
|
-
finished = true
|
|
317
|
-
return new Response('ok')
|
|
318
|
-
},
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
const responsePromise = router.fetch(
|
|
322
|
-
new Request('https://example.com/slow', { signal: controller.signal }),
|
|
323
|
-
)
|
|
324
|
-
await handlerStartedPromise
|
|
325
|
-
controller.abort()
|
|
326
|
-
allowCheck()
|
|
327
|
-
|
|
328
|
-
const response = await responsePromise
|
|
329
|
-
expect(response.status).toBe(HttpStatus.CLIENT_CLOSED_REQUEST)
|
|
330
|
-
expect(sawAbort).toBe(true)
|
|
331
|
-
expect(finished).toBe(false)
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
it('returns string or bytes as response bodies', async () => {
|
|
335
|
-
const router = new Router()
|
|
336
|
-
router.add({
|
|
337
|
-
method: HttpMethod.GET,
|
|
338
|
-
path: '/text',
|
|
339
|
-
handler: () => 'ok',
|
|
340
|
-
})
|
|
341
|
-
router.add({
|
|
342
|
-
method: HttpMethod.GET,
|
|
343
|
-
path: '/bytes',
|
|
344
|
-
handler: () => new Uint8Array([111, 107]),
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
const textResponse = await router.fetch(makeRequest('/text'))
|
|
348
|
-
expect(textResponse.status).toBe(HttpStatus.OK)
|
|
349
|
-
expect(await textResponse.text()).toBe('ok')
|
|
350
|
-
|
|
351
|
-
const bytesResponse = await router.fetch(makeRequest('/bytes'))
|
|
352
|
-
expect(bytesResponse.status).toBe(HttpStatus.OK)
|
|
353
|
-
expect(await bytesResponse.text()).toBe('ok')
|
|
354
|
-
})
|
|
355
|
-
|
|
356
|
-
it('prefers explicit HEAD handlers and falls back to GET handlers', async () => {
|
|
357
|
-
const router = new Router()
|
|
358
|
-
let getCalls = 0
|
|
359
|
-
let headCalls = 0
|
|
360
|
-
router.add({
|
|
361
|
-
method: HttpMethod.GET,
|
|
362
|
-
path: '/resource',
|
|
363
|
-
handler: () => {
|
|
364
|
-
getCalls += 1
|
|
365
|
-
return new Response('get')
|
|
366
|
-
},
|
|
367
|
-
})
|
|
368
|
-
router.add({
|
|
369
|
-
method: HttpMethod.HEAD,
|
|
370
|
-
path: '/resource',
|
|
371
|
-
handler: () => {
|
|
372
|
-
headCalls += 1
|
|
373
|
-
return new Response('head')
|
|
374
|
-
},
|
|
375
|
-
})
|
|
376
|
-
router.add({
|
|
377
|
-
method: HttpMethod.GET,
|
|
378
|
-
path: '/fallback',
|
|
379
|
-
handler: () => {
|
|
380
|
-
getCalls += 1
|
|
381
|
-
return new Response('get')
|
|
382
|
-
},
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
const headResponse = await router.fetch(makeRequest('/resource', HttpMethod.HEAD))
|
|
386
|
-
expect(headResponse.status).toBe(HttpStatus.OK)
|
|
387
|
-
expect(headCalls).toBe(1)
|
|
388
|
-
expect(getCalls).toBe(0)
|
|
389
|
-
|
|
390
|
-
const fallbackResponse = await router.fetch(makeRequest('/fallback', HttpMethod.HEAD))
|
|
391
|
-
expect(fallbackResponse.status).toBe(HttpStatus.OK)
|
|
392
|
-
expect(getCalls).toBe(1)
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
it('returns 405 when the route exists but the method is not allowed', async () => {
|
|
396
|
-
const router = new Router()
|
|
397
|
-
let postCalls = 0
|
|
398
|
-
router.add({
|
|
399
|
-
method: HttpMethod.POST,
|
|
400
|
-
path: '/mutate',
|
|
401
|
-
handler: () => {
|
|
402
|
-
postCalls += 1
|
|
403
|
-
return new Response('ok')
|
|
404
|
-
},
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
const headResponse = await router.fetch(makeRequest('/mutate', HttpMethod.HEAD))
|
|
408
|
-
expect(headResponse.status).toBe(HttpStatus.METHOD_NOT_ALLOWED)
|
|
409
|
-
expect(postCalls).toBe(0)
|
|
410
|
-
|
|
411
|
-
const getResponse = await router.fetch(makeRequest('/mutate', HttpMethod.GET))
|
|
412
|
-
expect(getResponse.status).toBe(HttpStatus.METHOD_NOT_ALLOWED)
|
|
413
|
-
expect(postCalls).toBe(0)
|
|
414
|
-
|
|
415
|
-
const postResponse = await router.fetch(makeRequest('/mutate', HttpMethod.POST))
|
|
416
|
-
expect(postResponse.status).toBe(HttpStatus.OK)
|
|
417
|
-
expect(postCalls).toBe(1)
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
it('supports method arrays including HEAD', async () => {
|
|
421
|
-
const router = new Router()
|
|
422
|
-
let calls = 0
|
|
423
|
-
router.add({
|
|
424
|
-
method: [HttpMethod.POST, HttpMethod.HEAD],
|
|
425
|
-
path: '/combo',
|
|
426
|
-
handler: () => {
|
|
427
|
-
calls += 1
|
|
428
|
-
return new Response('ok')
|
|
429
|
-
},
|
|
430
|
-
})
|
|
431
|
-
|
|
432
|
-
const headResponse = await router.fetch(makeRequest('/combo', HttpMethod.HEAD))
|
|
433
|
-
expect(headResponse.status).toBe(HttpStatus.OK)
|
|
434
|
-
expect(calls).toBe(1)
|
|
435
|
-
|
|
436
|
-
const postResponse = await router.fetch(makeRequest('/combo', HttpMethod.POST))
|
|
437
|
-
expect(postResponse.status).toBe(HttpStatus.OK)
|
|
438
|
-
expect(calls).toBe(2)
|
|
439
|
-
|
|
440
|
-
const getResponse = await router.fetch(makeRequest('/combo', HttpMethod.GET))
|
|
441
|
-
expect(getResponse.status).toBe(HttpStatus.METHOD_NOT_ALLOWED)
|
|
442
|
-
expect(calls).toBe(2)
|
|
443
|
-
})
|
|
444
|
-
|
|
445
|
-
it('automatically handles OPTIONS and reports allowed methods', async () => {
|
|
446
|
-
const router = new Router()
|
|
447
|
-
router.add({
|
|
448
|
-
method: HttpMethod.GET,
|
|
449
|
-
path: '/cors',
|
|
450
|
-
handler: function () {
|
|
451
|
-
return new Response('get')
|
|
452
|
-
},
|
|
453
|
-
})
|
|
454
|
-
router.add({
|
|
455
|
-
method: HttpMethod.POST,
|
|
456
|
-
path: '/cors',
|
|
457
|
-
handler: function () {
|
|
458
|
-
return new Response('post')
|
|
459
|
-
},
|
|
460
|
-
})
|
|
461
|
-
|
|
462
|
-
const response = await router.fetch(makeRequest('/cors', HttpMethod.OPTIONS))
|
|
463
|
-
expect(response.status).toBe(HttpStatus.NO_CONTENT)
|
|
464
|
-
expect(response.headers.get('access-control-allow-methods')).toBe(
|
|
465
|
-
'GET, HEAD, POST, OPTIONS',
|
|
466
|
-
)
|
|
467
|
-
expect(response.headers.has('access-control-allow-origin')).toBe(false)
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
it('returns 415 when Content-Type does not satisfy the route accept', async () => {
|
|
471
|
-
const router = new Router()
|
|
472
|
-
router.add({
|
|
473
|
-
method: HttpMethod.POST,
|
|
474
|
-
path: '/json',
|
|
475
|
-
accept: ['application/json; charset=UTF-8', { type: 'text/plain' }],
|
|
476
|
-
handler: () => new Response('ok'),
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
const missingHeader = await router.fetch(makeRequest('/json', HttpMethod.POST))
|
|
480
|
-
expect(missingHeader.status).toBe(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
|
481
|
-
|
|
482
|
-
const wrongType = await router.fetch(
|
|
483
|
-
makeRequest('/json', HttpMethod.POST, { 'content-type': 'text/html' }),
|
|
484
|
-
)
|
|
485
|
-
expect(wrongType.status).toBe(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
|
486
|
-
|
|
487
|
-
const wrongCharset = await router.fetch(
|
|
488
|
-
makeRequest('/json', HttpMethod.POST, {
|
|
489
|
-
'content-type': 'application/json; charset=latin1',
|
|
490
|
-
}),
|
|
491
|
-
)
|
|
492
|
-
expect(wrongCharset.status).toBe(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
|
493
|
-
|
|
494
|
-
const noCharset = await router.fetch(
|
|
495
|
-
makeRequest('/json', HttpMethod.POST, { 'content-type': 'application/json' }),
|
|
496
|
-
)
|
|
497
|
-
expect(noCharset.status).toBe(HttpStatus.OK)
|
|
498
|
-
expect(await noCharset.text()).toBe('ok')
|
|
499
|
-
|
|
500
|
-
const normalizedCharset = await router.fetch(
|
|
501
|
-
makeRequest('/json', HttpMethod.POST, {
|
|
502
|
-
'content-type': 'application/json; charset=utf8',
|
|
503
|
-
}),
|
|
504
|
-
)
|
|
505
|
-
expect(normalizedCharset.status).toBe(HttpStatus.OK)
|
|
506
|
-
expect(await normalizedCharset.text()).toBe('ok')
|
|
507
|
-
})
|
|
508
|
-
|
|
509
|
-
it('routes to the matching accept route before returning 415', async () => {
|
|
510
|
-
const router = new Router()
|
|
511
|
-
router.add({
|
|
512
|
-
method: HttpMethod.POST,
|
|
513
|
-
path: '/payload',
|
|
514
|
-
accept: ['application/json'],
|
|
515
|
-
handler: () => new Response('json'),
|
|
516
|
-
})
|
|
517
|
-
router.add({
|
|
518
|
-
method: HttpMethod.POST,
|
|
519
|
-
path: '/payload',
|
|
520
|
-
accept: ['text/plain'],
|
|
521
|
-
handler: () => new Response('text'),
|
|
522
|
-
})
|
|
523
|
-
|
|
524
|
-
const jsonResponse = await router.fetch(
|
|
525
|
-
makeRequest('/payload', HttpMethod.POST, { 'content-type': 'application/json' }),
|
|
526
|
-
)
|
|
527
|
-
expect(jsonResponse.status).toBe(HttpStatus.OK)
|
|
528
|
-
expect(await jsonResponse.text()).toBe('json')
|
|
529
|
-
|
|
530
|
-
const textResponse = await router.fetch(
|
|
531
|
-
makeRequest('/payload', HttpMethod.POST, { 'content-type': 'text/plain' }),
|
|
532
|
-
)
|
|
533
|
-
expect(textResponse.status).toBe(HttpStatus.OK)
|
|
534
|
-
expect(await textResponse.text()).toBe('text')
|
|
535
|
-
|
|
536
|
-
const unsupportedResponse = await router.fetch(
|
|
537
|
-
makeRequest('/payload', HttpMethod.POST, { 'content-type': 'text/html' }),
|
|
538
|
-
)
|
|
539
|
-
expect(unsupportedResponse.status).toBe(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
|
540
|
-
})
|
|
541
|
-
|
|
542
|
-
it('allows Content-Type when route does not specify accept', async function () {
|
|
543
|
-
const router = new Router()
|
|
544
|
-
router.add({
|
|
545
|
-
method: HttpMethod.POST,
|
|
546
|
-
path: '/plain',
|
|
547
|
-
handler: () => new Response('ok'),
|
|
548
|
-
})
|
|
549
|
-
|
|
550
|
-
const response = await router.fetch(
|
|
551
|
-
makeRequest('/plain', HttpMethod.POST, { 'content-type': 'text/plain' }),
|
|
552
|
-
)
|
|
553
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
554
|
-
expect(await response.text()).toBe('ok')
|
|
555
|
-
})
|
|
556
|
-
|
|
557
|
-
it('parses request bodies with configured request body parsers', async function () {
|
|
558
|
-
const router = new Router()
|
|
559
|
-
.setRequestBodyParsers([
|
|
560
|
-
{
|
|
561
|
-
mediaTypes: ['application/x-lines'],
|
|
562
|
-
parse: async (ctx) => (await ctx.text()).split('\n'),
|
|
563
|
-
},
|
|
564
|
-
])
|
|
565
|
-
.add({
|
|
566
|
-
method: HttpMethod.POST,
|
|
567
|
-
path: '/lines',
|
|
568
|
-
handler: async ({ request }) => await request.body.parse(),
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
const response = await router.fetch(
|
|
572
|
-
new Request('https://example.com/lines', {
|
|
573
|
-
method: HttpMethod.POST,
|
|
574
|
-
headers: { 'content-type': 'application/x-lines' },
|
|
575
|
-
body: 'a\nb',
|
|
576
|
-
}),
|
|
577
|
-
)
|
|
578
|
-
|
|
579
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
580
|
-
expect(await response.json()).toEqual(['a', 'b'])
|
|
581
|
-
})
|
|
582
|
-
|
|
583
|
-
it('parses URL-encoded request bodies by default', async function () {
|
|
584
|
-
const router = new Router()
|
|
585
|
-
router.add({
|
|
586
|
-
method: HttpMethod.POST,
|
|
587
|
-
path: '/form',
|
|
588
|
-
handler: async ({ request }) => await request.body.parse(),
|
|
589
|
-
})
|
|
590
|
-
|
|
591
|
-
const response = await router.fetch(
|
|
592
|
-
new Request('https://example.com/form', {
|
|
593
|
-
method: HttpMethod.POST,
|
|
594
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
595
|
-
body: 'tag=a&tag=b&name=Ada',
|
|
596
|
-
}),
|
|
597
|
-
)
|
|
598
|
-
|
|
599
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
600
|
-
expect(await response.json()).toEqual({ tag: ['a', 'b'], name: 'Ada' })
|
|
601
|
-
})
|
|
602
|
-
|
|
603
|
-
it('returns 415 when no request body parser matches', async function () {
|
|
604
|
-
const router = new Router()
|
|
605
|
-
router.add({
|
|
606
|
-
method: HttpMethod.POST,
|
|
607
|
-
path: '/upload',
|
|
608
|
-
handler: async ({ request }) => await request.body.parse(),
|
|
609
|
-
})
|
|
610
|
-
|
|
611
|
-
const response = await router.fetch(
|
|
612
|
-
new Request('https://example.com/upload', {
|
|
613
|
-
method: HttpMethod.POST,
|
|
614
|
-
headers: { 'content-type': 'image/png' },
|
|
615
|
-
body: 'not really png',
|
|
616
|
-
}),
|
|
617
|
-
)
|
|
618
|
-
|
|
619
|
-
expect(response.status).toBe(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
|
620
|
-
})
|
|
621
|
-
})
|
|
622
|
-
|
|
623
|
-
describe('Router.get', () => {
|
|
624
|
-
it('registers GET routes', async () => {
|
|
625
|
-
const router = new Router()
|
|
626
|
-
router.get('/get', () => new Response('ok'))
|
|
627
|
-
|
|
628
|
-
const [route] = router.getRoutes()
|
|
629
|
-
expect(route?.method).toBe(HttpMethod.GET)
|
|
630
|
-
|
|
631
|
-
const response = await router.fetch(makeRequest('/get', HttpMethod.GET))
|
|
632
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
633
|
-
})
|
|
634
|
-
})
|
|
635
|
-
|
|
636
|
-
describe('Router.head', () => {
|
|
637
|
-
it('registers HEAD routes', async () => {
|
|
638
|
-
const router = new Router()
|
|
639
|
-
router.head('/head', () => new Response('ok'))
|
|
640
|
-
|
|
641
|
-
const [route] = router.getRoutes()
|
|
642
|
-
expect(route?.method).toBe(HttpMethod.HEAD)
|
|
643
|
-
|
|
644
|
-
const response = await router.fetch(makeRequest('/head', HttpMethod.HEAD))
|
|
645
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
646
|
-
})
|
|
647
|
-
})
|
|
648
|
-
|
|
649
|
-
describe('Router.post', () => {
|
|
650
|
-
it('registers POST routes', async () => {
|
|
651
|
-
const router = new Router()
|
|
652
|
-
router.post('/post', () => new Response('ok'))
|
|
653
|
-
|
|
654
|
-
const [route] = router.getRoutes()
|
|
655
|
-
expect(route?.method).toBe(HttpMethod.POST)
|
|
656
|
-
|
|
657
|
-
const response = await router.fetch(makeRequest('/post', HttpMethod.POST))
|
|
658
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
659
|
-
})
|
|
660
|
-
})
|
|
661
|
-
|
|
662
|
-
describe('Router.put', () => {
|
|
663
|
-
it('registers PUT routes', async () => {
|
|
664
|
-
const router = new Router()
|
|
665
|
-
router.put('/put', () => new Response('ok'))
|
|
666
|
-
|
|
667
|
-
const [route] = router.getRoutes()
|
|
668
|
-
expect(route?.method).toBe(HttpMethod.PUT)
|
|
669
|
-
|
|
670
|
-
const response = await router.fetch(makeRequest('/put', HttpMethod.PUT))
|
|
671
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
672
|
-
})
|
|
673
|
-
})
|
|
674
|
-
|
|
675
|
-
describe('Router.delete', () => {
|
|
676
|
-
it('registers DELETE routes', async () => {
|
|
677
|
-
const router = new Router()
|
|
678
|
-
router.delete('/delete', () => new Response('ok'))
|
|
679
|
-
|
|
680
|
-
const [route] = router.getRoutes()
|
|
681
|
-
expect(route?.method).toBe(HttpMethod.DELETE)
|
|
682
|
-
|
|
683
|
-
const response = await router.fetch(makeRequest('/delete', HttpMethod.DELETE))
|
|
684
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
685
|
-
})
|
|
686
|
-
})
|
|
687
|
-
|
|
688
|
-
describe('Router.patch', () => {
|
|
689
|
-
it('registers PATCH routes', async () => {
|
|
690
|
-
const router = new Router()
|
|
691
|
-
router.patch('/patch', () => new Response('ok'))
|
|
692
|
-
|
|
693
|
-
const [route] = router.getRoutes()
|
|
694
|
-
expect(route?.method).toBe(HttpMethod.PATCH)
|
|
695
|
-
|
|
696
|
-
const response = await router.fetch(makeRequest('/patch', HttpMethod.PATCH))
|
|
697
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
698
|
-
})
|
|
699
|
-
})
|
|
700
|
-
|
|
701
|
-
describe('Router.install', () => {
|
|
702
|
-
it('invokes extensions and returns the same router for chaining', async () => {
|
|
703
|
-
let installedRouter: Router | undefined
|
|
704
|
-
const router = new Router()
|
|
705
|
-
|
|
706
|
-
const returned = router.install((target) => {
|
|
707
|
-
installedRouter = target
|
|
708
|
-
target.get('/installed', () => text('ok'))
|
|
709
|
-
})
|
|
710
|
-
|
|
711
|
-
const response = await router.fetch(makeRequest('/installed'))
|
|
712
|
-
|
|
713
|
-
expect(returned).toBe(router)
|
|
714
|
-
expect(installedRouter).toBe(router)
|
|
715
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
716
|
-
expect(await response.text()).toBe('ok')
|
|
717
|
-
})
|
|
718
|
-
})
|
|
719
|
-
|
|
720
|
-
describe('Router.onNotFound', () => {
|
|
721
|
-
it('uses the configured onNotFound handler', async () => {
|
|
722
|
-
const router = new Router().onNotFound(
|
|
723
|
-
() => new Response('missing', { status: HttpStatus.NOT_FOUND }),
|
|
724
|
-
)
|
|
725
|
-
|
|
726
|
-
const response = await router.fetch(makeRequest('/missing'))
|
|
727
|
-
|
|
728
|
-
expect(response.status).toBe(HttpStatus.NOT_FOUND)
|
|
729
|
-
expect(await response.text()).toBe('missing')
|
|
730
|
-
})
|
|
731
|
-
|
|
732
|
-
it('falls back to 500 when the onNotFound handler throws', async () => {
|
|
733
|
-
const router = new Router().setLogger(createTestLogger()).onNotFound(() => {
|
|
734
|
-
throw new Error('boom')
|
|
735
|
-
})
|
|
736
|
-
|
|
737
|
-
const response = await router.fetch(makeRequest('/missing'))
|
|
738
|
-
|
|
739
|
-
expect(response.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
740
|
-
expect(await response.text()).toBe('Internal Server Error')
|
|
741
|
-
})
|
|
742
|
-
})
|
|
743
|
-
|
|
744
|
-
describe('Router.onMethodNotAllowed', () => {
|
|
745
|
-
it('uses the configured onMethodNotAllowed handler', async () => {
|
|
746
|
-
const router = new Router()
|
|
747
|
-
.onMethodNotAllowed(
|
|
748
|
-
() => new Response('nope', { status: HttpStatus.METHOD_NOT_ALLOWED }),
|
|
749
|
-
)
|
|
750
|
-
.add({
|
|
751
|
-
method: HttpMethod.POST,
|
|
752
|
-
path: '/mutate',
|
|
753
|
-
handler: () => new Response('ok'),
|
|
754
|
-
})
|
|
755
|
-
|
|
756
|
-
const response = await router.fetch(makeRequest('/mutate', HttpMethod.GET))
|
|
757
|
-
|
|
758
|
-
expect(response.status).toBe(HttpStatus.METHOD_NOT_ALLOWED)
|
|
759
|
-
expect(await response.text()).toBe('nope')
|
|
760
|
-
})
|
|
761
|
-
})
|
|
762
|
-
|
|
763
|
-
describe('Router.onUnsupportedMediaType', () => {
|
|
764
|
-
it('uses the configured onUnsupportedMediaType handler', async () => {
|
|
765
|
-
const router = new Router()
|
|
766
|
-
.onUnsupportedMediaType(
|
|
767
|
-
() => new Response('unsupported', { status: HttpStatus.UNSUPPORTED_MEDIA_TYPE }),
|
|
768
|
-
)
|
|
769
|
-
.add({
|
|
770
|
-
method: HttpMethod.POST,
|
|
771
|
-
path: '/json',
|
|
772
|
-
accept: ['application/json'],
|
|
773
|
-
handler: () => new Response('ok'),
|
|
774
|
-
})
|
|
775
|
-
|
|
776
|
-
const response = await router.fetch(makeRequest('/json', HttpMethod.POST))
|
|
777
|
-
|
|
778
|
-
expect(response.status).toBe(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
|
779
|
-
expect(await response.text()).toBe('unsupported')
|
|
780
|
-
})
|
|
781
|
-
})
|
|
782
|
-
|
|
783
|
-
describe('Router.onInternalError', () => {
|
|
784
|
-
it('logs matched route errors with the default ConsoleLogger', async () => {
|
|
785
|
-
const originalError = console.error
|
|
786
|
-
const calls: any[][] = []
|
|
787
|
-
const error = new Error('boom')
|
|
788
|
-
console.error = ((...data: any[]) => {
|
|
789
|
-
calls.push(data)
|
|
790
|
-
}) as typeof console.error
|
|
791
|
-
try {
|
|
792
|
-
const router = new Router().add({
|
|
793
|
-
method: HttpMethod.GET,
|
|
794
|
-
path: '/boom',
|
|
795
|
-
handler: () => {
|
|
796
|
-
throw error
|
|
797
|
-
},
|
|
798
|
-
})
|
|
799
|
-
|
|
800
|
-
const response = await router.fetch(makeRequest('/boom'))
|
|
801
|
-
|
|
802
|
-
expect(response.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
803
|
-
expect(await response.text()).toBe('Internal Server Error')
|
|
804
|
-
expect(calls).toHaveLength(1)
|
|
805
|
-
expect(calls[0]?.[0]).toBe('Routekit internal server error')
|
|
806
|
-
expect(calls[0]?.[1]).toBe(error)
|
|
807
|
-
expect(calls[0]?.[2]).toEqual({
|
|
808
|
-
'http.request.method': HttpMethod.GET,
|
|
809
|
-
'http.route': '/boom',
|
|
810
|
-
'url.path': '/boom',
|
|
811
|
-
})
|
|
812
|
-
} finally {
|
|
813
|
-
console.error = originalError
|
|
814
|
-
}
|
|
815
|
-
})
|
|
816
|
-
|
|
817
|
-
it('uses the configured onInternalError handler', async () => {
|
|
818
|
-
const logger = createTestLogger()
|
|
819
|
-
const error = new Error('boom')
|
|
820
|
-
const router = new Router()
|
|
821
|
-
.setLogger(logger)
|
|
822
|
-
.onInternalError(
|
|
823
|
-
() => new Response('broken', { status: HttpStatus.SERVICE_UNAVAILABLE }),
|
|
824
|
-
)
|
|
825
|
-
.add({
|
|
826
|
-
method: HttpMethod.GET,
|
|
827
|
-
path: '/boom',
|
|
828
|
-
handler: () => {
|
|
829
|
-
throw error
|
|
830
|
-
},
|
|
831
|
-
})
|
|
832
|
-
|
|
833
|
-
const response = await router.fetch(makeRequest('/boom'))
|
|
834
|
-
|
|
835
|
-
expect(response.status).toBe(HttpStatus.SERVICE_UNAVAILABLE)
|
|
836
|
-
expect(await response.text()).toBe('broken')
|
|
837
|
-
expect(logger.calls).toHaveLength(1)
|
|
838
|
-
expect(logger.calls[0]?.method).toBe('error')
|
|
839
|
-
expect(logger.calls[0]?.data[0]).toBe('Routekit internal server error')
|
|
840
|
-
expect(logger.calls[0]?.data[1]).toBe(error)
|
|
841
|
-
expect(logger.calls[0]?.context).toEqual({
|
|
842
|
-
'http.request.method': HttpMethod.GET,
|
|
843
|
-
'http.route': '/boom',
|
|
844
|
-
'url.path': '/boom',
|
|
845
|
-
})
|
|
846
|
-
})
|
|
847
|
-
|
|
848
|
-
it('falls back to 500 when the onInternalError handler throws', async () => {
|
|
849
|
-
const logger = createTestLogger()
|
|
850
|
-
const routeError = new Error('boom')
|
|
851
|
-
const handlerError = new Error('handler boom')
|
|
852
|
-
const router = new Router()
|
|
853
|
-
.setLogger(logger)
|
|
854
|
-
.onInternalError(() => {
|
|
855
|
-
throw handlerError
|
|
856
|
-
})
|
|
857
|
-
.add({
|
|
858
|
-
method: HttpMethod.GET,
|
|
859
|
-
path: '/boom',
|
|
860
|
-
handler: () => {
|
|
861
|
-
throw routeError
|
|
862
|
-
},
|
|
863
|
-
})
|
|
864
|
-
|
|
865
|
-
const response = await router.fetch(makeRequest('/boom'))
|
|
866
|
-
|
|
867
|
-
expect(response.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
868
|
-
expect(await response.text()).toBe('Internal Server Error')
|
|
869
|
-
expect(logger.calls).toHaveLength(2)
|
|
870
|
-
expect(logger.calls[0]?.data[1]).toBe(routeError)
|
|
871
|
-
expect(logger.calls[1]?.data[1]).toBe(handlerError)
|
|
872
|
-
})
|
|
873
|
-
})
|
|
874
|
-
|
|
875
|
-
describe('Router.fetch', () => {
|
|
876
|
-
it('binds handler this to the matching router instance', async () => {
|
|
877
|
-
const router = new Router()
|
|
878
|
-
let boundRouter: Router | null = null
|
|
879
|
-
router.add({
|
|
880
|
-
method: HttpMethod.GET,
|
|
881
|
-
path: '/ping',
|
|
882
|
-
handler: function () {
|
|
883
|
-
boundRouter = this
|
|
884
|
-
return new Response('ok')
|
|
885
|
-
},
|
|
886
|
-
})
|
|
887
|
-
|
|
888
|
-
const response = await router.fetch(new Request('https://example.com/ping'))
|
|
889
|
-
|
|
890
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
891
|
-
expect(boundRouter === router).toBe(true)
|
|
892
|
-
})
|
|
893
|
-
|
|
894
|
-
it('handles unbound fetch usage', async () => {
|
|
895
|
-
const router = new Router()
|
|
896
|
-
router.add({
|
|
897
|
-
method: HttpMethod.GET,
|
|
898
|
-
path: '/ping',
|
|
899
|
-
handler: () => new Response('ok'),
|
|
900
|
-
})
|
|
901
|
-
|
|
902
|
-
const fetch = router.fetch
|
|
903
|
-
const response = await fetch(new Request('https://example.com/ping'))
|
|
904
|
-
|
|
905
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
906
|
-
expect(await response.text()).toBe('ok')
|
|
907
|
-
})
|
|
908
|
-
|
|
909
|
-
it('binds handlers to mounted routers', async () => {
|
|
910
|
-
const parent = new Router()
|
|
911
|
-
const child = new Router()
|
|
912
|
-
let boundRouter: Router | null = null
|
|
913
|
-
child.add({
|
|
914
|
-
method: HttpMethod.GET,
|
|
915
|
-
path: '/nested',
|
|
916
|
-
handler: function () {
|
|
917
|
-
boundRouter = this
|
|
918
|
-
return new Response('ok')
|
|
919
|
-
},
|
|
920
|
-
})
|
|
921
|
-
parent.mount('/api', child)
|
|
922
|
-
|
|
923
|
-
const response = await parent.fetch(new Request('https://example.com/api/nested'))
|
|
924
|
-
|
|
925
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
926
|
-
expect(boundRouter === child).toBe(true)
|
|
927
|
-
})
|
|
928
|
-
|
|
929
|
-
it('mounted routers use middleware from parent', async () => {
|
|
930
|
-
const rootRouter = new Router()
|
|
931
|
-
const childRouter = new Router()
|
|
932
|
-
|
|
933
|
-
rootRouter.mount('/api', childRouter) // mounted before adding route
|
|
934
|
-
|
|
935
|
-
childRouter.add({
|
|
936
|
-
method: HttpMethod.GET,
|
|
937
|
-
path: '/nested',
|
|
938
|
-
handler: () => noContent(),
|
|
939
|
-
})
|
|
940
|
-
|
|
941
|
-
const mockMiddleware = mock()
|
|
942
|
-
rootRouter.use(mockMiddleware) // registered after routes
|
|
943
|
-
|
|
944
|
-
const response = await rootRouter.fetch(new Request('https://example.com/api/nested'))
|
|
945
|
-
|
|
946
|
-
expect(response.status).toBe(HttpStatus.NO_CONTENT)
|
|
947
|
-
expect(mockMiddleware).toHaveBeenCalled()
|
|
948
|
-
})
|
|
949
|
-
|
|
950
|
-
it('mount middleware not applied to root routes', async () => {
|
|
951
|
-
const rootRouter = new Router()
|
|
952
|
-
|
|
953
|
-
const mockMiddleware = mock()
|
|
954
|
-
rootRouter.mount({ middleware: [mockMiddleware] }, (r) =>
|
|
955
|
-
r.get('/grouped', () => text('grouped')),
|
|
956
|
-
)
|
|
957
|
-
rootRouter.get('/root', () => text('root'))
|
|
958
|
-
|
|
959
|
-
const rootResponse = await rootRouter.fetch(new Request('https://example.com/root'))
|
|
960
|
-
|
|
961
|
-
expect(await rootResponse.text()).toBe('root')
|
|
962
|
-
expect(mockMiddleware).not.toHaveBeenCalled()
|
|
963
|
-
|
|
964
|
-
const groupedResponse = await rootRouter.fetch(new Request('https://example.com/grouped'))
|
|
965
|
-
|
|
966
|
-
expect(await groupedResponse.text()).toBe('grouped')
|
|
967
|
-
expect(mockMiddleware).toHaveBeenCalled()
|
|
968
|
-
})
|
|
969
|
-
|
|
970
|
-
it('mounted routers inherit the parent logger for internal errors', async () => {
|
|
971
|
-
const parentLogger = createTestLogger()
|
|
972
|
-
const error = new Error('boom')
|
|
973
|
-
const parent = new Router().setLogger(parentLogger)
|
|
974
|
-
const child = new Router()
|
|
975
|
-
child.add({
|
|
976
|
-
method: HttpMethod.GET,
|
|
977
|
-
path: '/nested',
|
|
978
|
-
handler: () => {
|
|
979
|
-
throw error
|
|
980
|
-
},
|
|
981
|
-
})
|
|
982
|
-
parent.mount('/api', child)
|
|
983
|
-
|
|
984
|
-
const response = await parent.fetch(new Request('https://example.com/api/nested'))
|
|
985
|
-
|
|
986
|
-
expect(response.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
987
|
-
expect(parentLogger.calls).toHaveLength(1)
|
|
988
|
-
expect(parentLogger.calls[0]?.data[1]).toBe(error)
|
|
989
|
-
})
|
|
990
|
-
|
|
991
|
-
it('mounted routers can override the inherited logger for internal errors', async () => {
|
|
992
|
-
const parentLogger = createTestLogger()
|
|
993
|
-
const childLogger = createTestLogger()
|
|
994
|
-
const error = new Error('boom')
|
|
995
|
-
const parent = new Router().setLogger(parentLogger)
|
|
996
|
-
const child = new Router().setLogger(childLogger)
|
|
997
|
-
child.add({
|
|
998
|
-
method: HttpMethod.GET,
|
|
999
|
-
path: '/nested',
|
|
1000
|
-
handler: () => {
|
|
1001
|
-
throw error
|
|
1002
|
-
},
|
|
1003
|
-
})
|
|
1004
|
-
parent.mount('/api', child)
|
|
1005
|
-
|
|
1006
|
-
const response = await parent.fetch(new Request('https://example.com/api/nested'))
|
|
1007
|
-
|
|
1008
|
-
expect(response.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
1009
|
-
expect(parentLogger.calls).toHaveLength(0)
|
|
1010
|
-
expect(childLogger.calls).toHaveLength(1)
|
|
1011
|
-
expect(childLogger.calls[0]?.data[1]).toBe(error)
|
|
1012
|
-
expect(childLogger.calls[0]?.context).toEqual({
|
|
1013
|
-
'http.request.method': HttpMethod.GET,
|
|
1014
|
-
'http.route': '/api/nested',
|
|
1015
|
-
'url.path': '/api/nested',
|
|
1016
|
-
})
|
|
1017
|
-
})
|
|
1018
|
-
})
|
|
1019
|
-
|
|
1020
|
-
describe('Router.addRequestBodyParser', () => {
|
|
1021
|
-
it('adds request body parsers inherited by mounted routers', async () => {
|
|
1022
|
-
const linesParser: RequestBodyParser<string[]> = {
|
|
1023
|
-
mediaTypes: ['application/x-lines'],
|
|
1024
|
-
parse: async (ctx) => (await ctx.text()).split('\n'),
|
|
1025
|
-
}
|
|
1026
|
-
const child = new Router().post('/lines', async ({ request }) => await request.body.parse())
|
|
1027
|
-
const router = new Router().addRequestBodyParser(linesParser).mount('/api', child)
|
|
1028
|
-
|
|
1029
|
-
const response = await router.fetch(
|
|
1030
|
-
new Request('https://example.com/api/lines', {
|
|
1031
|
-
method: HttpMethod.POST,
|
|
1032
|
-
headers: { 'content-type': 'application/x-lines' },
|
|
1033
|
-
body: 'a\nb',
|
|
1034
|
-
}),
|
|
1035
|
-
)
|
|
1036
|
-
|
|
1037
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
1038
|
-
expect(await response.json()).toEqual(['a', 'b'])
|
|
1039
|
-
})
|
|
1040
|
-
|
|
1041
|
-
it('uses parser media type q before registration order', async () => {
|
|
1042
|
-
const customParser: RequestBodyParser<string> = {
|
|
1043
|
-
mediaTypes: ['application/vnd.routekit+json'],
|
|
1044
|
-
parse: async (ctx) => `custom:${await ctx.text()}`,
|
|
1045
|
-
}
|
|
1046
|
-
const router = new Router()
|
|
1047
|
-
.addRequestBodyParser(customParser)
|
|
1048
|
-
.post('/body', async ({ request }) => await request.body.parse())
|
|
1049
|
-
|
|
1050
|
-
const response = await router.fetch(
|
|
1051
|
-
new Request('https://example.com/body', {
|
|
1052
|
-
method: HttpMethod.POST,
|
|
1053
|
-
headers: { 'content-type': 'application/vnd.routekit+json' },
|
|
1054
|
-
body: 'not json',
|
|
1055
|
-
}),
|
|
1056
|
-
)
|
|
1057
|
-
|
|
1058
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
1059
|
-
expect(await response.text()).toBe('custom:not json')
|
|
1060
|
-
})
|
|
1061
|
-
})
|
|
1062
|
-
|
|
1063
|
-
describe('Router.setRequestBodyParsers', () => {
|
|
1064
|
-
it('replaces inherited request body parsers for a mounted subtree', async () => {
|
|
1065
|
-
const rootParser: RequestBodyParser<string> = {
|
|
1066
|
-
mediaTypes: ['application/x-root'],
|
|
1067
|
-
parse: async (ctx) => `root:${await ctx.text()}`,
|
|
1068
|
-
}
|
|
1069
|
-
const childParser: RequestBodyParser<string> = {
|
|
1070
|
-
mediaTypes: ['application/x-child'],
|
|
1071
|
-
parse: async (ctx) => `child:${await ctx.text()}`,
|
|
1072
|
-
}
|
|
1073
|
-
const child = new Router()
|
|
1074
|
-
.setRequestBodyParsers([childParser])
|
|
1075
|
-
.post('/body', async ({ request }) => await request.body.parse())
|
|
1076
|
-
const router = new Router().addRequestBodyParser(rootParser).mount('/api', child)
|
|
1077
|
-
|
|
1078
|
-
const rootTypeResponse = await router.fetch(
|
|
1079
|
-
new Request('https://example.com/api/body', {
|
|
1080
|
-
method: HttpMethod.POST,
|
|
1081
|
-
headers: { 'content-type': 'application/x-root' },
|
|
1082
|
-
body: 'value',
|
|
1083
|
-
}),
|
|
1084
|
-
)
|
|
1085
|
-
const childTypeResponse = await router.fetch(
|
|
1086
|
-
new Request('https://example.com/api/body', {
|
|
1087
|
-
method: HttpMethod.POST,
|
|
1088
|
-
headers: { 'content-type': 'application/x-child' },
|
|
1089
|
-
body: 'value',
|
|
1090
|
-
}),
|
|
1091
|
-
)
|
|
1092
|
-
|
|
1093
|
-
expect(rootTypeResponse.status).toBe(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
|
1094
|
-
expect(childTypeResponse.status).toBe(HttpStatus.OK)
|
|
1095
|
-
expect(await childTypeResponse.text()).toBe('child:value')
|
|
1096
|
-
})
|
|
1097
|
-
})
|
|
1098
|
-
|
|
1099
|
-
describe('Router.addResponseBodySerializer', () => {
|
|
1100
|
-
it('adds response body serializers inherited by mounted routers', async () => {
|
|
1101
|
-
const serializer: ResponseBodySerializer<{ message: string }> = {
|
|
1102
|
-
mediaTypes: ['application/x-message'],
|
|
1103
|
-
canSerialize: (value): value is { message: string } =>
|
|
1104
|
-
typeof value === 'object' && value !== null && 'message' in value,
|
|
1105
|
-
serialize: (value) => `message:${value.message}`,
|
|
1106
|
-
}
|
|
1107
|
-
const child = new Router().get('/message', () => ({ message: 'hello' }))
|
|
1108
|
-
const router = new Router().addResponseBodySerializer(serializer).mount('/api', child)
|
|
1109
|
-
|
|
1110
|
-
const response = await router.fetch(
|
|
1111
|
-
new Request('https://example.com/api/message', {
|
|
1112
|
-
headers: { accept: 'application/x-message' },
|
|
1113
|
-
}),
|
|
1114
|
-
)
|
|
1115
|
-
|
|
1116
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
1117
|
-
expect(response.headers.get('content-type')).toBe('application/x-message')
|
|
1118
|
-
expect(await response.text()).toBe('message:hello')
|
|
1119
|
-
})
|
|
1120
|
-
|
|
1121
|
-
it('uses serializer media type q before Accept order when client q ties', async () => {
|
|
1122
|
-
const serializer: ResponseBodySerializer<{ message: string }> = {
|
|
1123
|
-
mediaTypes: ['application/x-low;q=0.5', 'application/x-high'],
|
|
1124
|
-
canSerialize: (value): value is { message: string } =>
|
|
1125
|
-
typeof value === 'object' && value !== null && 'message' in value,
|
|
1126
|
-
serialize: (value) => `message:${value.message}`,
|
|
1127
|
-
}
|
|
1128
|
-
const router = new Router().addResponseBodySerializer(serializer).get('/message', () => ({
|
|
1129
|
-
message: 'hello',
|
|
1130
|
-
}))
|
|
1131
|
-
|
|
1132
|
-
const response = await router.fetch(
|
|
1133
|
-
new Request('https://example.com/message', {
|
|
1134
|
-
headers: { accept: 'application/x-low, application/x-high' },
|
|
1135
|
-
}),
|
|
1136
|
-
)
|
|
1137
|
-
|
|
1138
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
1139
|
-
expect(response.headers.get('content-type')).toBe('application/x-high')
|
|
1140
|
-
expect(await response.text()).toBe('message:hello')
|
|
1141
|
-
})
|
|
1142
|
-
})
|
|
1143
|
-
|
|
1144
|
-
describe('Router.setResponseBodySerializers', () => {
|
|
1145
|
-
it('replaces inherited response body serializers for a mounted subtree', async () => {
|
|
1146
|
-
const serializer: ResponseBodySerializer<{ message: string }> = {
|
|
1147
|
-
mediaTypes: ['application/x-message'],
|
|
1148
|
-
canSerialize: (value): value is { message: string } =>
|
|
1149
|
-
typeof value === 'object' && value !== null && 'message' in value,
|
|
1150
|
-
serialize: (value) => `message:${value.message}`,
|
|
1151
|
-
}
|
|
1152
|
-
const child = new Router()
|
|
1153
|
-
.setResponseBodySerializers([serializer])
|
|
1154
|
-
.get('/message', () => ({ message: 'hello' }))
|
|
1155
|
-
const router = new Router().mount('/api', child)
|
|
1156
|
-
|
|
1157
|
-
const jsonResponse = await router.fetch(
|
|
1158
|
-
new Request('https://example.com/api/message', {
|
|
1159
|
-
headers: { accept: 'application/json' },
|
|
1160
|
-
}),
|
|
1161
|
-
)
|
|
1162
|
-
const customResponse = await router.fetch(
|
|
1163
|
-
new Request('https://example.com/api/message', {
|
|
1164
|
-
headers: { accept: 'application/x-message' },
|
|
1165
|
-
}),
|
|
1166
|
-
)
|
|
1167
|
-
|
|
1168
|
-
expect(jsonResponse.status).toBe(HttpStatus.NOT_ACCEPTABLE)
|
|
1169
|
-
expect(customResponse.status).toBe(HttpStatus.OK)
|
|
1170
|
-
expect(await customResponse.text()).toBe('message:hello')
|
|
1171
|
-
})
|
|
1172
|
-
})
|
|
1173
|
-
|
|
1174
|
-
describe('Router.use', () => {
|
|
1175
|
-
it('infers middleware-added context for handlers', () => {
|
|
1176
|
-
const router = new Router().useRequest(requestIdCtx())
|
|
1177
|
-
|
|
1178
|
-
router.add({
|
|
1179
|
-
method: HttpMethod.GET,
|
|
1180
|
-
path: '/',
|
|
1181
|
-
handler: (ctx) => {
|
|
1182
|
-
expectType<string>(ctx.requestId)
|
|
1183
|
-
expectType<Logger>(ctx.logger)
|
|
1184
|
-
return new Response('ok')
|
|
1185
|
-
},
|
|
1186
|
-
})
|
|
1187
|
-
})
|
|
1188
|
-
|
|
1189
|
-
it('merges context across middleware lists', () => {
|
|
1190
|
-
const addUser: ContextMiddleware<{ userId: string }> = (ctx) => {
|
|
1191
|
-
ctx.userId = 'user-1'
|
|
1192
|
-
}
|
|
1193
|
-
const addFlag: ContextMiddleware<{ isAdmin: boolean }> = (ctx) => {
|
|
1194
|
-
ctx.isAdmin = true
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
const router = new Router().use([addUser, addFlag])
|
|
1198
|
-
|
|
1199
|
-
router.add({
|
|
1200
|
-
method: HttpMethod.GET,
|
|
1201
|
-
path: '/',
|
|
1202
|
-
handler: (ctx) => {
|
|
1203
|
-
expectType<string>(ctx.userId)
|
|
1204
|
-
expectType<boolean>(ctx.isAdmin)
|
|
1205
|
-
return new Response('ok')
|
|
1206
|
-
},
|
|
1207
|
-
})
|
|
1208
|
-
})
|
|
1209
|
-
|
|
1210
|
-
it('passes middleware context through to handlers', async () => {
|
|
1211
|
-
const addRequestIdLocal: ContextMiddleware<{ requestId: number }> = (ctx) => {
|
|
1212
|
-
ctx.requestId = 42
|
|
1213
|
-
}
|
|
1214
|
-
const router = new Router().use(addRequestIdLocal)
|
|
1215
|
-
router.add({
|
|
1216
|
-
method: HttpMethod.GET,
|
|
1217
|
-
path: '/',
|
|
1218
|
-
handler: (ctx) => new Response(String(ctx.requestId)),
|
|
1219
|
-
})
|
|
1220
|
-
|
|
1221
|
-
const response = await router.fetch(makeRequest('/'))
|
|
1222
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
1223
|
-
expect(await response.text()).toBe('42')
|
|
1224
|
-
})
|
|
1225
|
-
})
|
|
1226
|
-
|
|
1227
|
-
describe('Route middleware', () => {
|
|
1228
|
-
it('runs for a single route and exposes added context to the handler', async () => {
|
|
1229
|
-
const addRouteContext: ContextMiddleware<{ routeValue: string }> = (ctx) => {
|
|
1230
|
-
ctx.routeValue = 'route'
|
|
1231
|
-
}
|
|
1232
|
-
const router = new Router()
|
|
1233
|
-
|
|
1234
|
-
router.get('/items', {
|
|
1235
|
-
middleware: [addRouteContext],
|
|
1236
|
-
handler: (ctx) => {
|
|
1237
|
-
expectType<TypeEqual<typeof ctx.routeValue, string>>(true)
|
|
1238
|
-
return new Response(ctx.routeValue)
|
|
1239
|
-
},
|
|
1240
|
-
})
|
|
1241
|
-
|
|
1242
|
-
const response = await router.fetch(makeRequest('/items'))
|
|
1243
|
-
const [route] = router.getRoutes()
|
|
1244
|
-
|
|
1245
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
1246
|
-
expect(await response.text()).toBe('route')
|
|
1247
|
-
expect(route && 'middleware' in route).toBe(false)
|
|
1248
|
-
})
|
|
1249
|
-
|
|
1250
|
-
it('allows route middleware to short-circuit the handler', async () => {
|
|
1251
|
-
const handler = mock(() => new Response('handler'))
|
|
1252
|
-
const stop = defineMiddleware({
|
|
1253
|
-
responses: {
|
|
1254
|
-
[HttpStatus.IM_A_TEAPOT]: {
|
|
1255
|
-
schema: { type: 'string' },
|
|
1256
|
-
parse: (value: unknown) => String(value),
|
|
1257
|
-
},
|
|
1258
|
-
},
|
|
1259
|
-
run: (_ctx, { respond }) =>
|
|
1260
|
-
respond(text('blocked', { status: HttpStatus.IM_A_TEAPOT })),
|
|
1261
|
-
})
|
|
1262
|
-
const router = new Router()
|
|
1263
|
-
|
|
1264
|
-
router.get('/blocked', {
|
|
1265
|
-
middleware: stop,
|
|
1266
|
-
handler,
|
|
1267
|
-
})
|
|
1268
|
-
|
|
1269
|
-
const response = await router.fetch(makeRequest('/blocked'))
|
|
1270
|
-
|
|
1271
|
-
expect(response.status).toBe(HttpStatus.IM_A_TEAPOT)
|
|
1272
|
-
expect(await response.text()).toBe('blocked')
|
|
1273
|
-
expect(handler).not.toHaveBeenCalled()
|
|
1274
|
-
})
|
|
1275
|
-
|
|
1276
|
-
it('does not allow raw context middleware to return a terminal response', () => {
|
|
1277
|
-
// @ts-expect-error Raw context middleware can only initialize context.
|
|
1278
|
-
const invalid: ContextMiddleware = () => new Response('blocked')
|
|
1279
|
-
|
|
1280
|
-
expect(invalid).toBeDefined()
|
|
1281
|
-
})
|
|
1282
|
-
|
|
1283
|
-
it('runs after parent and mount middleware', async () => {
|
|
1284
|
-
const events: string[] = []
|
|
1285
|
-
const log = (label: string) =>
|
|
1286
|
-
defineMiddleware({
|
|
1287
|
-
async run(_ctx, { next, forward }) {
|
|
1288
|
-
events.push(`${label}:before`)
|
|
1289
|
-
const result = await next()
|
|
1290
|
-
events.push(`${label}:after`)
|
|
1291
|
-
return forward(result)
|
|
1292
|
-
},
|
|
1293
|
-
})
|
|
1294
|
-
|
|
1295
|
-
const router = new Router().use(log('root'))
|
|
1296
|
-
router.mount({ middleware: log('mount') }, (mounted) => {
|
|
1297
|
-
mounted.get('/items', {
|
|
1298
|
-
middleware: log('route'),
|
|
1299
|
-
handler: () => {
|
|
1300
|
-
events.push('handler')
|
|
1301
|
-
return new Response('ok')
|
|
1302
|
-
},
|
|
1303
|
-
})
|
|
1304
|
-
})
|
|
1305
|
-
|
|
1306
|
-
const response = await router.fetch(makeRequest('/items'))
|
|
1307
|
-
|
|
1308
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
1309
|
-
expect(events).toEqual([
|
|
1310
|
-
'root:before',
|
|
1311
|
-
'mount:before',
|
|
1312
|
-
'route:before',
|
|
1313
|
-
'handler',
|
|
1314
|
-
'route:after',
|
|
1315
|
-
'mount:after',
|
|
1316
|
-
'root:after',
|
|
1317
|
-
])
|
|
1318
|
-
})
|
|
1319
|
-
|
|
1320
|
-
it('validates and exposes a terminal middleware response declaration', async () => {
|
|
1321
|
-
const requireAuth = defineMiddleware({
|
|
1322
|
-
responses: {
|
|
1323
|
-
[HttpStatus.UNAUTHORIZED]: {
|
|
1324
|
-
schema: { const: 'sign in' },
|
|
1325
|
-
parse(value: unknown): 'sign in' {
|
|
1326
|
-
if (value !== 'sign in') throw new TypeError('Expected auth response.')
|
|
1327
|
-
return value
|
|
1328
|
-
},
|
|
1329
|
-
},
|
|
1330
|
-
},
|
|
1331
|
-
run: (_ctx, { respond }) =>
|
|
1332
|
-
respond(logicalResponse('sign in', { status: HttpStatus.UNAUTHORIZED })),
|
|
1333
|
-
})
|
|
1334
|
-
const router = new Router()
|
|
1335
|
-
router.get('/private', { middleware: requireAuth, handler: () => new Response('hidden') })
|
|
1336
|
-
|
|
1337
|
-
const response = await router.fetch(makeRequest('/private'))
|
|
1338
|
-
|
|
1339
|
-
expect(response.status).toBe(HttpStatus.UNAUTHORIZED)
|
|
1340
|
-
expect(await response.json()).toBe('sign in')
|
|
1341
|
-
expect(router.getRoutes()[0]?.schema?.response?.body).toEqual({
|
|
1342
|
-
[HttpStatus.UNAUTHORIZED]: { const: 'sign in' },
|
|
1343
|
-
})
|
|
1344
|
-
})
|
|
1345
|
-
|
|
1346
|
-
it('enforces declared terminal response body and status types', () => {
|
|
1347
|
-
const middleware = defineMiddleware({
|
|
1348
|
-
responses: {
|
|
1349
|
-
[HttpStatus.UNAUTHORIZED]: {
|
|
1350
|
-
schema: { type: 'string' },
|
|
1351
|
-
parse(value: unknown): string {
|
|
1352
|
-
return String(value)
|
|
1353
|
-
},
|
|
1354
|
-
},
|
|
1355
|
-
},
|
|
1356
|
-
run: (_ctx, { respond }) => {
|
|
1357
|
-
respond(logicalResponse('sign in', { status: HttpStatus.UNAUTHORIZED }))
|
|
1358
|
-
// @ts-expect-error The body must match the response declared for status 401.
|
|
1359
|
-
respond(logicalResponse({ error: true }, { status: HttpStatus.UNAUTHORIZED }))
|
|
1360
|
-
// @ts-expect-error Status 403 has no terminal declaration.
|
|
1361
|
-
respond(logicalResponse('denied', { status: HttpStatus.FORBIDDEN }))
|
|
1362
|
-
// @ts-expect-error Native responses do not provide a statically declared body/status.
|
|
1363
|
-
respond(new Response('sign in', { status: HttpStatus.UNAUTHORIZED }))
|
|
1364
|
-
},
|
|
1365
|
-
})
|
|
1366
|
-
const created = logicalResponse({ id: 'one' }, { status: HttpStatus.CREATED })
|
|
1367
|
-
|
|
1368
|
-
expectType<TypeEqual<typeof created.status, HttpStatus.CREATED>>(true)
|
|
1369
|
-
expect(middleware).toBeDefined()
|
|
1370
|
-
})
|
|
1371
|
-
|
|
1372
|
-
it('infers declarations after middleware context types are fixed', () => {
|
|
1373
|
-
const middleware: DeclaredMiddleware<{ auth: string }, { services: string }> =
|
|
1374
|
-
defineMiddleware({
|
|
1375
|
-
responses: {
|
|
1376
|
-
[HttpStatus.UNAUTHORIZED]: {
|
|
1377
|
-
schema: { type: 'string' },
|
|
1378
|
-
parse(value: unknown): string {
|
|
1379
|
-
return String(value)
|
|
1380
|
-
},
|
|
1381
|
-
},
|
|
1382
|
-
},
|
|
1383
|
-
run: (ctx, { respond }) => {
|
|
1384
|
-
expectType<TypeEqual<typeof ctx.auth, string>>(true)
|
|
1385
|
-
expectType<TypeEqual<typeof ctx.services, string>>(true)
|
|
1386
|
-
respond(logicalResponse('sign in', { status: HttpStatus.UNAUTHORIZED }))
|
|
1387
|
-
// @ts-expect-error Contextual typing still permits inferred response bodies.
|
|
1388
|
-
respond(logicalResponse({ error: true }, { status: HttpStatus.UNAUTHORIZED }))
|
|
1389
|
-
},
|
|
1390
|
-
})
|
|
1391
|
-
|
|
1392
|
-
expect(middleware).toBeDefined()
|
|
1393
|
-
})
|
|
1394
|
-
|
|
1395
|
-
it('inherits declared metadata through retroactive use and mounted routers', () => {
|
|
1396
|
-
const rootResponse = defineMiddleware({
|
|
1397
|
-
responses: {
|
|
1398
|
-
[HttpStatus.UNAUTHORIZED]: {
|
|
1399
|
-
schema: { const: 'root' },
|
|
1400
|
-
parse: (value: unknown) => value,
|
|
1401
|
-
},
|
|
1402
|
-
},
|
|
1403
|
-
run: () => undefined,
|
|
1404
|
-
})
|
|
1405
|
-
const mountResponse = defineMiddleware({
|
|
1406
|
-
responses: {
|
|
1407
|
-
[HttpStatus.UNAUTHORIZED]: {
|
|
1408
|
-
schema: { const: 'mount' },
|
|
1409
|
-
parse: (value: unknown) => value,
|
|
1410
|
-
},
|
|
1411
|
-
},
|
|
1412
|
-
run: () => undefined,
|
|
1413
|
-
})
|
|
1414
|
-
const child = new Router().get('/item', () => new Response('ok'))
|
|
1415
|
-
const router = new Router().mount('/api', child)
|
|
1416
|
-
router.use(rootResponse)
|
|
1417
|
-
router.mount({ prefix: '/scoped', middleware: mountResponse }, (scoped) => {
|
|
1418
|
-
scoped.get('/item', () => new Response('ok'))
|
|
1419
|
-
})
|
|
1420
|
-
|
|
1421
|
-
const [mountedRoute, scopedRoute] = router.getRoutes()
|
|
1422
|
-
expect(mountedRoute?.schema?.response?.body).toEqual({
|
|
1423
|
-
[HttpStatus.UNAUTHORIZED]: { const: 'root' },
|
|
1424
|
-
})
|
|
1425
|
-
expect(scopedRoute?.schema?.response?.body).toEqual({
|
|
1426
|
-
[HttpStatus.UNAUTHORIZED]: {
|
|
1427
|
-
anyOf: [{ const: 'root' }, { const: 'mount' }],
|
|
1428
|
-
},
|
|
1429
|
-
})
|
|
1430
|
-
})
|
|
1431
|
-
|
|
1432
|
-
it('rejects duplicate declared request components in metadata and dispatch', async () => {
|
|
1433
|
-
const first = defineMiddleware({
|
|
1434
|
-
schema: { request: { path: { type: 'object' } } },
|
|
1435
|
-
run: () => undefined,
|
|
1436
|
-
})
|
|
1437
|
-
const second = defineMiddleware({
|
|
1438
|
-
schema: { request: { path: { type: 'object' } } },
|
|
1439
|
-
run: () => undefined,
|
|
1440
|
-
})
|
|
1441
|
-
const router = new Router()
|
|
1442
|
-
router.get('/duplicate', { middleware: [first, second], handler: () => new Response('ok') })
|
|
1443
|
-
|
|
1444
|
-
expect(() => router.getRoutes()).toThrow('request.path')
|
|
1445
|
-
const response = await router.fetch(makeRequest('/duplicate'))
|
|
1446
|
-
expect(response.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
1447
|
-
})
|
|
1448
|
-
})
|
|
1449
|
-
|
|
1450
|
-
describe('Router.mount', () => {
|
|
1451
|
-
it('exposes scoped middleware context to inline mounted handlers', () => {
|
|
1452
|
-
const addUser: ContextMiddleware<{ userId: string }> = (ctx) => {
|
|
1453
|
-
ctx.userId = 'user-2'
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
const router = new Router().useRequest(requestIdCtx())
|
|
1457
|
-
router.mount({ middleware: [addUser] }, (mounted) => {
|
|
1458
|
-
mounted.add({
|
|
1459
|
-
method: HttpMethod.GET,
|
|
1460
|
-
path: '/',
|
|
1461
|
-
handler: (ctx) => {
|
|
1462
|
-
expectType<TypeEqual<typeof ctx.requestId, string>>(true)
|
|
1463
|
-
expectType<TypeEqual<typeof ctx.userId, string>>(true)
|
|
1464
|
-
return new Response('ok')
|
|
1465
|
-
},
|
|
1466
|
-
})
|
|
1467
|
-
})
|
|
1468
|
-
})
|
|
1469
|
-
|
|
1470
|
-
it('applies parent and scoped middleware in order', async () => {
|
|
1471
|
-
const events: string[] = []
|
|
1472
|
-
const log = (label: string) =>
|
|
1473
|
-
defineMiddleware({
|
|
1474
|
-
async run(_ctx, { next, forward }) {
|
|
1475
|
-
events.push(`${label}:before`)
|
|
1476
|
-
const result = await next()
|
|
1477
|
-
events.push(`${label}:after`)
|
|
1478
|
-
return forward(result)
|
|
1479
|
-
},
|
|
1480
|
-
})
|
|
1481
|
-
const router = new Router().use(log('root'))
|
|
1482
|
-
|
|
1483
|
-
router.mount({ middleware: [log('scope')] }, (scope) => {
|
|
1484
|
-
scope.add({
|
|
1485
|
-
method: HttpMethod.GET,
|
|
1486
|
-
path: '/items',
|
|
1487
|
-
handler: () => {
|
|
1488
|
-
events.push('handler')
|
|
1489
|
-
return new Response('ok')
|
|
1490
|
-
},
|
|
1491
|
-
})
|
|
1492
|
-
})
|
|
1493
|
-
|
|
1494
|
-
const response = await router.fetch(makeRequest('/items'))
|
|
1495
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
1496
|
-
expect(events).toEqual([
|
|
1497
|
-
'root:before',
|
|
1498
|
-
'scope:before',
|
|
1499
|
-
'handler',
|
|
1500
|
-
'scope:after',
|
|
1501
|
-
'root:after',
|
|
1502
|
-
])
|
|
1503
|
-
})
|
|
1504
|
-
|
|
1505
|
-
it('uses the inherited logger for inline mounted route internal errors', async () => {
|
|
1506
|
-
const logger = createTestLogger()
|
|
1507
|
-
const error = new Error('boom')
|
|
1508
|
-
const router = new Router().setLogger(logger)
|
|
1509
|
-
|
|
1510
|
-
router.mount((mounted) => {
|
|
1511
|
-
mounted.add({
|
|
1512
|
-
method: HttpMethod.GET,
|
|
1513
|
-
path: '/items',
|
|
1514
|
-
handler: () => {
|
|
1515
|
-
throw error
|
|
1516
|
-
},
|
|
1517
|
-
})
|
|
1518
|
-
})
|
|
1519
|
-
|
|
1520
|
-
const response = await router.fetch(makeRequest('/items'))
|
|
1521
|
-
|
|
1522
|
-
expect(response.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
1523
|
-
expect(logger.calls).toHaveLength(1)
|
|
1524
|
-
expect(logger.calls[0]?.data[1]).toBe(error)
|
|
1525
|
-
})
|
|
1526
|
-
|
|
1527
|
-
it('uses mounted prefix onNotFound handlers for misses under the prefix', async () => {
|
|
1528
|
-
const child = new Router().onNotFound(() => new Response('child missing'))
|
|
1529
|
-
const router = new Router()
|
|
1530
|
-
.onNotFound(() => new Response('root missing'))
|
|
1531
|
-
.mount('/api', child)
|
|
1532
|
-
|
|
1533
|
-
const response = await router.fetch(makeRequest('/api/missing'))
|
|
1534
|
-
|
|
1535
|
-
expect(response.status).toBe(HttpStatus.OK)
|
|
1536
|
-
expect(await response.text()).toBe('child missing')
|
|
1537
|
-
})
|
|
1538
|
-
|
|
1539
|
-
it('uses mounted prefix onMethodNotAllowed handlers for method misses under the prefix', async () => {
|
|
1540
|
-
const child = new Router()
|
|
1541
|
-
.onMethodNotAllowed(() => new Response('child method', { status: HttpStatus.ACCEPTED }))
|
|
1542
|
-
.post('/items', () => new Response('created'))
|
|
1543
|
-
const router = new Router()
|
|
1544
|
-
.onMethodNotAllowed(() => new Response('root method'))
|
|
1545
|
-
.mount('/api', child)
|
|
1546
|
-
|
|
1547
|
-
const response = await router.fetch(makeRequest('/api/items', HttpMethod.GET))
|
|
1548
|
-
|
|
1549
|
-
expect(response.status).toBe(HttpStatus.ACCEPTED)
|
|
1550
|
-
expect(await response.text()).toBe('child method')
|
|
1551
|
-
})
|
|
1552
|
-
|
|
1553
|
-
it('uses mounted route onUnsupportedMediaType handlers', async () => {
|
|
1554
|
-
const child = new Router()
|
|
1555
|
-
.onUnsupportedMediaType(
|
|
1556
|
-
() => new Response('child unsupported', { status: HttpStatus.ACCEPTED }),
|
|
1557
|
-
)
|
|
1558
|
-
.post('/json', {
|
|
1559
|
-
accept: ['application/json'],
|
|
1560
|
-
handler: () => new Response('ok'),
|
|
1561
|
-
})
|
|
1562
|
-
const router = new Router()
|
|
1563
|
-
.onUnsupportedMediaType(() => new Response('root unsupported'))
|
|
1564
|
-
.mount('/api', child)
|
|
1565
|
-
|
|
1566
|
-
const response = await router.fetch(makeRequest('/api/json', HttpMethod.POST))
|
|
1567
|
-
|
|
1568
|
-
expect(response.status).toBe(HttpStatus.ACCEPTED)
|
|
1569
|
-
expect(await response.text()).toBe('child unsupported')
|
|
1570
|
-
})
|
|
1571
|
-
})
|