@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.
Files changed (133) hide show
  1. package/dist/bin.d.mts +4 -0
  2. package/dist/client/react.d.mts +178 -0
  3. package/dist/client/react.mjs +142 -0
  4. package/dist/client.d.mts +433 -0
  5. package/dist/client.mjs +264 -0
  6. package/dist/content-BuDOmhH_.mjs +102 -0
  7. package/dist/core-CzUCxvGk.d.mts +140 -0
  8. package/dist/core-DbmQauwS.mjs +81 -0
  9. package/dist/handlers.d.mts +72 -0
  10. package/dist/handlers.mjs +153 -0
  11. package/dist/index.d.mts +3 -0
  12. package/dist/index.mjs +1152 -0
  13. package/dist/middleware.d.mts +388 -0
  14. package/dist/middleware.mjs +1222 -0
  15. package/dist/request-Dn0zc-xm.mjs +1025 -0
  16. package/dist/response/content.d.mts +79 -0
  17. package/dist/response/content.mjs +2 -0
  18. package/dist/response/json-rpc.d.mts +1 -0
  19. package/dist/response/json-rpc.mjs +1 -0
  20. package/dist/response/problem/valibot.d.mts +230 -0
  21. package/dist/response/problem/valibot.mjs +258 -0
  22. package/dist/response/problem.d.mts +415 -0
  23. package/dist/response/problem.mjs +183 -0
  24. package/dist/response/status.d.mts +45 -0
  25. package/dist/response/status.mjs +2 -0
  26. package/dist/responses-B379Ep9Y.d.mts +296 -0
  27. package/dist/responses-BpVrgeYi.mjs +101 -0
  28. package/dist/router-Cwb7ak0J.d.mts +1819 -0
  29. package/dist/routes.d.mts +282 -0
  30. package/dist/routes.mjs +311 -0
  31. package/dist/status-C-8mw-FB.mjs +59 -0
  32. package/dist/valibot-D7liFYyB.d.mts +290 -0
  33. package/dist/valibot-Du97X-TS.mjs +326 -0
  34. package/package.json +8 -2
  35. package/src/bin/gen-api-client.test.ts +0 -70
  36. package/src/bin/gen-api-client.ts +0 -986
  37. package/src/client/headers.ts +0 -31
  38. package/src/client/index.ts +0 -8
  39. package/src/client/promise.ts +0 -11
  40. package/src/client/react/index.test.tsx +0 -266
  41. package/src/client/react/index.ts +0 -431
  42. package/src/client/responses.test.ts +0 -151
  43. package/src/client/responses.ts +0 -278
  44. package/src/client/transport.ts +0 -74
  45. package/src/client/transports/body-codec.ts +0 -61
  46. package/src/client/transports/fetch.ts +0 -113
  47. package/src/client/tsconfig.json +0 -9
  48. package/src/client/types.ts +0 -15
  49. package/src/client/url.ts +0 -31
  50. package/src/index.ts +0 -63
  51. package/src/router/fetch-types.ts +0 -13
  52. package/src/router/handlers/index.ts +0 -2
  53. package/src/router/handlers/openapi/index.ts +0 -2
  54. package/src/router/handlers/openapi/openapi.ts +0 -293
  55. package/src/router/integration/zod-openapi.test.ts +0 -74
  56. package/src/router/lib/charset.test.ts +0 -22
  57. package/src/router/lib/charset.ts +0 -133
  58. package/src/router/lib/collections.ts +0 -3
  59. package/src/router/lib/format.test.ts +0 -67
  60. package/src/router/lib/format.ts +0 -35
  61. package/src/router/lib/host.ts +0 -4
  62. package/src/router/lib/json-schema.ts +0 -6
  63. package/src/router/lib/media-type.test.ts +0 -122
  64. package/src/router/lib/media-type.ts +0 -289
  65. package/src/router/lib/pathname.test.ts +0 -18
  66. package/src/router/lib/pathname.ts +0 -19
  67. package/src/router/lib/route-names.ts +0 -70
  68. package/src/router/lib/route-normalize.test.ts +0 -36
  69. package/src/router/lib/route-normalize.ts +0 -67
  70. package/src/router/lib/schema-merge.ts +0 -56
  71. package/src/router/middleware/accept-ctx.test.ts +0 -33
  72. package/src/router/middleware/accept-ctx.ts +0 -12
  73. package/src/router/middleware/body-limit.test.ts +0 -112
  74. package/src/router/middleware/body-limit.ts +0 -121
  75. package/src/router/middleware/content-type-context.ts +0 -0
  76. package/src/router/middleware/cors.test.ts +0 -269
  77. package/src/router/middleware/cors.ts +0 -490
  78. package/src/router/middleware/csrf.test.ts +0 -106
  79. package/src/router/middleware/csrf.ts +0 -192
  80. package/src/router/middleware/define.ts +0 -249
  81. package/src/router/middleware/index.ts +0 -34
  82. package/src/router/middleware/jsxhtml-response.ts +0 -0
  83. package/src/router/middleware/oas-swagger.ts +0 -0
  84. package/src/router/middleware/rate-limit.test.ts +0 -886
  85. package/src/router/middleware/rate-limit.ts +0 -920
  86. package/src/router/middleware/request-id-ctx.test.ts +0 -183
  87. package/src/router/middleware/request-id-ctx.ts +0 -135
  88. package/src/router/middleware/request-logger-format.test.ts +0 -16
  89. package/src/router/middleware/request-logger-format.ts +0 -269
  90. package/src/router/middleware/request-logger.test.ts +0 -267
  91. package/src/router/middleware/request-logger.ts +0 -131
  92. package/src/router/middleware/start-time-ctx.ts +0 -5
  93. package/src/router/request.ts +0 -611
  94. package/src/router/response/core.ts +0 -181
  95. package/src/router/response/directives.ts +0 -233
  96. package/src/router/response/formats/content/bodyless.ts +0 -54
  97. package/src/router/response/formats/content/content.ts +0 -79
  98. package/src/router/response/formats/content/index.ts +0 -2
  99. package/src/router/response/formats/json-rpc/index.ts +0 -2
  100. package/src/router/response/formats/problem/badRequest.ts +0 -90
  101. package/src/router/response/formats/problem/conflict.ts +0 -90
  102. package/src/router/response/formats/problem/created.ts +0 -40
  103. package/src/router/response/formats/problem/index.ts +0 -27
  104. package/src/router/response/formats/problem/notFound.ts +0 -90
  105. package/src/router/response/formats/problem/permissionDenied.ts +0 -90
  106. package/src/router/response/formats/problem/problem.test.ts +0 -888
  107. package/src/router/response/formats/problem/rateLimited.ts +0 -90
  108. package/src/router/response/formats/problem/responses.ts +0 -219
  109. package/src/router/response/formats/problem/root-errors.ts +0 -48
  110. package/src/router/response/formats/problem/sessionExpired.ts +0 -90
  111. package/src/router/response/formats/problem/types.ts +0 -170
  112. package/src/router/response/formats/problem/unauthenticated.ts +0 -90
  113. package/src/router/response/formats/problem/valibot.ts +0 -410
  114. package/src/router/response/formats/status/index.ts +0 -1
  115. package/src/router/response/formats/status/responses.ts +0 -59
  116. package/src/router/response/formats/status/status.test.ts +0 -21
  117. package/src/router/response/framers.ts +0 -85
  118. package/src/router/response/index.ts +0 -28
  119. package/src/router/response/openapi.test.ts +0 -96
  120. package/src/router/response/openapi.ts +0 -1
  121. package/src/router/response/serializers.ts +0 -66
  122. package/src/router/response/stream.ts +0 -35
  123. package/src/router/router.test.ts +0 -1571
  124. package/src/router/router.ts +0 -1965
  125. package/src/router/routes/index.ts +0 -46
  126. package/src/router/routes/valibot/index.ts +0 -18
  127. package/src/router/routes/valibot/valibot.ts +0 -1393
  128. package/src/router/routes/valibot.test.ts +0 -286
  129. package/src/router/routes/zod/index.ts +0 -18
  130. package/src/router/routes/zod/zod.ts +0 -1318
  131. package/src/router/routes/zod.test.ts +0 -280
  132. package/src/router/server-interface.ts +0 -31
  133. package/src/router/types.ts +0 -657
@@ -1,888 +0,0 @@
1
- #!/usr/bin/env -S bun test
2
- import { describe, expect, it } from 'bun:test'
3
- import { HttpMethod, HttpStatus } from '@mpen/http'
4
- import type { LogActivity, Logger } from '@mpen/logger'
5
- import { expectType, type TypeEqual } from '@mpen/ts-types'
6
- import * as v from 'valibot'
7
- import { Router } from '../../../router'
8
- import {
9
- badRequest,
10
- conflict,
11
- created,
12
- notFound,
13
- ok,
14
- permissionDenied,
15
- problem,
16
- problemRootErrors,
17
- rateLimited,
18
- sessionExpired,
19
- unauthenticated,
20
- validationProblem,
21
- } from './index'
22
- import {
23
- createValibotRouteBuilder,
24
- issueFromValibotIssue,
25
- issuesFromValibotIssues,
26
- okSchema,
27
- problemIssueSchema,
28
- problemValidationErrorHandler,
29
- problemSchema,
30
- standardResponseSchema,
31
- validationProblemFromValibotIssues,
32
- } from './valibot'
33
- import type { SuccessResponse, ProblemResponse } from './index'
34
-
35
- type TestLogger = Logger & {
36
- errorCalls: unknown[][]
37
- }
38
-
39
- function createTestLogger(): TestLogger {
40
- const errorCalls: unknown[][] = []
41
- const create = (): Logger => {
42
- const logger: Logger = {
43
- log() {},
44
- info() {},
45
- warn() {},
46
- error(...data: unknown[]) {
47
- errorCalls.push(data)
48
- },
49
- table() {},
50
- withContext() {
51
- return logger
52
- },
53
- startActivity(): LogActivity {
54
- return {
55
- logger,
56
- end() {},
57
- }
58
- },
59
- }
60
- return logger
61
- }
62
- return Object.assign(create(), { errorCalls })
63
- }
64
-
65
- describe(ok.name, () => {
66
- it('creates a successful standard response envelope', () => {
67
- const result = ok(
68
- { id: 'user_123' },
69
- { headers: { 'x-request-id': 'req_123' }, meta: { requestId: 'req_123' } },
70
- )
71
-
72
- expect(result.status).toBe(HttpStatus.OK)
73
- expect(result.headers.get('x-request-id')).toBe('req_123')
74
- expect(result.body).toEqual({
75
- success: true,
76
- data: { id: 'user_123' },
77
- meta: { requestId: 'req_123' },
78
- })
79
- expectType<
80
- TypeEqual<
81
- typeof result.body,
82
- SuccessResponse<{ readonly id: 'user_123' }, { readonly requestId: 'req_123' }>
83
- >
84
- >(true)
85
- })
86
- })
87
-
88
- describe(created.name, () => {
89
- it('creates a created standard response envelope', () => {
90
- const result = created(
91
- { id: 'user_123' },
92
- { headers: { 'x-request-id': 'req_123' }, meta: { requestId: 'req_123' } },
93
- )
94
-
95
- expect(result.status).toBe(HttpStatus.CREATED)
96
- expect(result.headers.get('x-request-id')).toBe('req_123')
97
- expect(result.body).toEqual({
98
- success: true,
99
- data: { id: 'user_123' },
100
- meta: { requestId: 'req_123' },
101
- })
102
- expectType<
103
- TypeEqual<
104
- typeof result.body,
105
- SuccessResponse<{ readonly id: 'user_123' }, { readonly requestId: 'req_123' }>
106
- >
107
- >(true)
108
- })
109
- })
110
-
111
- describe(problem.name, () => {
112
- it('creates a problem response envelope', () => {
113
- const result = problem({
114
- code: 'not_found',
115
- message: 'No user exists for the provided id.',
116
- status: HttpStatus.NOT_FOUND,
117
- title: 'User not found',
118
- headers: { 'x-request-id': 'req_123' },
119
- })
120
-
121
- expect(result.status).toBe(HttpStatus.NOT_FOUND)
122
- expect(result.headers.get('x-request-id')).toBe('req_123')
123
- expect(result.body).toEqual({
124
- success: false,
125
- error: {
126
- code: 'not_found',
127
- message: 'No user exists for the provided id.',
128
- title: 'User not found',
129
- },
130
- })
131
- expectType<TypeEqual<typeof result.body, ProblemResponse<'not_found'>>>(true)
132
- })
133
- })
134
-
135
- describe(problemRootErrors.name, () => {
136
- it('installs problem response handlers for router root errors', async () => {
137
- const error = new Error('boom')
138
- const logger = createTestLogger()
139
- const router = new Router()
140
- .setLogger(logger)
141
- .install(problemRootErrors())
142
- .post('/json', {
143
- accept: ['application/json'],
144
- handler: () => ok({ accepted: true }),
145
- })
146
- .get('/boom', () => {
147
- throw error
148
- })
149
-
150
- const cases = [
151
- {
152
- request: new Request('https://example.com/missing'),
153
- status: HttpStatus.NOT_FOUND,
154
- code: 'not_found',
155
- message: 'Not found',
156
- },
157
- {
158
- request: new Request('https://example.com/json', { method: HttpMethod.GET }),
159
- status: HttpStatus.METHOD_NOT_ALLOWED,
160
- code: 'method_not_allowed',
161
- message: 'Method not allowed',
162
- },
163
- {
164
- request: new Request('https://example.com/json', { method: HttpMethod.POST }),
165
- status: HttpStatus.UNSUPPORTED_MEDIA_TYPE,
166
- code: 'unsupported_media_type',
167
- message: 'Unsupported media type',
168
- },
169
- {
170
- request: new Request('https://example.com/boom'),
171
- status: HttpStatus.INTERNAL_SERVER_ERROR,
172
- code: 'internal_server_error',
173
- message: 'Internal server error',
174
- },
175
- ] as const
176
-
177
- for (const testCase of cases) {
178
- const response = await router.fetch(testCase.request)
179
-
180
- expect(response.status).toBe(testCase.status)
181
- expect(response.headers.get('content-type')).toBe('application/json')
182
- expect(await response.json()).toEqual({
183
- success: false,
184
- error: {
185
- code: testCase.code,
186
- message: testCase.message,
187
- },
188
- })
189
- }
190
- expect(logger.errorCalls).toHaveLength(1)
191
- expect(logger.errorCalls[0]?.[1]).toBe(error)
192
- })
193
-
194
- it('lets later root handlers override installed defaults', async () => {
195
- const router = new Router()
196
- .install(problemRootErrors())
197
- .onNotFound(() => new Response('custom missing', { status: HttpStatus.NOT_FOUND }))
198
-
199
- const response = await router.fetch(new Request('https://example.com/missing'))
200
-
201
- expect(response.status).toBe(HttpStatus.NOT_FOUND)
202
- expect(await response.text()).toBe('custom missing')
203
- })
204
- })
205
-
206
- describe(notFound.name, () => {
207
- it('creates a not found problem response envelope with string message', () => {
208
- const result = notFound('Todo not found.', {
209
- headers: { 'x-request-id': 'req_123' },
210
- })
211
-
212
- expect(result.status).toBe(HttpStatus.NOT_FOUND)
213
- expect(result.headers.get('x-request-id')).toBe('req_123')
214
- expect(result.body).toEqual({
215
- success: false,
216
- error: {
217
- code: 'not_found',
218
- message: 'Todo not found.',
219
- },
220
- })
221
- expectType<TypeEqual<typeof result.body, ProblemResponse<'not_found'>>>(true)
222
- })
223
-
224
- it('creates a not found problem response envelope with options object', () => {
225
- const result = notFound({
226
- message: 'User not found.',
227
- headers: { 'x-request-id': 'req_123' },
228
- })
229
-
230
- expect(result.status).toBe(HttpStatus.NOT_FOUND)
231
- expect(result.headers.get('x-request-id')).toBe('req_123')
232
- expect(result.body).toEqual({
233
- success: false,
234
- error: {
235
- code: 'not_found',
236
- message: 'User not found.',
237
- },
238
- })
239
- expectType<TypeEqual<typeof result.body, ProblemResponse<'not_found'>>>(true)
240
- })
241
-
242
- it('uses default message when none provided', () => {
243
- const result = notFound()
244
-
245
- expect(result.status).toBe(HttpStatus.NOT_FOUND)
246
- expect(result.body).toEqual({
247
- success: false,
248
- error: {
249
- code: 'not_found',
250
- message: 'Not found',
251
- },
252
- })
253
- expectType<TypeEqual<typeof result.body, ProblemResponse<'not_found'>>>(true)
254
- })
255
-
256
- it('allows overriding code and status', () => {
257
- const result = notFound({
258
- code: 'todo_not_found',
259
- status: HttpStatus.GONE,
260
- message: 'This todo has been deleted.',
261
- })
262
-
263
- expect(result.status).toBe(HttpStatus.GONE)
264
- expect(result.body).toEqual({
265
- success: false,
266
- error: {
267
- code: 'todo_not_found',
268
- message: 'This todo has been deleted.',
269
- },
270
- })
271
- expectType<TypeEqual<typeof result.body, ProblemResponse<'todo_not_found'>>>(true)
272
- })
273
- })
274
-
275
- describe(permissionDenied.name, () => {
276
- it('creates a permission denied problem response envelope with string message', () => {
277
- const result = permissionDenied('You cannot access this.', {
278
- headers: { 'x-request-id': 'req_123' },
279
- })
280
-
281
- expect(result.status).toBe(HttpStatus.FORBIDDEN)
282
- expect(result.headers.get('x-request-id')).toBe('req_123')
283
- expect(result.body).toEqual({
284
- success: false,
285
- error: {
286
- code: 'permission_denied',
287
- message: 'You cannot access this.',
288
- },
289
- })
290
- expectType<TypeEqual<typeof result.body, ProblemResponse<'permission_denied'>>>(true)
291
- })
292
-
293
- it('creates a permission denied problem response envelope with options object', () => {
294
- const result = permissionDenied({
295
- message: 'Insufficient privileges.',
296
- headers: { 'x-request-id': 'req_123' },
297
- })
298
-
299
- expect(result.status).toBe(HttpStatus.FORBIDDEN)
300
- expect(result.headers.get('x-request-id')).toBe('req_123')
301
- expect(result.body).toEqual({
302
- success: false,
303
- error: {
304
- code: 'permission_denied',
305
- message: 'Insufficient privileges.',
306
- },
307
- })
308
- expectType<TypeEqual<typeof result.body, ProblemResponse<'permission_denied'>>>(true)
309
- })
310
-
311
- it('uses default message when none provided', () => {
312
- const result = permissionDenied()
313
-
314
- expect(result.status).toBe(HttpStatus.FORBIDDEN)
315
- expect(result.body).toEqual({
316
- success: false,
317
- error: {
318
- code: 'permission_denied',
319
- message: 'Permission denied',
320
- },
321
- })
322
- expectType<TypeEqual<typeof result.body, ProblemResponse<'permission_denied'>>>(true)
323
- })
324
-
325
- it('allows overriding code and status', () => {
326
- const result = permissionDenied({
327
- code: 'custom_denied',
328
- status: HttpStatus.FORBIDDEN,
329
- message: 'Custom permission error.',
330
- })
331
-
332
- expect(result.status).toBe(HttpStatus.FORBIDDEN)
333
- expect(result.body).toEqual({
334
- success: false,
335
- error: {
336
- code: 'custom_denied',
337
- message: 'Custom permission error.',
338
- },
339
- })
340
- expectType<TypeEqual<typeof result.body, ProblemResponse<'custom_denied'>>>(true)
341
- })
342
- })
343
-
344
- describe(unauthenticated.name, () => {
345
- it('creates an unauthenticated problem response envelope with string message', () => {
346
- const result = unauthenticated('Missing token.', {
347
- headers: { 'x-request-id': 'req_123' },
348
- })
349
-
350
- expect(result.status).toBe(HttpStatus.UNAUTHORIZED)
351
- expect(result.headers.get('x-request-id')).toBe('req_123')
352
- expect(result.body).toEqual({
353
- success: false,
354
- error: {
355
- code: 'unauthenticated',
356
- message: 'Missing token.',
357
- },
358
- })
359
- expectType<TypeEqual<typeof result.body, ProblemResponse<'unauthenticated'>>>(true)
360
- })
361
-
362
- it('creates an unauthenticated problem response envelope with options object', () => {
363
- const result = unauthenticated({
364
- message: 'Invalid credentials.',
365
- headers: { 'x-request-id': 'req_123' },
366
- })
367
-
368
- expect(result.status).toBe(HttpStatus.UNAUTHORIZED)
369
- expect(result.headers.get('x-request-id')).toBe('req_123')
370
- expect(result.body).toEqual({
371
- success: false,
372
- error: {
373
- code: 'unauthenticated',
374
- message: 'Invalid credentials.',
375
- },
376
- })
377
- expectType<TypeEqual<typeof result.body, ProblemResponse<'unauthenticated'>>>(true)
378
- })
379
-
380
- it('uses default message when none provided', () => {
381
- const result = unauthenticated()
382
-
383
- expect(result.status).toBe(HttpStatus.UNAUTHORIZED)
384
- expect(result.body).toEqual({
385
- success: false,
386
- error: {
387
- code: 'unauthenticated',
388
- message: 'Unauthenticated',
389
- },
390
- })
391
- expectType<TypeEqual<typeof result.body, ProblemResponse<'unauthenticated'>>>(true)
392
- })
393
-
394
- it('allows overriding code and status', () => {
395
- const result = unauthenticated({
396
- code: 'invalid_auth',
397
- status: HttpStatus.UNAUTHORIZED,
398
- message: 'Auth failed.',
399
- })
400
-
401
- expect(result.status).toBe(HttpStatus.UNAUTHORIZED)
402
- expect(result.body).toEqual({
403
- success: false,
404
- error: {
405
- code: 'invalid_auth',
406
- message: 'Auth failed.',
407
- },
408
- })
409
- expectType<TypeEqual<typeof result.body, ProblemResponse<'invalid_auth'>>>(true)
410
- })
411
- })
412
-
413
- describe(sessionExpired.name, () => {
414
- it('creates a session expired problem response envelope with string message', () => {
415
- const result = sessionExpired('Session has timed out.', {
416
- headers: { 'x-request-id': 'req_123' },
417
- })
418
-
419
- expect(result.status).toBe(HttpStatus.UNAUTHORIZED)
420
- expect(result.headers.get('x-request-id')).toBe('req_123')
421
- expect(result.body).toEqual({
422
- success: false,
423
- error: {
424
- code: 'session_expired',
425
- message: 'Session has timed out.',
426
- },
427
- })
428
- expectType<TypeEqual<typeof result.body, ProblemResponse<'session_expired'>>>(true)
429
- })
430
-
431
- it('creates a session expired problem response envelope with options object', () => {
432
- const result = sessionExpired({
433
- message: 'Session expired.',
434
- headers: { 'x-request-id': 'req_123' },
435
- })
436
-
437
- expect(result.status).toBe(HttpStatus.UNAUTHORIZED)
438
- expect(result.headers.get('x-request-id')).toBe('req_123')
439
- expect(result.body).toEqual({
440
- success: false,
441
- error: {
442
- code: 'session_expired',
443
- message: 'Session expired.',
444
- },
445
- })
446
- expectType<TypeEqual<typeof result.body, ProblemResponse<'session_expired'>>>(true)
447
- })
448
-
449
- it('uses default message when none provided', () => {
450
- const result = sessionExpired()
451
-
452
- expect(result.status).toBe(HttpStatus.UNAUTHORIZED)
453
- expect(result.body).toEqual({
454
- success: false,
455
- error: {
456
- code: 'session_expired',
457
- message: 'Session expired',
458
- },
459
- })
460
- expectType<TypeEqual<typeof result.body, ProblemResponse<'session_expired'>>>(true)
461
- })
462
- })
463
-
464
- describe(badRequest.name, () => {
465
- it('creates a bad request problem response envelope with string message', () => {
466
- const result = badRequest('Invalid format.', {
467
- headers: { 'x-request-id': 'req_123' },
468
- })
469
-
470
- expect(result.status).toBe(HttpStatus.BAD_REQUEST)
471
- expect(result.headers.get('x-request-id')).toBe('req_123')
472
- expect(result.body).toEqual({
473
- success: false,
474
- error: {
475
- code: 'bad_request',
476
- message: 'Invalid format.',
477
- },
478
- })
479
- expectType<TypeEqual<typeof result.body, ProblemResponse<'bad_request'>>>(true)
480
- })
481
-
482
- it('creates a bad request problem response envelope with options object', () => {
483
- const result = badRequest({
484
- message: 'Malformed payload.',
485
- headers: { 'x-request-id': 'req_123' },
486
- })
487
-
488
- expect(result.status).toBe(HttpStatus.BAD_REQUEST)
489
- expect(result.headers.get('x-request-id')).toBe('req_123')
490
- expect(result.body).toEqual({
491
- success: false,
492
- error: {
493
- code: 'bad_request',
494
- message: 'Malformed payload.',
495
- },
496
- })
497
- expectType<TypeEqual<typeof result.body, ProblemResponse<'bad_request'>>>(true)
498
- })
499
-
500
- it('uses default message when none provided', () => {
501
- const result = badRequest()
502
-
503
- expect(result.status).toBe(HttpStatus.BAD_REQUEST)
504
- expect(result.body).toEqual({
505
- success: false,
506
- error: {
507
- code: 'bad_request',
508
- message: 'Bad request',
509
- },
510
- })
511
- expectType<TypeEqual<typeof result.body, ProblemResponse<'bad_request'>>>(true)
512
- })
513
- })
514
-
515
- describe(conflict.name, () => {
516
- it('creates a conflict problem response envelope with string message', () => {
517
- const result = conflict('Already exists.', {
518
- headers: { 'x-request-id': 'req_123' },
519
- })
520
-
521
- expect(result.status).toBe(HttpStatus.CONFLICT)
522
- expect(result.headers.get('x-request-id')).toBe('req_123')
523
- expect(result.body).toEqual({
524
- success: false,
525
- error: {
526
- code: 'conflict',
527
- message: 'Already exists.',
528
- },
529
- })
530
- expectType<TypeEqual<typeof result.body, ProblemResponse<'conflict'>>>(true)
531
- })
532
-
533
- it('creates a conflict problem response envelope with options object', () => {
534
- const result = conflict({
535
- message: 'User already exists.',
536
- headers: { 'x-request-id': 'req_123' },
537
- })
538
-
539
- expect(result.status).toBe(HttpStatus.CONFLICT)
540
- expect(result.headers.get('x-request-id')).toBe('req_123')
541
- expect(result.body).toEqual({
542
- success: false,
543
- error: {
544
- code: 'conflict',
545
- message: 'User already exists.',
546
- },
547
- })
548
- expectType<TypeEqual<typeof result.body, ProblemResponse<'conflict'>>>(true)
549
- })
550
-
551
- it('uses default message when none provided', () => {
552
- const result = conflict()
553
-
554
- expect(result.status).toBe(HttpStatus.CONFLICT)
555
- expect(result.body).toEqual({
556
- success: false,
557
- error: {
558
- code: 'conflict',
559
- message: 'Conflict',
560
- },
561
- })
562
- expectType<TypeEqual<typeof result.body, ProblemResponse<'conflict'>>>(true)
563
- })
564
- })
565
-
566
- describe(rateLimited.name, () => {
567
- it('creates a rate limited problem response envelope with string message', () => {
568
- const result = rateLimited('Too many requests.', {
569
- headers: { 'x-request-id': 'req_123' },
570
- })
571
-
572
- expect(result.status).toBe(HttpStatus.TOO_MANY_REQUESTS)
573
- expect(result.headers.get('x-request-id')).toBe('req_123')
574
- expect(result.body).toEqual({
575
- success: false,
576
- error: {
577
- code: 'rate_limited',
578
- message: 'Too many requests.',
579
- },
580
- })
581
- expectType<TypeEqual<typeof result.body, ProblemResponse<'rate_limited'>>>(true)
582
- })
583
-
584
- it('creates a rate limited problem response envelope with options object', () => {
585
- const result = rateLimited({
586
- message: 'Rate limit exceeded.',
587
- headers: { 'x-request-id': 'req_123' },
588
- })
589
-
590
- expect(result.status).toBe(HttpStatus.TOO_MANY_REQUESTS)
591
- expect(result.headers.get('x-request-id')).toBe('req_123')
592
- expect(result.body).toEqual({
593
- success: false,
594
- error: {
595
- code: 'rate_limited',
596
- message: 'Rate limit exceeded.',
597
- },
598
- })
599
- expectType<TypeEqual<typeof result.body, ProblemResponse<'rate_limited'>>>(true)
600
- })
601
-
602
- it('uses default message when none provided', () => {
603
- const result = rateLimited()
604
-
605
- expect(result.status).toBe(HttpStatus.TOO_MANY_REQUESTS)
606
- expect(result.body).toEqual({
607
- success: false,
608
- error: {
609
- code: 'rate_limited',
610
- message: 'Rate limited',
611
- },
612
- })
613
- expectType<TypeEqual<typeof result.body, ProblemResponse<'rate_limited'>>>(true)
614
- })
615
- })
616
-
617
- describe(validationProblem.name, () => {
618
- it('creates a validation problem response envelope', () => {
619
- const result = validationProblem([
620
- {
621
- code: 'required',
622
- message: 'Email is required',
623
- path: ['body', 'email'],
624
- },
625
- ])
626
-
627
- expect(result.status).toBe(HttpStatus.BAD_REQUEST)
628
- expect(result.body).toEqual({
629
- success: false,
630
- error: {
631
- code: 'validation_failed',
632
- message: 'Validation failed',
633
- },
634
- issues: [
635
- {
636
- code: 'required',
637
- message: 'Email is required',
638
- path: ['body', 'email'],
639
- },
640
- ],
641
- })
642
- })
643
- })
644
-
645
- describe(problemIssueSchema.name, () => {
646
- it('builds a reusable issue schema with typed codes', () => {
647
- const schema = problemIssueSchema(v.picklist(['required', 'invalid_type']))
648
- const parsed = v.parse(schema, {
649
- code: 'required',
650
- message: 'Email is required',
651
- path: ['body', 'email'],
652
- expected: 'string',
653
- received: 'undefined',
654
- })
655
-
656
- expect(parsed).toEqual({
657
- code: 'required',
658
- message: 'Email is required',
659
- path: ['body', 'email'],
660
- expected: 'string',
661
- received: 'undefined',
662
- })
663
- expectType<
664
- TypeEqual<
665
- typeof parsed,
666
- {
667
- code: 'required' | 'invalid_type'
668
- message: string
669
- path?: readonly (string | number)[] | undefined
670
- expected?: string | undefined
671
- received?: string | undefined
672
- }
673
- >
674
- >(true)
675
- })
676
- })
677
-
678
- describe(okSchema.name, () => {
679
- it('builds a reusable success response schema', () => {
680
- const schema = okSchema(v.object({ id: v.string() }), v.object({ requestId: v.string() }))
681
- const parsed = v.parse(schema, {
682
- success: true,
683
- data: { id: 'user_123' },
684
- meta: { requestId: 'req_123' },
685
- })
686
-
687
- expect(parsed).toEqual({
688
- success: true,
689
- data: { id: 'user_123' },
690
- meta: { requestId: 'req_123' },
691
- })
692
- expectType<
693
- TypeEqual<
694
- typeof parsed,
695
- {
696
- success: true
697
- data: { id: string }
698
- meta?: { requestId: string } | undefined
699
- }
700
- >
701
- >(true)
702
- })
703
- })
704
-
705
- describe(standardResponseSchema.name, () => {
706
- it('builds a reusable standard response union schema', () => {
707
- const schema = standardResponseSchema(v.object({ id: v.string() }), {
708
- code: v.picklist(['not_found', 'validation_failed']),
709
- })
710
-
711
- expect(v.parse(schema, { success: true, data: { id: 'user_123' } })).toEqual({
712
- success: true,
713
- data: { id: 'user_123' },
714
- })
715
- expect(
716
- v.parse(schema, {
717
- success: false,
718
- error: {
719
- code: 'not_found',
720
- message: 'User not found',
721
- },
722
- }),
723
- ).toEqual({
724
- success: false,
725
- error: {
726
- code: 'not_found',
727
- message: 'User not found',
728
- },
729
- })
730
- })
731
- })
732
-
733
- describe(issueFromValibotIssue.name, () => {
734
- it('converts Valibot issues to standard response issues', () => {
735
- const parsed = v.safeParse(v.object({ email: v.string() }), { email: 123 })
736
- if (parsed.success) throw new Error('Expected parsing to fail')
737
-
738
- const issue = issueFromValibotIssue(parsed.issues[0])
739
-
740
- expect(issue).toMatchObject({
741
- code: 'string',
742
- path: ['email'],
743
- expected: 'string',
744
- })
745
- expect(typeof issue.message).toBe('string')
746
- expect(typeof issue.received).toBe('string')
747
- })
748
- })
749
-
750
- describe(issuesFromValibotIssues.name, () => {
751
- it('converts Valibot issue lists to standard response issues', () => {
752
- const parsed = v.safeParse(v.object({ user: v.object({ email: v.string() }) }), {
753
- user: { email: 123 },
754
- })
755
- if (parsed.success) throw new Error('Expected parsing to fail')
756
-
757
- const issues = issuesFromValibotIssues(parsed.issues)
758
-
759
- expect(issues).toHaveLength(1)
760
- expect(issues[0]).toMatchObject({
761
- code: 'string',
762
- path: ['user', 'email'],
763
- expected: 'string',
764
- })
765
- })
766
- })
767
-
768
- describe(validationProblemFromValibotIssues.name, () => {
769
- it('creates validation problem responses from Valibot issues', () => {
770
- const parsed = v.safeParse(v.object({ email: v.string() }), { email: 123 })
771
- if (parsed.success) throw new Error('Expected parsing to fail')
772
-
773
- const result = validationProblemFromValibotIssues(parsed.issues)
774
-
775
- expect(result.status).toBe(HttpStatus.BAD_REQUEST)
776
- expect(result.body.error.code).toBe('validation_failed')
777
- expect(result.body.issues?.[0]).toMatchObject({
778
- code: 'string',
779
- path: ['email'],
780
- })
781
- })
782
- })
783
-
784
- describe(problemValidationErrorHandler.name, () => {
785
- it('returns 422 and validation_failed:body for REQUEST_BODY component', () => {
786
- const issues: [v.BaseIssue<unknown>, ...v.BaseIssue<unknown>[]] = [
787
- {
788
- kind: 'schema',
789
- type: 'string',
790
- input: 123,
791
- expected: 'string',
792
- received: 'number',
793
- message: 'Expected a string',
794
- },
795
- ]
796
- const result = problemValidationErrorHandler(0, issues) // ValibotValidationError.REQUEST_BODY = 0
797
-
798
- expect(result.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY)
799
- expect(result.body.error.code).toBe('validation_failed:body')
800
- })
801
-
802
- it('returns 400 and validation_failed:query for QUERY_PARAMETERS component', () => {
803
- const issues: [v.BaseIssue<unknown>, ...v.BaseIssue<unknown>[]] = [
804
- {
805
- kind: 'schema',
806
- type: 'string',
807
- input: 123,
808
- expected: 'string',
809
- received: 'number',
810
- message: 'Expected a string',
811
- },
812
- ]
813
- const result = problemValidationErrorHandler(2, issues) // ValibotValidationError.QUERY_PARAMETERS = 2
814
-
815
- expect(result.status).toBe(HttpStatus.BAD_REQUEST)
816
- expect(result.body.error.code).toBe('validation_failed:query')
817
- })
818
- })
819
-
820
- describe(createValibotRouteBuilder.name, () => {
821
- it('creates a builder that automatically includes problem error schemas for 400 and 422', () => {
822
- const builder = createValibotRouteBuilder()
823
- const routeOptions = builder({
824
- schema: {
825
- request: {
826
- body: v.object({ title: v.string() }),
827
- },
828
- },
829
- handler: () => ({ success: true }),
830
- })
831
-
832
- const router = new Router().get('/example', routeOptions)
833
- const bodySchemas = router.getRoutes()[0]!.schema!.response!.body!
834
- expect(bodySchemas).toBeDefined()
835
- expect(bodySchemas[400]).toBeDefined()
836
- expect(bodySchemas[422]).toBeDefined()
837
-
838
- const schema400 = bodySchemas[400] as Record<string, unknown>
839
- const schema422 = bodySchemas[422] as Record<string, unknown>
840
-
841
- // 400 schema has anyOf representing query and path validation error types
842
- expect(schema400.anyOf).toBeDefined()
843
- // 422 schema has body validation error type
844
- const errorSchema = (schema422.properties as Record<string, unknown>)?.error as Record<
845
- string,
846
- unknown
847
- >
848
- const codeSchema = (errorSchema?.properties as Record<string, unknown>)?.code as Record<
849
- string,
850
- unknown
851
- >
852
- expect(codeSchema?.const).toBe('validation_failed:body')
853
- })
854
-
855
- it('merges overlapping custom response schemas correctly with v.union', () => {
856
- const builder = createValibotRouteBuilder()
857
- const routeOptions = builder({
858
- schema: {
859
- response: {
860
- body: {
861
- 422: problemSchema({ code: v.literal('custom_business_error') }),
862
- },
863
- },
864
- },
865
- handler: () => ({ success: true }),
866
- })
867
-
868
- const router = new Router().get('/example', routeOptions)
869
- const bodySchemas = router.getRoutes()[0]!.schema!.response!.body!
870
- expect(bodySchemas).toBeDefined()
871
- expect(bodySchemas[422]).toBeDefined()
872
- const schema422 = bodySchemas[422] as Record<string, unknown>
873
- // Merged schema for 422 status code should have anyOf with both error types
874
- expect(schema422.anyOf).toBeDefined()
875
- const anyOf = schema422.anyOf as Record<string, unknown>[]
876
- expect(anyOf).toHaveLength(2)
877
- const codes = anyOf.map((s) => {
878
- const err = (s.properties as Record<string, unknown>)?.error as Record<string, unknown>
879
- const code = (err?.properties as Record<string, unknown>)?.code as Record<
880
- string,
881
- unknown
882
- >
883
- return code?.const
884
- })
885
- expect(codes).toContain('validation_failed:body')
886
- expect(codes).toContain('custom_business_error')
887
- })
888
- })