@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,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
|
-
}
|