@mpen/routekit 0.1.0 → 0.1.1

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,122 +0,0 @@
1
- #!/usr/bin/env -S bun test
2
- import { describe, expect, it } from 'bun:test'
3
- import {
4
- isJsonContentType,
5
- mediaRangeAccepts,
6
- mediaRangeQuality,
7
- mediaRangeToContentType,
8
- mediaTypeMatches,
9
- parseAcceptHeader,
10
- parseContentType,
11
- parseMediaType,
12
- } from './media-type'
13
-
14
- describe(parseMediaType.name, function () {
15
- it('parses types with parameters', function () {
16
- const media = parseMediaType('multipart/form-data; boundary=abc; charset=UTF-8')
17
- expect(media).toEqual({
18
- type: 'multipart/form-data',
19
- boundary: 'abc',
20
- charset: 'utf-8',
21
- })
22
- })
23
-
24
- it('parses quoted parameters', function () {
25
- const media = parseMediaType('multipart/form-data; boundary="xyz-123"')
26
- expect(media).toEqual({
27
- type: 'multipart/form-data',
28
- boundary: 'xyz-123',
29
- })
30
- })
31
-
32
- it('parses with no parameters', function () {
33
- const media = parseMediaType('application/json')
34
- expect(media).toEqual({
35
- type: 'application/json',
36
- })
37
- })
38
-
39
- it('preserves q as a content-type parameter instead of accept metadata', function () {
40
- const media = parseMediaType('application/json; q=0.7')
41
- expect(media).toEqual({
42
- type: 'application/json',
43
- parameters: { q: '0.7' },
44
- })
45
- })
46
-
47
- it('rejects invalid content-type values', function () {
48
- expect(parseContentType('application/json, text/plain')).toBeNull()
49
- expect(parseContentType('application')).toBeNull()
50
- expect(parseContentType('*/*')).toBeNull()
51
- })
52
- })
53
-
54
- describe(mediaTypeMatches.name, function () {
55
- it('matches types and charsets when compatible', function () {
56
- const accept = parseMediaType('application/json; charset=utf-8')!
57
- const contentType = parseMediaType('application/json; charset=UTF8')!
58
- expect(mediaTypeMatches(accept, contentType)).toBe(true)
59
- })
60
-
61
- it('matches when charset is missing on either side', function () {
62
- const accept = parseMediaType('application/json; charset=utf-8')!
63
- const contentType = parseMediaType('application/json')!
64
- expect(mediaTypeMatches(accept, contentType)).toBe(true)
65
- expect(mediaTypeMatches(contentType, accept)).toBe(true)
66
- })
67
-
68
- it('rejects mismatched media types', function () {
69
- const accept = parseMediaType('application/json')!
70
- const contentType = parseMediaType('text/plain')!
71
- expect(mediaTypeMatches(accept, contentType)).toBe(false)
72
- })
73
- })
74
-
75
- describe(parseAcceptHeader.name, function () {
76
- it('sorts by descending q and preserves order for ties', function () {
77
- const accept = parseAcceptHeader(
78
- 'text/plain;q=0.5, application/json, text/html;q=0.9, image/png;q=0.9',
79
- )
80
- expect(accept).toEqual([
81
- { type: 'application/json', q: 1 },
82
- { type: 'text/html', q: 0.9 },
83
- { type: 'image/png', q: 0.9 },
84
- { type: 'text/plain', q: 0.5 },
85
- ])
86
- })
87
- })
88
-
89
- describe(mediaRangeQuality.name, function () {
90
- it('reads q preference from media ranges', function () {
91
- expect(mediaRangeQuality('application/*+json;q=0.5')).toBe(0.5)
92
- expect(mediaRangeQuality('application/json')).toBe(1)
93
- })
94
- })
95
-
96
- describe(mediaRangeToContentType.name, function () {
97
- it('strips q preference from formatted content types', function () {
98
- expect(mediaRangeToContentType('application/json; charset=utf-8; q=0.5')).toBe(
99
- 'application/json; charset=utf-8',
100
- )
101
- })
102
- })
103
-
104
- describe(isJsonContentType.name, function () {
105
- it('accepts exact and structured JSON content types', function () {
106
- expect(isJsonContentType('application/json; charset=utf-8')).toBe(true)
107
- expect(isJsonContentType('application/problem+json')).toBe(true)
108
- })
109
-
110
- it('rejects substring and non-json content types', function () {
111
- expect(isJsonContentType('fooapplication/jsonbar')).toBe(false)
112
- expect(isJsonContentType('text/plain')).toBe(false)
113
- })
114
- })
115
-
116
- describe(mediaRangeAccepts.name, function () {
117
- it('matches wildcard media ranges', function () {
118
- expect(mediaRangeAccepts('*/*', 'application/json')).toBe(true)
119
- expect(mediaRangeAccepts('application/*', 'application/json')).toBe(true)
120
- expect(mediaRangeAccepts('application/*+json', 'application/problem+json')).toBe(true)
121
- })
122
- })
@@ -1,289 +0,0 @@
1
- import type { AcceptMediaRange, ContentType, MediaType } from '../types'
2
- import { normalizeCharsetName } from './charset'
3
-
4
- const tokenPattern = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i
5
- const rangeTokenPattern = /^(?:\*|[!#$%&'*+.^_`|~0-9a-z-]+)$/i
6
-
7
- function normalizeToken(value: string): string {
8
- return value.trim()
9
- }
10
-
11
- function normalizeType(value: string): string {
12
- return normalizeToken(value).toLowerCase()
13
- }
14
-
15
- function isValidType(value: string, allowRange: boolean): boolean {
16
- const [type, subtype, extra] = value.split('/')
17
- if (!type || !subtype || extra !== undefined) return false
18
- if (!allowRange && (type.includes('*') || subtype.includes('*'))) return false
19
- const pattern = allowRange ? rangeTokenPattern : tokenPattern
20
- if (!pattern.test(type) || !pattern.test(subtype)) return false
21
- if (type === '*' && subtype !== '*') return false
22
- return true
23
- }
24
-
25
- function splitParameters(value: string): string[] {
26
- const parts: string[] = []
27
- let current = ''
28
- let inQuote = false
29
- let escaped = false
30
-
31
- for (const char of value) {
32
- if (escaped) {
33
- current += char
34
- escaped = false
35
- continue
36
- }
37
- if (char === '\\' && inQuote) {
38
- escaped = true
39
- current += char
40
- continue
41
- }
42
- if (char === '"') {
43
- inQuote = !inQuote
44
- current += char
45
- continue
46
- }
47
- if (char === ';' && !inQuote) {
48
- parts.push(current)
49
- current = ''
50
- continue
51
- }
52
- current += char
53
- }
54
- parts.push(current)
55
- return parts
56
- }
57
-
58
- function unquote(value: string): string {
59
- const trimmed = value.trim()
60
- if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return trimmed
61
- return trimmed.slice(1, -1).replace(/\\(["\\])/g, '$1')
62
- }
63
-
64
- function parseParameterizedMediaType(
65
- value: string,
66
- options: { allowRange: boolean },
67
- ): ContentType | null {
68
- const [rawType = '', ...rawParams] = splitParameters(value)
69
- const type = normalizeType(rawType)
70
- if (!type || !isValidType(type, options.allowRange)) return null
71
-
72
- const parameters: Record<string, string> = {}
73
- const result: ContentType = { type }
74
- for (const param of rawParams) {
75
- const separatorIndex = param.indexOf('=')
76
- if (separatorIndex <= 0) continue
77
- const key = param.slice(0, separatorIndex).trim().toLowerCase()
78
- if (!key || !tokenPattern.test(key)) continue
79
- const paramValue = unquote(param.slice(separatorIndex + 1))
80
- parameters[key] = paramValue
81
- if (key === 'charset') result.charset = normalizeCharsetName(paramValue)
82
- if (key === 'boundary') result.boundary = paramValue
83
- }
84
-
85
- const extraParameters = Object.fromEntries(
86
- Object.entries(parameters).filter(([key]) => key !== 'charset' && key !== 'boundary'),
87
- )
88
- if (Object.keys(extraParameters).length > 0) {
89
- result.parameters = extraParameters
90
- }
91
-
92
- return result
93
- }
94
-
95
- function parseMediaRange(value: string): ContentType | null {
96
- return parseParameterizedMediaType(value, { allowRange: true })
97
- }
98
-
99
- function normalizeQuality(value: string | number | undefined): number {
100
- if (value === undefined) return 1
101
- const q = typeof value === 'number' ? value : Number.parseFloat(value)
102
- return Number.isFinite(q) && q >= 0 && q <= 1 ? q : 1
103
- }
104
-
105
- function formatContentType(value: ContentType): string {
106
- const params: string[] = []
107
- if (value.charset) params.push(`charset=${value.charset}`)
108
- if (value.boundary) params.push(`boundary=${value.boundary}`)
109
- for (const [key, paramValue] of Object.entries(value.parameters ?? {})) {
110
- if (key === 'q') continue
111
- params.push(`${key}=${paramValue}`)
112
- }
113
- return [value.type, ...params].join('; ')
114
- }
115
-
116
- /**
117
- * Normalize a concrete media type.
118
- *
119
- * @param value - Media type to normalize.
120
- * @returns Normalized media type.
121
- */
122
- export function normalizeMediaType(value: MediaType): MediaType {
123
- const type = normalizeType(value.type)
124
- const charset = value.charset ? normalizeCharsetName(value.charset) : undefined
125
- const boundary = value.boundary ? normalizeToken(value.boundary) : undefined
126
- const parameters = value.parameters
127
- ? Object.fromEntries(
128
- Object.entries(value.parameters).map(([key, paramValue]) => [
129
- key.trim().toLowerCase(),
130
- paramValue.trim(),
131
- ]),
132
- )
133
- : undefined
134
- const result: MediaType = { type }
135
- if (charset) result.charset = charset
136
- if (boundary) result.boundary = boundary
137
- if (parameters && Object.keys(parameters).length > 0) result.parameters = parameters
138
- return result
139
- }
140
-
141
- /**
142
- * Parse a single HTTP `Content-Type` header value.
143
- *
144
- * @param value - Header value to parse.
145
- * @returns Parsed content type, or `null` when the value is invalid or missing.
146
- */
147
- export function parseContentType(value: string | null | undefined): ContentType | null {
148
- if (!value) return null
149
- return parseParameterizedMediaType(value, { allowRange: false })
150
- }
151
-
152
- /**
153
- * Parse a concrete media type value.
154
- *
155
- * @param value - Media type value to parse.
156
- * @returns Parsed media type, or `null` when the value is invalid.
157
- */
158
- export function parseMediaType(value: string): MediaType | null {
159
- return parseContentType(value)
160
- }
161
-
162
- /**
163
- * Return the preference weight from an Accept-style media range.
164
- *
165
- * @param value - Media range value to inspect.
166
- * @returns The parsed `q` value, or `1` when none is provided.
167
- */
168
- export function mediaRangeQuality(value: string | AcceptMediaRange | MediaType): number {
169
- if (typeof value !== 'string') {
170
- return normalizeQuality('q' in value ? value.q : value.parameters?.q)
171
- }
172
- return normalizeQuality(parseMediaRange(value)?.parameters?.q)
173
- }
174
-
175
- /**
176
- * Format an Accept-style media range as a concrete `Content-Type`.
177
- *
178
- * @param value - Media range value to format.
179
- * @returns The media type without any `q` preference parameter, or `null` when invalid.
180
- */
181
- export function mediaRangeToContentType(value: string | MediaType): string | null {
182
- const parsed = typeof value === 'string' ? parseMediaRange(value) : normalizeMediaType(value)
183
- if (!parsed) return null
184
- return formatContentType(parsed)
185
- }
186
-
187
- /**
188
- * Parse an Accept header into quality-sorted media ranges.
189
- *
190
- * @param value - Accept header value to parse.
191
- * @returns Media ranges sorted by descending `q` values, preserving original order for ties.
192
- */
193
- export function parseAcceptHeader(value: string | null | undefined): AcceptMediaRange[] {
194
- const entries: Array<{ media: AcceptMediaRange; index: number }> = []
195
- for (const [index, entry] of (value ?? '').split(',').entries()) {
196
- const parsed = parseMediaRange(entry.trim())
197
- if (!parsed) continue
198
- const qParameter = parsed.parameters?.q
199
- const parsedQ = qParameter === undefined ? 1 : Number.parseFloat(qParameter)
200
- const q = Number.isFinite(parsedQ) && parsedQ >= 0 && parsedQ <= 1 ? parsedQ : 1
201
- const parameters = { ...(parsed.parameters ?? {}) }
202
- delete parameters.q
203
- const media: AcceptMediaRange = { ...parsed, q }
204
- delete media.parameters
205
- if (Object.keys(parameters).length > 0) media.parameters = parameters
206
- entries.push({
207
- media,
208
- index,
209
- })
210
- }
211
-
212
- entries.sort((a, b) => {
213
- if (b.media.q !== a.media.q) return b.media.q - a.media.q
214
- return a.index - b.index
215
- })
216
-
217
- return entries.map((entry) => {
218
- const { parameters, ...media } = entry.media
219
- return parameters && Object.keys(parameters).length > 0 ? { ...media, parameters } : media
220
- })
221
- }
222
-
223
- /**
224
- * Test whether a media type is a JSON document type.
225
- *
226
- * @param value - Content type or header value to inspect.
227
- * @returns Whether the media type is `application/json` or an `application/*+json` type.
228
- */
229
- export function isJsonContentType(
230
- value: string | ContentType | null | undefined,
231
- ): value is ContentType {
232
- const contentType = typeof value === 'string' ? parseContentType(value) : value
233
- if (!contentType) return false
234
- const [type, subtype] = contentType.type.split('/', 2)
235
- return type === 'application' && (subtype === 'json' || subtype.endsWith('+json'))
236
- }
237
-
238
- /**
239
- * Test whether an accepted concrete media type matches an incoming content type.
240
- *
241
- * @param accept - Media type accepted by a route.
242
- * @param contentType - Incoming request content type.
243
- * @returns Whether the accepted media type matches the content type.
244
- */
245
- export function mediaTypeMatches(accept: MediaType, contentType: ContentType): boolean {
246
- const normalizedAccept = normalizeMediaType(accept)
247
- const normalizedContent = normalizeMediaType(contentType)
248
- if (normalizedAccept.type !== normalizedContent.type) return false
249
- if (normalizedAccept.charset && normalizedContent.charset) {
250
- if (
251
- normalizeCharsetName(normalizedAccept.charset) !==
252
- normalizeCharsetName(normalizedContent.charset)
253
- )
254
- return false
255
- }
256
- if (normalizedAccept.boundary && normalizedContent.boundary) {
257
- if (normalizedAccept.boundary !== normalizedContent.boundary) return false
258
- }
259
- return true
260
- }
261
-
262
- /**
263
- * Test whether an HTTP media range accepts a concrete media type.
264
- *
265
- * @param range - Accept-style media range.
266
- * @param mediaType - Concrete media type to test.
267
- * @returns Whether the range accepts the media type.
268
- */
269
- export function mediaRangeAccepts(
270
- range: string | AcceptMediaRange | MediaType,
271
- mediaType: string | MediaType,
272
- ): boolean {
273
- const parsedRange =
274
- typeof range === 'string' ? parseMediaRange(range) : normalizeMediaType(range)
275
- const produced = typeof mediaType === 'string' ? parseContentType(mediaType) : mediaType
276
- if (!parsedRange || !produced) return false
277
- const normalizedRange = parsedRange.type.toLowerCase()
278
- const normalizedProduced = produced.type.toLowerCase()
279
- if (normalizedRange === '*/*') return true
280
- if (normalizedRange === normalizedProduced) return true
281
- const [rangeType, rangeSubtype] = normalizedRange.split('/', 2)
282
- const [producedType, producedSubtype] = normalizedProduced.split('/', 2)
283
- if (!rangeType || !rangeSubtype || !producedType || !producedSubtype) return false
284
- if (rangeSubtype === '*' && rangeType === producedType) return true
285
- if (rangeSubtype.startsWith('*+')) {
286
- return rangeType === producedType && producedSubtype.endsWith(rangeSubtype.slice(1))
287
- }
288
- return false
289
- }
@@ -1,18 +0,0 @@
1
- #!/usr/bin/env -S bun test
2
- import { describe, expect, it } from 'bun:test'
3
- import { joinPrefixPathname, stripPrefixPathname } from './pathname'
4
-
5
- describe('pathname helpers', function () {
6
- it('joins prefixes and pathnames', function () {
7
- expect(joinPrefixPathname('', '/items')).toBe('/items')
8
- expect(joinPrefixPathname('api', 'items')).toBe('/api/items')
9
- expect(joinPrefixPathname('/api/', '/items')).toBe('/api/items')
10
- expect(joinPrefixPathname('/api', '/')).toBe('/api')
11
- })
12
-
13
- it('strips prefixes from pathnames', function () {
14
- expect(stripPrefixPathname('/api', '/api/items')).toBe('/items')
15
- expect(stripPrefixPathname('/api', '/api')).toBe('/')
16
- expect(stripPrefixPathname('/api', '/other')).toBe(null)
17
- })
18
- })
@@ -1,19 +0,0 @@
1
- export function joinPrefixPathname(prefix: string, pathname: string): string {
2
- if (!prefix) return pathname
3
- if (!prefix.startsWith('/')) prefix = '/' + prefix
4
- if (prefix.endsWith('/')) prefix = prefix.slice(0, -1)
5
- if (pathname === '/') return prefix || '/'
6
- if (!pathname.startsWith('/')) pathname = '/' + pathname
7
- return prefix + pathname || '/'
8
- }
9
-
10
- export function stripPrefixPathname(prefix: string, pathname: string): string | null {
11
- if (!prefix) return pathname
12
- if (!prefix.startsWith('/')) prefix = '/' + prefix
13
- if (prefix.endsWith('/')) prefix = prefix.slice(0, -1)
14
- if (prefix === '/') return pathname
15
-
16
- if (pathname === prefix) return '/'
17
- if (pathname.startsWith(prefix + '/')) return pathname.slice(prefix.length)
18
- return null
19
- }
@@ -1,70 +0,0 @@
1
- function sanitizeNamePart(part: string): string {
2
- const replaced = part.replace(/:([a-zA-Z_$][a-zA-Z0-9_$]*)/g, '$$$1')
3
- const cleaned = replaced.replace(/[^a-zA-Z0-9_$]/g, '')
4
- if (cleaned.length === 0) return 'index'
5
- if (!/^[a-zA-Z_$]/.test(cleaned)) return '_' + cleaned
6
- return cleaned
7
- }
8
-
9
- export function sanitizeNameParts(parts: string[]): string[] {
10
- return parts.map(sanitizeNamePart).filter(Boolean)
11
- }
12
-
13
- export function splitNameString(name: string): string[] {
14
- const parts: string[] = []
15
- let current = ''
16
- let escaping = false
17
-
18
- for (const char of name) {
19
- if (escaping) {
20
- current += char
21
- escaping = false
22
- continue
23
- }
24
- if (char === '\\') {
25
- escaping = true
26
- continue
27
- }
28
- if (char === '.') {
29
- parts.push(current)
30
- current = ''
31
- continue
32
- }
33
- current += char
34
- }
35
- parts.push(current)
36
-
37
- return parts.filter((p) => p.length > 0)
38
- }
39
-
40
- function upperFirst(str: string): string {
41
- return str.slice(0, 1).toUpperCase() + str.slice(1)
42
- }
43
-
44
- function lowerFirst(str: string): string {
45
- return str.slice(0, 1).toLowerCase() + str.slice(1)
46
- }
47
-
48
- function segmentToDefaultName(segment: string): string {
49
- const paramMatch = segment.match(/^:([a-zA-Z0-9_]+)(?:\\(.+\\))?$/)
50
- if (paramMatch) {
51
- const key = paramMatch[1]
52
- if (key) return 'By' + upperFirst(key)
53
- }
54
-
55
- const cleaned = segment.split(/[^a-zA-Z0-9]+/).filter(Boolean)
56
- if (cleaned.length === 0) return 'Index'
57
- return cleaned.map(upperFirst).join('')
58
- }
59
-
60
- export function pattToName(_method: string, patt: URLPattern): string[] {
61
- const pathname = patt.pathname
62
- const parts = pathname.split('/').filter((p) => p.length > 0)
63
-
64
- if (parts.length === 0) {
65
- return []
66
- }
67
-
68
- const combined = parts.map(segmentToDefaultName).join('')
69
- return sanitizeNameParts([lowerFirst(combined)])
70
- }
@@ -1,36 +0,0 @@
1
- #!/usr/bin/env -S bun test
2
- import { describe, expect, it } from 'bun:test'
3
- import { HttpMethod } from '@mpen/http'
4
- import { normalizeRoute } from './route-normalize'
5
-
6
- describe('normalizeRoute', function () {
7
- it('builds a URLPattern and default name', function () {
8
- const route = normalizeRoute({
9
- method: HttpMethod.GET,
10
- path: '/users/:id',
11
- handler: function () {
12
- return new Response('ok')
13
- },
14
- })
15
-
16
- expect(route.path).toBeInstanceOf(URLPattern)
17
- expect(route.name).toEqual(['usersById'])
18
- expect(route.method).toBe(HttpMethod.GET)
19
- })
20
-
21
- it('normalizes accept entries into media types', function () {
22
- const route = normalizeRoute({
23
- method: HttpMethod.POST,
24
- path: '/upload',
25
- accept: ['Application/JSON; charset=UTF-8', { type: 'text/plain' }],
26
- handler: function () {
27
- return new Response('ok')
28
- },
29
- })
30
-
31
- expect(route.accept).toEqual([
32
- { type: 'application/json', charset: 'utf-8' },
33
- { type: 'text/plain' },
34
- ])
35
- })
36
- })
@@ -1,67 +0,0 @@
1
- import type {
2
- AddedContextFromMiddlewareInput,
3
- AnyContext,
4
- MediaType,
5
- MiddlewareInput,
6
- NormalizedRoute,
7
- Route,
8
- } from '../types'
9
- import type { HttpMethod } from '@mpen/http'
10
- import { normalizeMediaType, parseMediaType } from './media-type'
11
- import { pattToName, sanitizeNameParts, splitNameString } from './route-names'
12
-
13
- function normalizeRouteName(
14
- name: Route['name'],
15
- method: HttpMethod | HttpMethod[] | undefined,
16
- path: URLPattern,
17
- ): string[] {
18
- const methodName = Array.isArray(method) ? method[0] : method
19
- if (!name) {
20
- return pattToName(methodName ?? 'ANY', path)
21
- }
22
- if (typeof name === 'string') {
23
- return sanitizeNameParts(splitNameString(name))
24
- }
25
- return sanitizeNameParts(name)
26
- }
27
-
28
- export function normalizeRoute<
29
- Ctx extends object = AnyContext,
30
- RouteMiddleware extends MiddlewareInput<Ctx> = undefined,
31
- HandlerCtx extends object = Ctx & AddedContextFromMiddlewareInput<RouteMiddleware>,
32
- >(route: Route<Ctx, RouteMiddleware, HandlerCtx>): NormalizedRoute<HandlerCtx> {
33
- if (!route.path) {
34
- throw new Error('Route is missing a path')
35
- }
36
- const path =
37
- typeof route.path === 'string' ? new URLPattern({ pathname: route.path }) : route.path
38
- const method = route.method
39
- const accept = route.accept
40
- let normalizedAccept: MediaType[] | undefined
41
- if (accept) {
42
- const acceptList = Array.isArray(accept) ? accept : [accept]
43
- normalizedAccept = acceptList.map((entry) => {
44
- if (typeof entry === 'string') {
45
- const parsed = parseMediaType(entry)
46
- if (!parsed) {
47
- throw new Error(`Invalid accept media type: ${entry}`)
48
- }
49
- return parsed
50
- }
51
- return normalizeMediaType(entry)
52
- })
53
- if (normalizedAccept.length === 0) {
54
- normalizedAccept = undefined
55
- }
56
- }
57
- return {
58
- name: normalizeRouteName(route.name, route.method, path),
59
- path,
60
- handler: route.handler,
61
- ...(route.match === undefined ? {} : { match: route.match }),
62
- ...(route.meta === undefined ? {} : { meta: route.meta }),
63
- ...(route.schema === undefined ? {} : { schema: route.schema }),
64
- ...(method === undefined ? {} : { method }),
65
- ...(normalizedAccept === undefined ? {} : { accept: normalizedAccept }),
66
- }
67
- }
@@ -1,56 +0,0 @@
1
- import type { JsonSchema, RouteSchema } from '../types'
2
-
3
- function unionJsonSchemas(left: JsonSchema, right: JsonSchema): JsonSchema {
4
- return { anyOf: [left, right] }
5
- }
6
-
7
- /**
8
- * Merge route schema contributions from routes and middleware.
9
- *
10
- * Response declarations at the same status are alternatives. Request declarations describe
11
- * parsed context fields and therefore may only be declared once within an executed chain.
12
- *
13
- * @param schemas - Schema contributions in middleware execution order followed by the route.
14
- * @returns The combined schema contribution, or `undefined` if none were supplied.
15
- */
16
- export function mergeRouteSchemas(
17
- ...schemas: Array<RouteSchema | undefined>
18
- ): RouteSchema | undefined {
19
- const request: NonNullable<RouteSchema['request']> = {}
20
- const responseBody: NonNullable<NonNullable<RouteSchema['response']>['body']> = {}
21
- let hasRequest = false
22
- let hasResponse = false
23
-
24
- for (const schema of schemas) {
25
- if (!schema) continue
26
- if (schema.request) {
27
- for (const component of ['query', 'path', 'body'] as const) {
28
- const declaration = schema.request[component]
29
- if (declaration === undefined) continue
30
- if (request[component] !== undefined) {
31
- throw new Error(
32
- `Multiple middleware or route schemas declare request.${component}.`,
33
- )
34
- }
35
- request[component] = declaration as never
36
- hasRequest = true
37
- }
38
- }
39
- for (const [status, declaration] of Object.entries(schema.response?.body ?? {})) {
40
- if (declaration === undefined) continue
41
- const normalizedStatus = status === 'default' ? status : Number(status)
42
- const existing = responseBody[normalizedStatus]
43
- responseBody[normalizedStatus] =
44
- existing === undefined
45
- ? declaration
46
- : unionJsonSchemas(existing, declaration as JsonSchema)
47
- hasResponse = true
48
- }
49
- }
50
-
51
- if (!hasRequest && !hasResponse) return undefined
52
- return {
53
- ...(hasRequest ? { request } : {}),
54
- ...(hasResponse ? { response: { body: responseBody } } : {}),
55
- }
56
- }