@znight/http 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
|
@@ -0,0 +1,1513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `analyzeClass` of the `@znight/http` compiler extension: controllers, routes, parameters,
|
|
3
|
+
* guards, interceptors, statuses, headers and route metadata. Everything is checked here except
|
|
4
|
+
* what needs the whole application (paths and duplicate routes, see `validate.ts`).
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
AnalyzeContext,
|
|
8
|
+
ArgInfo,
|
|
9
|
+
ClassInfo,
|
|
10
|
+
ClassRef,
|
|
11
|
+
DecoratorInfo,
|
|
12
|
+
Location,
|
|
13
|
+
MethodInfo,
|
|
14
|
+
ParameterInfo,
|
|
15
|
+
StaticValue,
|
|
16
|
+
TypeInfo,
|
|
17
|
+
ValueRef,
|
|
18
|
+
} from '@znight/compiler'
|
|
19
|
+
import type { HttpMethod } from '../metadata.ts'
|
|
20
|
+
import type {
|
|
21
|
+
HttpControllerData,
|
|
22
|
+
HttpParamData,
|
|
23
|
+
HttpRouteData,
|
|
24
|
+
ParamCoercion,
|
|
25
|
+
SseRouteData,
|
|
26
|
+
UploadData,
|
|
27
|
+
} from './model.ts'
|
|
28
|
+
|
|
29
|
+
import { isMediaTypePattern } from '../mime.ts'
|
|
30
|
+
import { joinPaths, normalizePath } from '../path.ts'
|
|
31
|
+
import { parseByteSize } from '../size.ts'
|
|
32
|
+
import { acceptsUndefined, isRaw, parameterType, planCoercion } from './coercion.ts'
|
|
33
|
+
import { Codes, HTTP_PACKAGE, Ids, PARAM_SOURCES, ROUTE_METHODS } from './model.ts'
|
|
34
|
+
import { isRouteMetaDecorator, isValueRef, routeMeta } from './route-decorators.ts'
|
|
35
|
+
|
|
36
|
+
/** Name of the extension (owner of the decorators in `DecoratorInfo.owner`). */
|
|
37
|
+
export const EXTENSION_NAME = 'http'
|
|
38
|
+
|
|
39
|
+
type Context = AnalyzeContext<HttpControllerData>
|
|
40
|
+
type Scope = 'singleton' | 'request' | 'transient'
|
|
41
|
+
|
|
42
|
+
const SCOPES: readonly Scope[] = ['singleton', 'request', 'transient']
|
|
43
|
+
/** RFC 9110 token (header names). */
|
|
44
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
45
|
+
/** Types an upload parameter may have (`File`, `Blob`, arrays of them for `@UploadedFiles`); `true`: cast needed. */
|
|
46
|
+
const FILE_TYPES: ReadonlyMap<string, boolean> = new Map([
|
|
47
|
+
['globalThis#File', false],
|
|
48
|
+
['globalThis#Blob', false],
|
|
49
|
+
['node:buffer#File', true],
|
|
50
|
+
['node:buffer#Blob', true],
|
|
51
|
+
])
|
|
52
|
+
/** Methods whose requests usually have no body (upload parameters there are a warning). */
|
|
53
|
+
const BODYLESS_METHODS: ReadonlySet<HttpMethod> = new Set<HttpMethod>(['GET', 'HEAD', 'DELETE', 'OPTIONS'])
|
|
54
|
+
const UPLOAD_OPTIONS = ['required', 'maxSize', 'maxFiles', 'mimeTypes']
|
|
55
|
+
/** Smallest `@Sse` heartbeat and `maxDuration` (ms). */
|
|
56
|
+
const MIN_SSE_INTERVAL_MS = 1_000
|
|
57
|
+
/** Longest `@Sse` heartbeat and `maxDuration` (ms): the longest `setTimeout` delay. */
|
|
58
|
+
const MAX_SSE_TIMER_MS = 2_147_483_647
|
|
59
|
+
/** Primitive return types an `@Sse` handler cannot return (`any`, `unknown`, `object`, `never` are accepted). */
|
|
60
|
+
const NOT_SSE_SOURCES: ReadonlySet<string> = new Set([
|
|
61
|
+
'string',
|
|
62
|
+
'number',
|
|
63
|
+
'boolean',
|
|
64
|
+
'bigint',
|
|
65
|
+
'symbol',
|
|
66
|
+
'undefined',
|
|
67
|
+
'null',
|
|
68
|
+
'void',
|
|
69
|
+
])
|
|
70
|
+
/** Values that are fine without schema (`@Body() body: string`, `@Body() bytes: Uint8Array`...). */
|
|
71
|
+
const UNVALIDATED_REFERENCES = new Set([
|
|
72
|
+
'globalThis#Uint8Array',
|
|
73
|
+
'globalThis#ArrayBuffer',
|
|
74
|
+
'globalThis#Buffer',
|
|
75
|
+
'globalThis#Blob',
|
|
76
|
+
'globalThis#FormData',
|
|
77
|
+
'node:buffer#Buffer',
|
|
78
|
+
'@znight/http#QueryRecord',
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
/** Analyses a class carrying at least one `@znight/http` decorator. */
|
|
82
|
+
export function analyzeHttpClass(cls: ClassInfo, ctx: Context): void {
|
|
83
|
+
const controllers = cls.decorators.filter(decorator => decorator.identity === Ids.Controller)
|
|
84
|
+
const [controller] = controllers
|
|
85
|
+
if (controller === undefined) {
|
|
86
|
+
reportOutsideController(cls, ctx)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
for (const duplicate of controllers.slice(1)) {
|
|
90
|
+
error(ctx, duplicate.location, `@Controller is applied several times to ${cls.displayName}`, [
|
|
91
|
+
'Keep a single @Controller()',
|
|
92
|
+
])
|
|
93
|
+
}
|
|
94
|
+
const options = controllerOptions(ctx, controller)
|
|
95
|
+
const token = ctx.registerProvider(cls, { scope: options.scope })
|
|
96
|
+
const basePath = normalizePath(options.path)
|
|
97
|
+
const guards = classRefs(ctx, cls, cls.decorators, Ids.UseGuard, 'guard')
|
|
98
|
+
const interceptors = classRefs(ctx, cls, cls.decorators, Ids.UseInterceptor, 'interceptor')
|
|
99
|
+
const meta = routeMeta(ctx, cls, cls.decorators, cls.displayName)
|
|
100
|
+
const bodyLimit = bodyLimitOf(ctx, cls.decorators)
|
|
101
|
+
const routes: HttpRouteData[] = []
|
|
102
|
+
for (const method of cls.methods) {
|
|
103
|
+
routes.push(...analyzeMethod(ctx, cls, method, basePath, bodyLimit))
|
|
104
|
+
}
|
|
105
|
+
ctx.store({
|
|
106
|
+
token,
|
|
107
|
+
identity: cls.identity,
|
|
108
|
+
displayName: cls.displayName,
|
|
109
|
+
rawBasePath: options.path,
|
|
110
|
+
basePath,
|
|
111
|
+
guards,
|
|
112
|
+
interceptors,
|
|
113
|
+
meta,
|
|
114
|
+
routes,
|
|
115
|
+
location: controller.location,
|
|
116
|
+
pathLocation: options.pathLocation,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// -------------------------------------------------------------------------------------------------
|
|
121
|
+
// Controller
|
|
122
|
+
// -------------------------------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
interface ControllerOptions {
|
|
125
|
+
readonly path: string
|
|
126
|
+
readonly pathLocation: Location
|
|
127
|
+
readonly scope: Scope
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function controllerOptions(ctx: Context, decorator: DecoratorInfo): ControllerOptions {
|
|
131
|
+
const [arg] = decorator.args
|
|
132
|
+
const defaults: ControllerOptions = { path: '', pathLocation: decorator.location, scope: 'singleton' }
|
|
133
|
+
if (arg === undefined || !arg.evaluated) {
|
|
134
|
+
// missing, or not evaluable (already reported)
|
|
135
|
+
return defaults
|
|
136
|
+
}
|
|
137
|
+
const value = arg.value
|
|
138
|
+
if (typeof value === 'string') {
|
|
139
|
+
return { ...defaults, path: value, pathLocation: arg.location }
|
|
140
|
+
}
|
|
141
|
+
if (!isPlainObject(value)) {
|
|
142
|
+
error(ctx, arg.location, `@Controller expects a path or { path?, scope? }, got ${describe(value)}`, [
|
|
143
|
+
"@Controller('/users') or @Controller({ path: '/users', scope: 'request' })",
|
|
144
|
+
])
|
|
145
|
+
return defaults
|
|
146
|
+
}
|
|
147
|
+
let { path, scope } = defaults
|
|
148
|
+
for (const [key, option] of Object.entries(value)) {
|
|
149
|
+
if (key === 'path') {
|
|
150
|
+
if (typeof option === 'string') {
|
|
151
|
+
path = option
|
|
152
|
+
} else if (option !== undefined) {
|
|
153
|
+
error(ctx, arg.location, `@Controller: "path" must be a string, got ${describe(option)}`)
|
|
154
|
+
}
|
|
155
|
+
} else if (key === 'scope') {
|
|
156
|
+
if (SCOPES.includes(option as Scope)) {
|
|
157
|
+
scope = option as Scope
|
|
158
|
+
} else if (option !== undefined) {
|
|
159
|
+
error(ctx, arg.location, `@Controller: invalid scope ${describe(option)}`, [
|
|
160
|
+
`Use one of ${SCOPES.map(candidate => `'${candidate}'`).join(', ')}`,
|
|
161
|
+
])
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
error(ctx, arg.location, `@Controller: unknown option "${key}"`, ['Options: { path?, scope? }'])
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { path, pathLocation: arg.location, scope }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* HTTP decorators on a class that is not a controller have no effect. On a class another extension
|
|
172
|
+
* owns (a `@Consumer` of `@znight/messaging`...), adding `@Controller()` is no fix: a class is the
|
|
173
|
+
* provider of a single extension.
|
|
174
|
+
*/
|
|
175
|
+
function reportOutsideController(cls: ClassInfo, ctx: Context): void {
|
|
176
|
+
const owner = foreignOwner(cls)
|
|
177
|
+
const hint =
|
|
178
|
+
owner === undefined
|
|
179
|
+
? [`Add @Controller() to ${cls.displayName} (routes are not inherited from base classes)`]
|
|
180
|
+
: [
|
|
181
|
+
`${cls.displayName} is a @${owner.name} class of '${owner.module}': HTTP decorators only apply to @Controller() classes`,
|
|
182
|
+
]
|
|
183
|
+
const decorators: DecoratorInfo[] = [...cls.decorators]
|
|
184
|
+
for (const method of cls.methods) {
|
|
185
|
+
decorators.push(...method.decorators)
|
|
186
|
+
const handler =
|
|
187
|
+
owner === undefined ? undefined : method.decorators.find(decorator => decorator.owner === owner.decorator.owner)
|
|
188
|
+
if (owner !== undefined && handler !== undefined) {
|
|
189
|
+
reportHandlerParams(ctx, cls, method, owner, handler)
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
for (const param of method.params) {
|
|
193
|
+
decorators.push(...unique(param.decorators))
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const decorator of decorators) {
|
|
197
|
+
if (decorator.owner === EXTENSION_NAME && validTarget(decorator)) {
|
|
198
|
+
error(ctx, decorator.location, `@${decorator.name} has no effect: ${cls.displayName} is not a controller`, hint)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* HTTP decorators on the parameters of a handler of the extension owning the class (an `@OnMessage`
|
|
205
|
+
* handler...). That extension reads its handlers' parameters and reports those it cannot read
|
|
206
|
+
* (`Headers` imported from `@znight/http` instead of `@znight/messaging`...): an HTTP decorator is
|
|
207
|
+
* only reported here when the parameter is read through a decorator of that extension, or is
|
|
208
|
+
* optional (left undefined without a word).
|
|
209
|
+
*/
|
|
210
|
+
function reportHandlerParams(
|
|
211
|
+
ctx: Context,
|
|
212
|
+
cls: ClassInfo,
|
|
213
|
+
method: MethodInfo,
|
|
214
|
+
owner: QualifiedDecorator,
|
|
215
|
+
handler: DecoratorInfo,
|
|
216
|
+
): void {
|
|
217
|
+
for (const param of method.params) {
|
|
218
|
+
const read = param.decorators.some(decorator => decorator.owner === owner.decorator.owner)
|
|
219
|
+
if (!read && !param.optional && !param.hasDefault && !param.rest) {
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
for (const decorator of unique(param.decorators)) {
|
|
223
|
+
if (decorator.owner === EXTENSION_NAME && validTarget(decorator)) {
|
|
224
|
+
error(
|
|
225
|
+
ctx,
|
|
226
|
+
decorator.location,
|
|
227
|
+
`@${decorator.name} has no effect on parameter '${param.name}' of ${cls.displayName}.${method.name}: it is an @${handler.name} handler of '${owner.module}'`,
|
|
228
|
+
[`Use the parameter decorators of '${owner.module}', or remove it`],
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A decorator with the module and the export name of its identity (`'@znight/messaging#Consumer'`). */
|
|
236
|
+
interface QualifiedDecorator {
|
|
237
|
+
readonly decorator: DecoratorInfo
|
|
238
|
+
readonly module: string
|
|
239
|
+
readonly name: string
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function qualify(decorator: DecoratorInfo): QualifiedDecorator | undefined {
|
|
243
|
+
const identity = decorator.identity
|
|
244
|
+
const separator = identity?.lastIndexOf('#') ?? -1
|
|
245
|
+
if (identity === undefined || separator <= 0) {
|
|
246
|
+
return undefined
|
|
247
|
+
}
|
|
248
|
+
return { decorator, module: identity.slice(0, separator), name: identity.slice(separator + 1) }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The class decorator through which another extension owns the class (`@Consumer`...). */
|
|
252
|
+
function foreignOwner(cls: ClassInfo): QualifiedDecorator | undefined {
|
|
253
|
+
for (const decorator of cls.decorators) {
|
|
254
|
+
const owner = decorator.owner
|
|
255
|
+
if (owner === undefined || owner === EXTENSION_NAME || owner === 'core' || owner === 'manifest') {
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
const qualified = qualify(decorator)
|
|
259
|
+
if (qualified !== undefined) {
|
|
260
|
+
return qualified
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return undefined
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A parameter decorator named like an HTTP one but imported from another package (`Headers` and
|
|
268
|
+
* `Ctx` of `@znight/messaging`, typically auto-imported).
|
|
269
|
+
*/
|
|
270
|
+
function foreignParameterDecorator(param: ParameterInfo): QualifiedDecorator | undefined {
|
|
271
|
+
for (const decorator of unique(param.decorators)) {
|
|
272
|
+
const qualified = qualify(decorator)
|
|
273
|
+
if (
|
|
274
|
+
qualified !== undefined &&
|
|
275
|
+
qualified.module !== HTTP_PACKAGE &&
|
|
276
|
+
PARAM_SOURCES.has(`${HTTP_PACKAGE}#${qualified.name}`)
|
|
277
|
+
) {
|
|
278
|
+
return qualified
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return undefined
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// -------------------------------------------------------------------------------------------------
|
|
285
|
+
// Routes
|
|
286
|
+
// -------------------------------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
function analyzeMethod(
|
|
289
|
+
ctx: Context,
|
|
290
|
+
cls: ClassInfo,
|
|
291
|
+
method: MethodInfo,
|
|
292
|
+
basePath: string,
|
|
293
|
+
controllerBodyLimit: number | undefined,
|
|
294
|
+
): HttpRouteData[] {
|
|
295
|
+
const where = `${cls.displayName}.${method.name}`
|
|
296
|
+
let routeDecorators = method.decorators.filter(decorator => ROUTE_METHODS.has(decorator.identity ?? ''))
|
|
297
|
+
if (routeDecorators.length === 0) {
|
|
298
|
+
reportNotARoute(ctx, method, where)
|
|
299
|
+
return []
|
|
300
|
+
}
|
|
301
|
+
if (!checkHandler(ctx, method, where, routeDecorators[0] as DecoratorInfo)) {
|
|
302
|
+
return []
|
|
303
|
+
}
|
|
304
|
+
const sseDecorator = routeDecorators.find(decorator => decorator.identity === Ids.Sse)
|
|
305
|
+
if (sseDecorator !== undefined) {
|
|
306
|
+
// one handler per event stream: the other route decorators are reported and ignored
|
|
307
|
+
for (const other of routeDecorators) {
|
|
308
|
+
if (other === sseDecorator) {
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
error(
|
|
312
|
+
ctx,
|
|
313
|
+
other.location,
|
|
314
|
+
other.identity === Ids.Sse
|
|
315
|
+
? `@Sse is applied several times to ${where}`
|
|
316
|
+
: `@${other.name} cannot be combined with @Sse on ${where}`,
|
|
317
|
+
[
|
|
318
|
+
'One handler per event stream: declare other routes on other methods (HttpResponse.sse() serves an event stream from any route)',
|
|
319
|
+
],
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
routeDecorators = [sseDecorator]
|
|
323
|
+
}
|
|
324
|
+
const decorators = method.decorators
|
|
325
|
+
const bodyValidation = validateSchema(ctx, decorators)
|
|
326
|
+
const { params, complete } = analyzeParams(ctx, cls, method, where, bodyValidation)
|
|
327
|
+
let status = httpCode(ctx, decorators)
|
|
328
|
+
let headers = responseHeaders(ctx, decorators)
|
|
329
|
+
const guards = classRefs(ctx, cls, decorators, Ids.UseGuard, 'guard')
|
|
330
|
+
const interceptors = classRefs(ctx, cls, decorators, Ids.UseInterceptor, 'interceptor')
|
|
331
|
+
const meta = routeMeta(ctx, cls, decorators, where)
|
|
332
|
+
const bodySchema =
|
|
333
|
+
bodyValidation !== undefined && !params.some(param => param.source === 'body') ? bodyValidation.schema : undefined
|
|
334
|
+
let rawResponse = params.some(param => param.source === 'response')
|
|
335
|
+
const bodyLimit = bodyLimitOf(ctx, decorators) ?? controllerBodyLimit
|
|
336
|
+
const uploads = checkUploads(ctx, params, where)
|
|
337
|
+
if (sseDecorator !== undefined) {
|
|
338
|
+
checkSseHandler(ctx, method, params, where, bodyValidation)
|
|
339
|
+
status = undefined
|
|
340
|
+
headers = headers.filter(([name]) => name !== 'content-type')
|
|
341
|
+
rawResponse = false
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const routes: HttpRouteData[] = []
|
|
345
|
+
for (const decorator of routeDecorators) {
|
|
346
|
+
const httpMethod = ROUTE_METHODS.get(decorator.identity ?? '') as HttpMethod
|
|
347
|
+
const target = decorator === sseDecorator ? sseArgs(ctx, decorator) : routePath(ctx, decorator)
|
|
348
|
+
if (target === undefined) {
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
const { rawPath, arg } = target
|
|
352
|
+
const path = normalizePath(rawPath)
|
|
353
|
+
if (uploads && BODYLESS_METHODS.has(httpMethod)) {
|
|
354
|
+
ctx.report({
|
|
355
|
+
code: Codes.InvalidDecorator,
|
|
356
|
+
severity: 'warning',
|
|
357
|
+
message: `${where} reads uploaded files but is a ${httpMethod} route: ${httpMethod} requests usually have no body`,
|
|
358
|
+
location: decorator.location,
|
|
359
|
+
hints: ['Upload files with @Post(), @Put() or @Patch()'],
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
routes.push({
|
|
363
|
+
method: httpMethod,
|
|
364
|
+
rawPath,
|
|
365
|
+
path,
|
|
366
|
+
fullPath: joinPaths(basePath, path),
|
|
367
|
+
handler: method.name,
|
|
368
|
+
status,
|
|
369
|
+
headers,
|
|
370
|
+
meta,
|
|
371
|
+
guards,
|
|
372
|
+
interceptors,
|
|
373
|
+
params,
|
|
374
|
+
complete,
|
|
375
|
+
bodySchema,
|
|
376
|
+
bodyLimit,
|
|
377
|
+
rawResponse,
|
|
378
|
+
...(target.sse === undefined ? {} : { sse: target.sse }),
|
|
379
|
+
location: decorator.location,
|
|
380
|
+
pathLocation: arg?.location ?? decorator.location,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
return routes
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The path of a route decorator, its argument and, for `@Sse`, its options. */
|
|
387
|
+
interface RouteTarget {
|
|
388
|
+
readonly rawPath: string
|
|
389
|
+
readonly arg: ArgInfo | undefined
|
|
390
|
+
readonly sse?: SseRouteData
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The path argument of a route decorator (`undefined` after an error). */
|
|
394
|
+
function routePath(ctx: Context, decorator: DecoratorInfo): RouteTarget | undefined {
|
|
395
|
+
const [arg] = decorator.args
|
|
396
|
+
if (arg === undefined) {
|
|
397
|
+
return { rawPath: '', arg }
|
|
398
|
+
}
|
|
399
|
+
if (!arg.evaluated) {
|
|
400
|
+
return undefined // not evaluable: already reported
|
|
401
|
+
}
|
|
402
|
+
if (typeof arg.value !== 'string') {
|
|
403
|
+
error(ctx, arg.location, `@${decorator.name} expects a path (string), got ${describe(arg.value)}`, [
|
|
404
|
+
`@${decorator.name}('/:id')`,
|
|
405
|
+
])
|
|
406
|
+
return undefined
|
|
407
|
+
}
|
|
408
|
+
return { rawPath: arg.value, arg }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** `@Sse(path?, options?)` / `@Sse(options)`: the path and the options (`undefined` after an error). */
|
|
412
|
+
function sseArgs(ctx: Context, decorator: DecoratorInfo): RouteTarget | undefined {
|
|
413
|
+
const [first, second] = decorator.args
|
|
414
|
+
let pathArg: ArgInfo | undefined
|
|
415
|
+
let options: ArgInfo | undefined
|
|
416
|
+
if (first !== undefined) {
|
|
417
|
+
if (!first.evaluated) {
|
|
418
|
+
return undefined // already reported
|
|
419
|
+
}
|
|
420
|
+
if (typeof first.value === 'string') {
|
|
421
|
+
pathArg = first
|
|
422
|
+
options = second
|
|
423
|
+
} else if (first.value === undefined) {
|
|
424
|
+
options = second
|
|
425
|
+
} else if (isPlainObject(first.value)) {
|
|
426
|
+
if (second !== undefined) {
|
|
427
|
+
error(ctx, second.location, '@Sse(options) takes a single argument', [
|
|
428
|
+
"Put the path first: @Sse('/events', { retry: 5000 })",
|
|
429
|
+
])
|
|
430
|
+
return undefined
|
|
431
|
+
}
|
|
432
|
+
options = first
|
|
433
|
+
} else {
|
|
434
|
+
error(ctx, first.location, `@Sse expects a path (string) or options, got ${describe(first.value)}`, [
|
|
435
|
+
"@Sse('/events'), @Sse('/events', { heartbeat: 15_000 }) or @Sse({ retry: 5000 })",
|
|
436
|
+
])
|
|
437
|
+
return undefined
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const sse = sseOptions(ctx, options)
|
|
441
|
+
if (sse === undefined) {
|
|
442
|
+
return undefined
|
|
443
|
+
}
|
|
444
|
+
return { rawPath: typeof pathArg?.value === 'string' ? pathArg.value : '', arg: pathArg, sse }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The static `{ heartbeat?, retry?, maxDuration? }` of `@Sse` (`undefined` after an error). */
|
|
448
|
+
function sseOptions(ctx: Context, arg: ArgInfo | undefined): SseRouteData | undefined {
|
|
449
|
+
if (arg === undefined) {
|
|
450
|
+
return {}
|
|
451
|
+
}
|
|
452
|
+
if (!arg.evaluated) {
|
|
453
|
+
return undefined
|
|
454
|
+
}
|
|
455
|
+
const shape = '{ heartbeat?: number | false, retry?: number, maxDuration?: number }'
|
|
456
|
+
if (arg.value === undefined) {
|
|
457
|
+
return {}
|
|
458
|
+
}
|
|
459
|
+
if (!isPlainObject(arg.value)) {
|
|
460
|
+
error(ctx, arg.location, `@Sse options must be an object ${shape}, got ${describe(arg.value)}`)
|
|
461
|
+
return undefined
|
|
462
|
+
}
|
|
463
|
+
const result: { heartbeat?: number; retry?: number; maxDuration?: number } = {}
|
|
464
|
+
let ok = true
|
|
465
|
+
const invalid = (message: string, hints: readonly string[] = []): void => {
|
|
466
|
+
ok = false
|
|
467
|
+
error(ctx, arg.location, `@Sse: ${message}`, hints)
|
|
468
|
+
}
|
|
469
|
+
const integer = (value: StaticValue, min: number, max = Number.MAX_SAFE_INTEGER): value is number =>
|
|
470
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value >= min && value <= max
|
|
471
|
+
for (const [key, value] of Object.entries(arg.value)) {
|
|
472
|
+
if (value === undefined) {
|
|
473
|
+
continue
|
|
474
|
+
}
|
|
475
|
+
switch (key) {
|
|
476
|
+
case 'heartbeat':
|
|
477
|
+
if (value === false) {
|
|
478
|
+
result.heartbeat = 0
|
|
479
|
+
} else if (integer(value, MIN_SSE_INTERVAL_MS, MAX_SSE_TIMER_MS)) {
|
|
480
|
+
result.heartbeat = value
|
|
481
|
+
} else {
|
|
482
|
+
invalid(
|
|
483
|
+
`"heartbeat" must be an integer number of ms from ${MIN_SSE_INTERVAL_MS} to ${MAX_SSE_TIMER_MS}, or false, got ${describe(value)}`,
|
|
484
|
+
['Keep it below the idle timeouts of the proxies and of Bun.serve() (10 s): the default is 8 000'],
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
break
|
|
488
|
+
case 'retry':
|
|
489
|
+
if (integer(value, 0)) {
|
|
490
|
+
result.retry = value
|
|
491
|
+
} else {
|
|
492
|
+
invalid(`"retry" must be an integer number of ms ≥ 0, got ${describe(value)}`)
|
|
493
|
+
}
|
|
494
|
+
break
|
|
495
|
+
case 'maxDuration':
|
|
496
|
+
if (integer(value, MIN_SSE_INTERVAL_MS, MAX_SSE_TIMER_MS)) {
|
|
497
|
+
result.maxDuration = value
|
|
498
|
+
} else {
|
|
499
|
+
invalid(
|
|
500
|
+
`"maxDuration" must be an integer number of ms from ${MIN_SSE_INTERVAL_MS} to ${MAX_SSE_TIMER_MS} (about 24.8 days), got ${describe(value)}`,
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
break
|
|
504
|
+
default:
|
|
505
|
+
invalid(`unknown option "${key}"`, [`Options: ${shape}`])
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return ok ? result : undefined
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* `@Sse` handlers: the plugin writes the stream with status 200 and its own content type, so
|
|
513
|
+
* `@HttpCode`, `@Header('content-type')` and `@Res()` are errors; a body is a warning (EventSource
|
|
514
|
+
* sends `GET` without body); a primitive return type is an error.
|
|
515
|
+
*/
|
|
516
|
+
function checkSseHandler(
|
|
517
|
+
ctx: Context,
|
|
518
|
+
method: MethodInfo,
|
|
519
|
+
params: readonly HttpParamData[],
|
|
520
|
+
where: string,
|
|
521
|
+
bodyValidation: BodyValidation | undefined,
|
|
522
|
+
): void {
|
|
523
|
+
for (const decorator of method.decorators) {
|
|
524
|
+
if (decorator.identity === Ids.HttpCode) {
|
|
525
|
+
error(ctx, decorator.location, `@HttpCode has no effect on @Sse route ${where}: an event stream answers 200`, [
|
|
526
|
+
'Throw an HttpException or return an HttpResponse from the handler for other statuses',
|
|
527
|
+
])
|
|
528
|
+
} else if (decorator.identity === Ids.Header) {
|
|
529
|
+
const [name] = decorator.args
|
|
530
|
+
if (name?.evaluated === true && typeof name.value === 'string' && name.value.toLowerCase() === 'content-type') {
|
|
531
|
+
error(ctx, decorator.location, `@Header('content-type') has no effect on @Sse route ${where}`, [
|
|
532
|
+
'An event stream is always sent as text/event-stream; charset=utf-8',
|
|
533
|
+
])
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
for (const param of params) {
|
|
538
|
+
if (param.source === 'response') {
|
|
539
|
+
error(ctx, param.location, `@Res() cannot be used on @Sse route ${where}: the plugin writes the event stream`, [
|
|
540
|
+
'Return an AsyncIterable of SseEvent, or use a plain route to write the response yourself',
|
|
541
|
+
])
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const body = params.find(param => param.source === 'body' || param.upload !== undefined)
|
|
545
|
+
const bodyLocation = body?.location ?? bodyValidation?.decorator.location
|
|
546
|
+
if (bodyLocation !== undefined) {
|
|
547
|
+
ctx.report({
|
|
548
|
+
code: Codes.InvalidDecorator,
|
|
549
|
+
severity: 'warning',
|
|
550
|
+
message: `@Sse route ${where} reads a request body: EventSource sends GET requests without body`,
|
|
551
|
+
location: bodyLocation,
|
|
552
|
+
hints: ['For fetch() clients streaming a POST, use @Post() and return HttpResponse.sse(source)'],
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
const returned = method.returnType === undefined ? undefined : ctx.types.awaited(method.returnType)
|
|
556
|
+
if (returned !== undefined && !method.generator) {
|
|
557
|
+
const members = returned.kind === 'union' ? returned.types : [returned]
|
|
558
|
+
const invalid = members.some(
|
|
559
|
+
member => member.kind === 'literal' || (member.kind === 'primitive' && NOT_SSE_SOURCES.has(member.name)),
|
|
560
|
+
)
|
|
561
|
+
if (invalid) {
|
|
562
|
+
error(
|
|
563
|
+
ctx,
|
|
564
|
+
method.location,
|
|
565
|
+
`@Sse handler ${where} returns ${ctx.types.format(returned)}: return an AsyncIterable, an Iterable or a ReadableStream of SseEvent (or an HttpResponse)`,
|
|
566
|
+
['async events(): Promise<AsyncIterable<SseEvent<T>>> { ...; return channel }'],
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** `@BodyLimit(size)` of a class or method, in bytes. */
|
|
573
|
+
function bodyLimitOf(ctx: Context, decorators: readonly DecoratorInfo[]): number | undefined {
|
|
574
|
+
const [decorator, ...duplicates] = decorators.filter(candidate => candidate.identity === Ids.BodyLimit)
|
|
575
|
+
for (const duplicate of duplicates) {
|
|
576
|
+
error(ctx, duplicate.location, '@BodyLimit is applied several times', ['Keep a single @BodyLimit(size)'])
|
|
577
|
+
}
|
|
578
|
+
if (decorator === undefined) {
|
|
579
|
+
return undefined
|
|
580
|
+
}
|
|
581
|
+
const [arg] = decorator.args
|
|
582
|
+
if (arg === undefined) {
|
|
583
|
+
error(ctx, decorator.location, '@BodyLimit requires a size', ["@BodyLimit('10mb') or @BodyLimit(10_485_760)"])
|
|
584
|
+
return undefined
|
|
585
|
+
}
|
|
586
|
+
if (!arg.evaluated) {
|
|
587
|
+
return undefined
|
|
588
|
+
}
|
|
589
|
+
const size = parseByteSize(arg.value)
|
|
590
|
+
if (size === undefined) {
|
|
591
|
+
error(ctx, arg.location, `@BodyLimit expects a size in bytes, got ${describe(arg.value)}`, [
|
|
592
|
+
"A non-negative integer (10_485_760), a size with a unit ('10mb', '512kb'; kb = 1024 bytes) or Infinity",
|
|
593
|
+
])
|
|
594
|
+
}
|
|
595
|
+
return size
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Checks the upload parameters of a handler together (one parameter per field; a parameter without
|
|
600
|
+
* field takes every file and stands alone). Returns whether the handler reads uploaded files.
|
|
601
|
+
*/
|
|
602
|
+
function checkUploads(ctx: Context, params: readonly HttpParamData[], where: string): boolean {
|
|
603
|
+
const uploads = params.filter(param => param.upload !== undefined)
|
|
604
|
+
uploads.forEach((param, index) => {
|
|
605
|
+
const field = param.upload?.field
|
|
606
|
+
const previous = uploads
|
|
607
|
+
.slice(0, index)
|
|
608
|
+
.find(other => field === undefined || other.upload?.field === undefined || other.upload.field === field)
|
|
609
|
+
if (previous === undefined) {
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
ctx.report({
|
|
613
|
+
code: Codes.InvalidDecorator,
|
|
614
|
+
severity: 'error',
|
|
615
|
+
message:
|
|
616
|
+
field !== undefined && previous.upload?.field === field
|
|
617
|
+
? `The field "${field}" of ${where} is read by several upload parameters`
|
|
618
|
+
: `${where}: an upload parameter without field receives every file of the body and cannot be combined with other upload parameters`,
|
|
619
|
+
location: param.location,
|
|
620
|
+
hints: ['Give each @UploadedFile / @UploadedFiles parameter its own field'],
|
|
621
|
+
related: [{ message: 'Other upload parameter', location: previous.location }],
|
|
622
|
+
})
|
|
623
|
+
})
|
|
624
|
+
return uploads.length > 0
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** HTTP method decorators (and parameter decorators) on a method without route decorator. */
|
|
628
|
+
function reportNotARoute(ctx: Context, method: MethodInfo, where: string): void {
|
|
629
|
+
const hint = [`Add a route decorator to ${where}: @Get(), @Post(), @Put(), @Patch(), @Delete()...`]
|
|
630
|
+
for (const decorator of method.decorators) {
|
|
631
|
+
if (decorator.owner === EXTENSION_NAME && validTarget(decorator)) {
|
|
632
|
+
error(ctx, decorator.location, `@${decorator.name} has no effect: ${where} is not a route`, hint)
|
|
633
|
+
} else if (isRouteMetaDecorator(ctx, decorator)) {
|
|
634
|
+
ctx.report({
|
|
635
|
+
code: Codes.InvalidDecorator,
|
|
636
|
+
severity: 'warning',
|
|
637
|
+
message: `@${decorator.name} has no effect: ${where} is not a route`,
|
|
638
|
+
location: decorator.location,
|
|
639
|
+
hints: hint,
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
for (const param of method.params) {
|
|
644
|
+
for (const decorator of unique(param.decorators)) {
|
|
645
|
+
if (decorator.owner === EXTENSION_NAME && validTarget(decorator)) {
|
|
646
|
+
error(ctx, decorator.location, `@${decorator.name} has no effect: ${where} is not a route`, hint)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** A route handler must be a public, non-static method with a plain name. */
|
|
653
|
+
function checkHandler(ctx: Context, method: MethodInfo, where: string, decorator: DecoratorInfo): boolean {
|
|
654
|
+
let problem: string | undefined
|
|
655
|
+
let hint = 'Route handlers are public instance methods'
|
|
656
|
+
if (method.static) {
|
|
657
|
+
problem = `Static method ${where} cannot be a route handler`
|
|
658
|
+
} else if (method.privateName) {
|
|
659
|
+
problem = `#private method ${where} cannot be a route handler`
|
|
660
|
+
hint = 'Use a public method (the generated code calls it from outside the class)'
|
|
661
|
+
} else if (method.visibility !== 'public') {
|
|
662
|
+
problem = `${method.visibility === 'protected' ? 'Protected' : 'Private'} method ${where} cannot be a route handler`
|
|
663
|
+
hint = 'Make the method public (the generated code calls it from outside the class)'
|
|
664
|
+
} else if (method.kind !== 'method') {
|
|
665
|
+
problem = `Accessor ${where} cannot be a route handler`
|
|
666
|
+
} else if (method.computed) {
|
|
667
|
+
problem = `${where} cannot be a route handler: handlers need a plain name (no computed key)`
|
|
668
|
+
}
|
|
669
|
+
if (problem !== undefined) {
|
|
670
|
+
error(ctx, decorator.location, problem, [hint])
|
|
671
|
+
return false
|
|
672
|
+
}
|
|
673
|
+
return true
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function httpCode(ctx: Context, decorators: readonly DecoratorInfo[]): number | undefined {
|
|
677
|
+
const [decorator, ...duplicates] = decorators.filter(candidate => candidate.identity === Ids.HttpCode)
|
|
678
|
+
for (const duplicate of duplicates) {
|
|
679
|
+
error(ctx, duplicate.location, '@HttpCode is applied several times', ['Keep a single @HttpCode(status)'])
|
|
680
|
+
}
|
|
681
|
+
if (decorator === undefined) {
|
|
682
|
+
return undefined
|
|
683
|
+
}
|
|
684
|
+
const [arg] = decorator.args
|
|
685
|
+
if (arg === undefined) {
|
|
686
|
+
error(ctx, decorator.location, '@HttpCode requires a status', ['@HttpCode(202)'])
|
|
687
|
+
return undefined
|
|
688
|
+
}
|
|
689
|
+
if (!arg.evaluated) {
|
|
690
|
+
return undefined
|
|
691
|
+
}
|
|
692
|
+
const status = arg.value
|
|
693
|
+
if (typeof status !== 'number' || !Number.isInteger(status) || status < 100 || status > 599) {
|
|
694
|
+
error(ctx, arg.location, `@HttpCode expects an integer status between 100 and 599, got ${describe(status)}`)
|
|
695
|
+
return undefined
|
|
696
|
+
}
|
|
697
|
+
return status
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function responseHeaders(ctx: Context, decorators: readonly DecoratorInfo[]): (readonly [string, string])[] {
|
|
701
|
+
const headers: (readonly [string, string])[] = []
|
|
702
|
+
const seen = new Map<string, Location>()
|
|
703
|
+
for (const decorator of decorators) {
|
|
704
|
+
if (decorator.identity !== Ids.Header) {
|
|
705
|
+
continue
|
|
706
|
+
}
|
|
707
|
+
const [name, value] = decorator.args
|
|
708
|
+
if (name === undefined || value === undefined) {
|
|
709
|
+
error(ctx, decorator.location, '@Header requires a name and a value', ["@Header('cache-control', 'no-store')"])
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
if (!name.evaluated || !value.evaluated) {
|
|
713
|
+
continue
|
|
714
|
+
}
|
|
715
|
+
if (typeof name.value !== 'string' || !TOKEN.test(name.value)) {
|
|
716
|
+
error(ctx, name.location, `@Header: invalid header name ${describe(name.value)}`, [
|
|
717
|
+
"Header names are tokens: letters, digits and ! # $ % & ' * + - . ^ _ ` | ~",
|
|
718
|
+
])
|
|
719
|
+
continue
|
|
720
|
+
}
|
|
721
|
+
if (typeof value.value !== 'string' || /[\r\n\0]/.test(value.value)) {
|
|
722
|
+
error(ctx, value.location, `@Header: the value of "${name.value}" must be a string without line breaks`)
|
|
723
|
+
continue
|
|
724
|
+
}
|
|
725
|
+
const lower = name.value.toLowerCase()
|
|
726
|
+
const previous = seen.get(lower)
|
|
727
|
+
if (previous) {
|
|
728
|
+
ctx.report({
|
|
729
|
+
code: Codes.InvalidDecorator,
|
|
730
|
+
severity: 'error',
|
|
731
|
+
message: `@Header: "${lower}" is set several times`,
|
|
732
|
+
location: decorator.location,
|
|
733
|
+
hints: ['Set each response header once (or use ctx.appendHeader() in the handler)'],
|
|
734
|
+
related: [{ message: 'First set here', location: previous }],
|
|
735
|
+
})
|
|
736
|
+
continue
|
|
737
|
+
}
|
|
738
|
+
seen.set(lower, decorator.location)
|
|
739
|
+
headers.push([lower, value.value])
|
|
740
|
+
}
|
|
741
|
+
return headers
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
interface BodyValidation {
|
|
745
|
+
readonly schema: ValueRef
|
|
746
|
+
readonly decorator: DecoratorInfo
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** `@Validate(schema)` of a handler. */
|
|
750
|
+
function validateSchema(ctx: Context, decorators: readonly DecoratorInfo[]): BodyValidation | undefined {
|
|
751
|
+
const [decorator, ...duplicates] = decorators.filter(candidate => candidate.identity === Ids.Validate)
|
|
752
|
+
for (const duplicate of duplicates) {
|
|
753
|
+
error(ctx, duplicate.location, '@Validate is applied several times', [
|
|
754
|
+
'Keep a single @Validate(schema): compose the validation in the schema',
|
|
755
|
+
])
|
|
756
|
+
}
|
|
757
|
+
if (decorator === undefined) {
|
|
758
|
+
return undefined
|
|
759
|
+
}
|
|
760
|
+
const [arg] = decorator.args
|
|
761
|
+
if (arg === undefined) {
|
|
762
|
+
error(ctx, decorator.location, '@Validate requires a schema', ['@Validate(CreateUser)'])
|
|
763
|
+
return undefined
|
|
764
|
+
}
|
|
765
|
+
const schema = schemaRef(ctx, arg, '@Validate')
|
|
766
|
+
return schema ? { schema, decorator } : undefined
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Guard / interceptor references of a class or method, bound to provider tokens (in order). */
|
|
770
|
+
function classRefs(
|
|
771
|
+
ctx: Context,
|
|
772
|
+
cls: ClassInfo,
|
|
773
|
+
decorators: readonly DecoratorInfo[],
|
|
774
|
+
identity: string,
|
|
775
|
+
role: string,
|
|
776
|
+
): ClassRef[] {
|
|
777
|
+
const refs: ClassRef[] = []
|
|
778
|
+
for (const decorator of decorators) {
|
|
779
|
+
if (decorator.identity !== identity) {
|
|
780
|
+
continue
|
|
781
|
+
}
|
|
782
|
+
if (decorator.called && decorator.args.length === 0) {
|
|
783
|
+
error(ctx, decorator.location, `@${decorator.name} requires at least one ${role}`, [
|
|
784
|
+
`@${decorator.name}(My${role === 'guard' ? 'Guard' : 'Interceptor'})`,
|
|
785
|
+
])
|
|
786
|
+
}
|
|
787
|
+
for (const arg of decorator.args) {
|
|
788
|
+
// binds the class (auto-registered as a singleton when it is not a provider) and declares the
|
|
789
|
+
// controller → guard edge (ZNIGHT003, ZNIGHT019)
|
|
790
|
+
const ref = ctx.resolveClassRef(arg, { from: cls, role })
|
|
791
|
+
if (ref) {
|
|
792
|
+
refs.push(ref)
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return refs
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// -------------------------------------------------------------------------------------------------
|
|
800
|
+
// Parameters
|
|
801
|
+
// -------------------------------------------------------------------------------------------------
|
|
802
|
+
|
|
803
|
+
function analyzeParams(
|
|
804
|
+
ctx: Context,
|
|
805
|
+
cls: ClassInfo,
|
|
806
|
+
method: MethodInfo,
|
|
807
|
+
where: string,
|
|
808
|
+
bodyValidation: BodyValidation | undefined,
|
|
809
|
+
): { readonly params: HttpParamData[]; readonly complete: boolean } {
|
|
810
|
+
const params: HttpParamData[] = []
|
|
811
|
+
let complete = true
|
|
812
|
+
for (const param of method.params) {
|
|
813
|
+
const decorators = unique(param.decorators).filter(
|
|
814
|
+
decorator => decorator.owner === EXTENSION_NAME && PARAM_SOURCES.has(decorator.identity ?? ''),
|
|
815
|
+
)
|
|
816
|
+
const [decorator, ...extra] = decorators
|
|
817
|
+
const foreign = decorator === undefined ? foreignParameterDecorator(param) : undefined
|
|
818
|
+
if (foreign !== undefined) {
|
|
819
|
+
error(
|
|
820
|
+
ctx,
|
|
821
|
+
foreign.decorator.location,
|
|
822
|
+
`@${foreign.decorator.name} on parameter '${param.name}' of ${where} is imported from '${foreign.module}': route parameters use ${foreign.name} from '${HTTP_PACKAGE}'`,
|
|
823
|
+
[`Import it from '${HTTP_PACKAGE}': import { ${foreign.name} } from '${HTTP_PACKAGE}'`],
|
|
824
|
+
)
|
|
825
|
+
complete = false
|
|
826
|
+
continue
|
|
827
|
+
}
|
|
828
|
+
if (decorator === undefined) {
|
|
829
|
+
error(ctx, param.location, `Parameter '${param.name}' of ${where} has no HTTP decorator`, [
|
|
830
|
+
'Decorate it with @Param(), @Query(), @Body(), @Headers(), @Cookie(), @SignedCookie(), @UploadedFile(), @UploadedFiles(), @Ctx(), @Req() or @Res()',
|
|
831
|
+
])
|
|
832
|
+
complete = false
|
|
833
|
+
continue
|
|
834
|
+
}
|
|
835
|
+
for (const other of extra) {
|
|
836
|
+
error(ctx, other.location, `Parameter '${param.name}' of ${where} has several HTTP decorators`, [
|
|
837
|
+
'Keep a single parameter decorator',
|
|
838
|
+
])
|
|
839
|
+
}
|
|
840
|
+
if (param.rest) {
|
|
841
|
+
error(ctx, decorator.location, `Rest parameter '${param.name}' of ${where} cannot be injected`)
|
|
842
|
+
complete = false
|
|
843
|
+
continue
|
|
844
|
+
}
|
|
845
|
+
const data = analyzeParam(ctx, cls, method, param, decorator, where, bodyValidation)
|
|
846
|
+
if (data) {
|
|
847
|
+
params.push(data)
|
|
848
|
+
} else {
|
|
849
|
+
complete = false
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return { params, complete }
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function analyzeParam(
|
|
856
|
+
ctx: Context,
|
|
857
|
+
cls: ClassInfo,
|
|
858
|
+
method: MethodInfo,
|
|
859
|
+
param: ParameterInfo,
|
|
860
|
+
decorator: DecoratorInfo,
|
|
861
|
+
where: string,
|
|
862
|
+
bodyValidation: BodyValidation | undefined,
|
|
863
|
+
): HttpParamData | undefined {
|
|
864
|
+
const source = PARAM_SOURCES.get(decorator.identity ?? '')
|
|
865
|
+
if (source === undefined) {
|
|
866
|
+
return undefined
|
|
867
|
+
}
|
|
868
|
+
const declared = parameterType(ctx, cls, method, param)
|
|
869
|
+
const defaultValue = staticDefault(param)
|
|
870
|
+
const base: Omit<HttpParamData, 'key' | 'schema' | 'coercion' | 'optional'> = {
|
|
871
|
+
index: param.index,
|
|
872
|
+
name: param.name,
|
|
873
|
+
source,
|
|
874
|
+
type: undefined,
|
|
875
|
+
...(defaultValue === undefined ? {} : { defaultValue: defaultValue.value }),
|
|
876
|
+
location: decorator.location,
|
|
877
|
+
}
|
|
878
|
+
// the declared type is recorded for coerced values (informational, `params[i].type`)
|
|
879
|
+
const type = declared.type === undefined ? undefined : ctx.types.format(declared.type)
|
|
880
|
+
const plain = (): HttpParamData => ({
|
|
881
|
+
...base,
|
|
882
|
+
key: undefined,
|
|
883
|
+
optional: false,
|
|
884
|
+
schema: undefined,
|
|
885
|
+
coercion: undefined,
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
switch (source) {
|
|
889
|
+
case 'request':
|
|
890
|
+
case 'context':
|
|
891
|
+
return plain()
|
|
892
|
+
case 'response':
|
|
893
|
+
ctx.report({
|
|
894
|
+
code: Codes.InvalidDecorator,
|
|
895
|
+
severity: 'warning',
|
|
896
|
+
message: `@Res() makes ${where} adapter-specific: the handler writes the native response itself`,
|
|
897
|
+
location: decorator.location,
|
|
898
|
+
hints: ['Prefer returning a value, an HttpResponse or a Web Response (portable across adapters)'],
|
|
899
|
+
})
|
|
900
|
+
return plain()
|
|
901
|
+
case 'body': {
|
|
902
|
+
const [arg] = decorator.args
|
|
903
|
+
let schema: ValueRef | undefined
|
|
904
|
+
if (arg !== undefined) {
|
|
905
|
+
schema = schemaRef(ctx, arg, '@Body')
|
|
906
|
+
if (schema === undefined) {
|
|
907
|
+
return undefined
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (bodyValidation !== undefined) {
|
|
911
|
+
if (schema !== undefined) {
|
|
912
|
+
ctx.report({
|
|
913
|
+
code: Codes.InvalidDecorator,
|
|
914
|
+
severity: 'error',
|
|
915
|
+
message: `The body of ${where} is validated twice: @Validate(${bodyValidation.schema.text}) and @Body(${schema.text})`,
|
|
916
|
+
location: bodyValidation.decorator.location,
|
|
917
|
+
hints: ['Keep one of them: @Validate(S) on the method is the same as @Body(S) on the parameter'],
|
|
918
|
+
related: [{ message: '@Body schema', location: decorator.location }],
|
|
919
|
+
})
|
|
920
|
+
return undefined
|
|
921
|
+
}
|
|
922
|
+
schema = bodyValidation.schema
|
|
923
|
+
}
|
|
924
|
+
if (schema === undefined) {
|
|
925
|
+
checkUnvalidated(ctx, declared.type, decorator, param, where, 'body')
|
|
926
|
+
}
|
|
927
|
+
return { ...base, key: undefined, optional: declared.optional, schema, coercion: undefined }
|
|
928
|
+
}
|
|
929
|
+
case 'file':
|
|
930
|
+
case 'files': {
|
|
931
|
+
const upload = uploadOf(ctx, decorator, param, declared, where, source === 'files')
|
|
932
|
+
return upload === undefined
|
|
933
|
+
? undefined
|
|
934
|
+
: {
|
|
935
|
+
...base,
|
|
936
|
+
key: upload.field,
|
|
937
|
+
optional: !upload.required,
|
|
938
|
+
schema: undefined,
|
|
939
|
+
coercion: undefined,
|
|
940
|
+
upload,
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
case 'headers': {
|
|
944
|
+
const [arg] = decorator.args
|
|
945
|
+
if (arg === undefined) {
|
|
946
|
+
return plain()
|
|
947
|
+
}
|
|
948
|
+
if (!arg.evaluated) {
|
|
949
|
+
return undefined
|
|
950
|
+
}
|
|
951
|
+
if (typeof arg.value !== 'string' || !TOKEN.test(arg.value)) {
|
|
952
|
+
error(ctx, arg.location, `@Headers: invalid header name ${describe(arg.value)}`, [
|
|
953
|
+
"@Headers('x-api-key'), or @Headers() for every header",
|
|
954
|
+
])
|
|
955
|
+
return undefined
|
|
956
|
+
}
|
|
957
|
+
const key = arg.value.toLowerCase()
|
|
958
|
+
const coercion = coercionOf(ctx, declared, param, decorator, where, 'headers', key)
|
|
959
|
+
return coercion ? { ...base, key, type, optional: declared.optional, schema: undefined, coercion } : undefined
|
|
960
|
+
}
|
|
961
|
+
case 'param':
|
|
962
|
+
case 'query': {
|
|
963
|
+
const parsed = nameAndSchema(ctx, decorator)
|
|
964
|
+
if (!parsed.ok) {
|
|
965
|
+
return undefined
|
|
966
|
+
}
|
|
967
|
+
const { key, schema } = parsed
|
|
968
|
+
const optional = source === 'query' && declared.optional
|
|
969
|
+
if (schema !== undefined) {
|
|
970
|
+
return { ...base, key, optional, schema, coercion: undefined }
|
|
971
|
+
}
|
|
972
|
+
if (key === undefined) {
|
|
973
|
+
if (source === 'query') {
|
|
974
|
+
checkUnvalidated(ctx, declared.type, decorator, param, where, 'query')
|
|
975
|
+
}
|
|
976
|
+
return { ...base, key, optional: false, schema: undefined, coercion: undefined }
|
|
977
|
+
}
|
|
978
|
+
const coercion = coercionOf(ctx, declared, param, decorator, where, source, key)
|
|
979
|
+
return coercion ? { ...base, key, type, optional, schema: undefined, coercion } : undefined
|
|
980
|
+
}
|
|
981
|
+
case 'cookie':
|
|
982
|
+
case 'signedCookie':
|
|
983
|
+
return cookieParam(ctx, param, decorator, declared, where, base, type)
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** `@Cookie(name?, schema?)` / `@SignedCookie(name, schema?)`. */
|
|
988
|
+
function cookieParam(
|
|
989
|
+
ctx: Context,
|
|
990
|
+
param: ParameterInfo,
|
|
991
|
+
decorator: DecoratorInfo,
|
|
992
|
+
declared: ReturnType<typeof parameterType>,
|
|
993
|
+
where: string,
|
|
994
|
+
base: Omit<HttpParamData, 'key' | 'schema' | 'coercion' | 'optional'>,
|
|
995
|
+
type: string | undefined,
|
|
996
|
+
): HttpParamData | undefined {
|
|
997
|
+
const parsed = nameAndSchema(ctx, decorator)
|
|
998
|
+
if (!parsed.ok) {
|
|
999
|
+
return undefined
|
|
1000
|
+
}
|
|
1001
|
+
const { key, schema } = parsed
|
|
1002
|
+
const name = `@${decorator.name}`
|
|
1003
|
+
if (key !== undefined && !TOKEN.test(key)) {
|
|
1004
|
+
error(ctx, decorator.args[0]?.location ?? decorator.location, `${name}: invalid cookie name ${describe(key)}`, [
|
|
1005
|
+
`Cookie names are tokens (letters, digits and ! # $ % & ' * + - . ^ _ \` | ~): ${name}('session_id')`,
|
|
1006
|
+
])
|
|
1007
|
+
return undefined
|
|
1008
|
+
}
|
|
1009
|
+
if (decorator.identity === Ids.SignedCookie && key === undefined) {
|
|
1010
|
+
error(ctx, decorator.location, '@SignedCookie requires a cookie name', ["@SignedCookie('session_id')"])
|
|
1011
|
+
return undefined
|
|
1012
|
+
}
|
|
1013
|
+
const optional = declared.optional
|
|
1014
|
+
if (schema !== undefined) {
|
|
1015
|
+
return { ...base, key, optional, schema, coercion: undefined }
|
|
1016
|
+
}
|
|
1017
|
+
if (key === undefined) {
|
|
1018
|
+
checkUnvalidated(ctx, declared.type, decorator, param, where, 'cookies')
|
|
1019
|
+
return { ...base, key, optional: false, schema: undefined, coercion: undefined }
|
|
1020
|
+
}
|
|
1021
|
+
const coercion = coercionOf(ctx, declared, param, decorator, where, 'cookie', key)
|
|
1022
|
+
return coercion ? { ...base, key, type, optional, schema: undefined, coercion } : undefined
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* The statically evaluable default value of a parameter (`page = 1`), without references to
|
|
1027
|
+
* exported bindings. `ParameterInfo.initializer` is read defensively (compilers before RFC 0002 C4
|
|
1028
|
+
* do not provide it).
|
|
1029
|
+
*/
|
|
1030
|
+
function staticDefault(param: ParameterInfo): { readonly value: StaticValue } | undefined {
|
|
1031
|
+
const initializer = (param as { readonly initializer?: ArgInfo | undefined }).initializer
|
|
1032
|
+
if (initializer === undefined || !initializer.evaluated || initializer.ref !== undefined) {
|
|
1033
|
+
return undefined
|
|
1034
|
+
}
|
|
1035
|
+
const value = initializer.value
|
|
1036
|
+
if (value === undefined || hasRef(value)) {
|
|
1037
|
+
return undefined
|
|
1038
|
+
}
|
|
1039
|
+
return { value }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function hasRef(value: StaticValue): boolean {
|
|
1043
|
+
if (isValueRef(value)) {
|
|
1044
|
+
return true
|
|
1045
|
+
}
|
|
1046
|
+
if (Array.isArray(value)) {
|
|
1047
|
+
return value.some(hasRef)
|
|
1048
|
+
}
|
|
1049
|
+
if (typeof value === 'object' && value !== null) {
|
|
1050
|
+
return Object.values(value).some(hasRef)
|
|
1051
|
+
}
|
|
1052
|
+
return false
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** `@UploadedFile(field?, options?)` / `@UploadedFiles(field?, options?)`: field, options and type. */
|
|
1056
|
+
function uploadOf(
|
|
1057
|
+
ctx: Context,
|
|
1058
|
+
decorator: DecoratorInfo,
|
|
1059
|
+
param: ParameterInfo,
|
|
1060
|
+
declared: ReturnType<typeof parameterType>,
|
|
1061
|
+
where: string,
|
|
1062
|
+
multiple: boolean,
|
|
1063
|
+
): UploadData | undefined {
|
|
1064
|
+
const name = `@${decorator.name}`
|
|
1065
|
+
const [first, second] = decorator.args
|
|
1066
|
+
let field: string | undefined
|
|
1067
|
+
let options: ArgInfo | undefined
|
|
1068
|
+
if (first !== undefined) {
|
|
1069
|
+
if (!first.evaluated) {
|
|
1070
|
+
return undefined // already reported
|
|
1071
|
+
}
|
|
1072
|
+
if (typeof first.value === 'string') {
|
|
1073
|
+
if (first.value === '') {
|
|
1074
|
+
error(ctx, first.location, `${name} expects a non-empty field name`, [`${name}('avatar')`])
|
|
1075
|
+
return undefined
|
|
1076
|
+
}
|
|
1077
|
+
field = first.value
|
|
1078
|
+
options = second
|
|
1079
|
+
} else if (first.value === undefined) {
|
|
1080
|
+
options = second
|
|
1081
|
+
} else if (isPlainObject(first.value)) {
|
|
1082
|
+
if (second !== undefined) {
|
|
1083
|
+
error(ctx, second.location, `${name}(options) takes a single argument`, [
|
|
1084
|
+
`Put the field first: ${name}('field', { ... })`,
|
|
1085
|
+
])
|
|
1086
|
+
return undefined
|
|
1087
|
+
}
|
|
1088
|
+
options = first
|
|
1089
|
+
} else {
|
|
1090
|
+
error(ctx, first.location, `${name} expects a field name (string) or options, got ${describe(first.value)}`, [
|
|
1091
|
+
`${name}('avatar'), ${name}('avatar', { maxSize: '5mb' }) or ${name}({ maxSize: '5mb' })`,
|
|
1092
|
+
])
|
|
1093
|
+
return undefined
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const parsed = uploadOptions(ctx, name, options, multiple)
|
|
1097
|
+
if (parsed === undefined) {
|
|
1098
|
+
return undefined
|
|
1099
|
+
}
|
|
1100
|
+
const type = uploadType(ctx, declared.type, multiple)
|
|
1101
|
+
if (!type.ok) {
|
|
1102
|
+
error(
|
|
1103
|
+
ctx,
|
|
1104
|
+
param.typeLocation,
|
|
1105
|
+
`${name} parameter '${param.name}' of ${where} must be typed ${multiple ? 'File[]' : 'File'}, got ${type.got}`,
|
|
1106
|
+
[multiple ? `${param.name}: File[]` : `${param.name}: File (or ${param.name}?: File when optional)`],
|
|
1107
|
+
)
|
|
1108
|
+
return undefined
|
|
1109
|
+
}
|
|
1110
|
+
const required = parsed.required ?? !declared.optional
|
|
1111
|
+
let cast = type.cast
|
|
1112
|
+
if (!multiple && !required) {
|
|
1113
|
+
const acceptsMissing =
|
|
1114
|
+
declared.type === undefined ||
|
|
1115
|
+
param.optional ||
|
|
1116
|
+
param.hasDefault ||
|
|
1117
|
+
acceptsUndefined(declared.type) ||
|
|
1118
|
+
isRaw(ctx.types.stripNullish(declared.type).type)
|
|
1119
|
+
if (!acceptsMissing && !declared.optional) {
|
|
1120
|
+
error(
|
|
1121
|
+
ctx,
|
|
1122
|
+
options?.location ?? decorator.location,
|
|
1123
|
+
`${name}: '${param.name}' of ${where} is optional ({ required: false }) but typed ${ctx.types.format(declared.type as TypeInfo)}`,
|
|
1124
|
+
[`Type it ${param.name}?: File (undefined when no file is sent)`],
|
|
1125
|
+
)
|
|
1126
|
+
return undefined
|
|
1127
|
+
}
|
|
1128
|
+
// `File | null` receives undefined
|
|
1129
|
+
cast ||= !acceptsMissing
|
|
1130
|
+
}
|
|
1131
|
+
return {
|
|
1132
|
+
field,
|
|
1133
|
+
multiple,
|
|
1134
|
+
required,
|
|
1135
|
+
maxSize: parsed.maxSize,
|
|
1136
|
+
maxFiles: parsed.maxFiles,
|
|
1137
|
+
mimeTypes: parsed.mimeTypes,
|
|
1138
|
+
cast,
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
interface ParsedUploadOptions {
|
|
1143
|
+
readonly required: boolean | undefined
|
|
1144
|
+
readonly maxSize: number | undefined
|
|
1145
|
+
readonly maxFiles: number | undefined
|
|
1146
|
+
readonly mimeTypes: readonly string[] | undefined
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** The options object of an upload decorator (`undefined` after an error). */
|
|
1150
|
+
function uploadOptions(
|
|
1151
|
+
ctx: Context,
|
|
1152
|
+
name: string,
|
|
1153
|
+
arg: ArgInfo | undefined,
|
|
1154
|
+
multiple: boolean,
|
|
1155
|
+
): ParsedUploadOptions | undefined {
|
|
1156
|
+
const result: { -readonly [K in keyof ParsedUploadOptions]: ParsedUploadOptions[K] } = {
|
|
1157
|
+
required: undefined,
|
|
1158
|
+
maxSize: undefined,
|
|
1159
|
+
maxFiles: undefined,
|
|
1160
|
+
mimeTypes: undefined,
|
|
1161
|
+
}
|
|
1162
|
+
if (arg === undefined) {
|
|
1163
|
+
return result
|
|
1164
|
+
}
|
|
1165
|
+
if (!arg.evaluated) {
|
|
1166
|
+
return undefined
|
|
1167
|
+
}
|
|
1168
|
+
if (arg.value === undefined) {
|
|
1169
|
+
return result
|
|
1170
|
+
}
|
|
1171
|
+
const allowed = multiple ? UPLOAD_OPTIONS : UPLOAD_OPTIONS.filter(option => option !== 'maxFiles')
|
|
1172
|
+
const shape = `{ ${allowed.map(option => `${option}?`).join(', ')} }`
|
|
1173
|
+
if (!isPlainObject(arg.value)) {
|
|
1174
|
+
error(ctx, arg.location, `${name} options must be an object ${shape}, got ${describe(arg.value)}`)
|
|
1175
|
+
return undefined
|
|
1176
|
+
}
|
|
1177
|
+
let ok = true
|
|
1178
|
+
const invalid = (message: string, hints: readonly string[] = []): void => {
|
|
1179
|
+
ok = false
|
|
1180
|
+
error(ctx, arg.location, `${name}: ${message}`, hints)
|
|
1181
|
+
}
|
|
1182
|
+
for (const [key, value] of Object.entries(arg.value)) {
|
|
1183
|
+
if (value === undefined) {
|
|
1184
|
+
continue
|
|
1185
|
+
}
|
|
1186
|
+
switch (key) {
|
|
1187
|
+
case 'required':
|
|
1188
|
+
if (typeof value === 'boolean') {
|
|
1189
|
+
result.required = value
|
|
1190
|
+
} else {
|
|
1191
|
+
invalid(`"required" must be a boolean, got ${describe(value)}`)
|
|
1192
|
+
}
|
|
1193
|
+
break
|
|
1194
|
+
case 'maxSize': {
|
|
1195
|
+
const size = parseByteSize(value)
|
|
1196
|
+
if (size === undefined) {
|
|
1197
|
+
invalid(`"maxSize" must be a size in bytes, got ${describe(value)}`, [
|
|
1198
|
+
"A non-negative integer (5_242_880), a size with a unit ('5mb', '512kb'; kb = 1024 bytes) or Infinity",
|
|
1199
|
+
])
|
|
1200
|
+
} else {
|
|
1201
|
+
result.maxSize = size
|
|
1202
|
+
}
|
|
1203
|
+
break
|
|
1204
|
+
}
|
|
1205
|
+
case 'maxFiles':
|
|
1206
|
+
if (!multiple) {
|
|
1207
|
+
invalid('"maxFiles" only applies to @UploadedFiles (a single file is expected here)', [
|
|
1208
|
+
'Use @UploadedFiles() to receive several files',
|
|
1209
|
+
])
|
|
1210
|
+
} else if (value === Number.POSITIVE_INFINITY || (Number.isSafeInteger(value) && (value as number) >= 1)) {
|
|
1211
|
+
result.maxFiles = value as number
|
|
1212
|
+
} else {
|
|
1213
|
+
invalid(`"maxFiles" must be a positive integer, got ${describe(value)}`)
|
|
1214
|
+
}
|
|
1215
|
+
break
|
|
1216
|
+
case 'mimeTypes': {
|
|
1217
|
+
const patterns = Array.isArray(value) ? value : undefined
|
|
1218
|
+
const wrong = patterns?.filter(pattern => typeof pattern !== 'string' || !isMediaTypePattern(pattern)) ?? []
|
|
1219
|
+
if (patterns === undefined || patterns.length === 0) {
|
|
1220
|
+
invalid(`"mimeTypes" must be a non-empty array of media types, got ${describe(value)}`, [
|
|
1221
|
+
"mimeTypes: ['image/png', 'image/*']",
|
|
1222
|
+
])
|
|
1223
|
+
} else if (wrong.length > 0) {
|
|
1224
|
+
invalid(`invalid media type ${wrong.map(describe).join(', ')} in "mimeTypes"`, [
|
|
1225
|
+
"Use type/subtype, type/* or */* without parameters: 'image/png', 'image/*'",
|
|
1226
|
+
])
|
|
1227
|
+
} else {
|
|
1228
|
+
result.mimeTypes = patterns as string[]
|
|
1229
|
+
}
|
|
1230
|
+
break
|
|
1231
|
+
}
|
|
1232
|
+
default:
|
|
1233
|
+
invalid(`unknown option "${key}"`, [`Options: ${shape}`])
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return ok ? result : undefined
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Whether a parameter type can receive uploaded files (and whether the value must be cast). */
|
|
1240
|
+
function uploadType(
|
|
1241
|
+
ctx: Context,
|
|
1242
|
+
type: TypeInfo | undefined,
|
|
1243
|
+
multiple: boolean,
|
|
1244
|
+
): { readonly ok: true; readonly cast: boolean } | { readonly ok: false; readonly got: string } {
|
|
1245
|
+
if (type === undefined) {
|
|
1246
|
+
return { ok: true, cast: false }
|
|
1247
|
+
}
|
|
1248
|
+
const { type: base } = ctx.types.stripNullish(type)
|
|
1249
|
+
if (isRaw(base)) {
|
|
1250
|
+
return { ok: true, cast: false }
|
|
1251
|
+
}
|
|
1252
|
+
let target: TypeInfo | undefined = base
|
|
1253
|
+
if (multiple) {
|
|
1254
|
+
target = base.kind === 'array' ? base.element : undefined
|
|
1255
|
+
}
|
|
1256
|
+
if (target !== undefined && isRaw(target)) {
|
|
1257
|
+
return { ok: true, cast: false }
|
|
1258
|
+
}
|
|
1259
|
+
const cast = target?.kind === 'reference' ? FILE_TYPES.get(target.identity) : undefined
|
|
1260
|
+
return cast === undefined ? { ok: false, got: ctx.types.format(type) } : { ok: true, cast }
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** Name and/or schema of `@Param(name?, schema?)` / `@Query(name?, schema?)`. */
|
|
1264
|
+
function nameAndSchema(
|
|
1265
|
+
ctx: Context,
|
|
1266
|
+
decorator: DecoratorInfo,
|
|
1267
|
+
):
|
|
1268
|
+
| { readonly ok: true; readonly key: string | undefined; readonly schema: ValueRef | undefined }
|
|
1269
|
+
| { readonly ok: false } {
|
|
1270
|
+
const [first, second] = decorator.args
|
|
1271
|
+
if (first === undefined) {
|
|
1272
|
+
return { ok: true, key: undefined, schema: undefined }
|
|
1273
|
+
}
|
|
1274
|
+
if (first.error !== undefined) {
|
|
1275
|
+
return { ok: false }
|
|
1276
|
+
}
|
|
1277
|
+
if (first.ref !== undefined) {
|
|
1278
|
+
if (second !== undefined) {
|
|
1279
|
+
error(ctx, second.location, `@${decorator.name}(schema) takes a single argument`, [
|
|
1280
|
+
`Put the name first: @${decorator.name}('name', Schema)`,
|
|
1281
|
+
])
|
|
1282
|
+
return { ok: false }
|
|
1283
|
+
}
|
|
1284
|
+
return { ok: true, key: undefined, schema: first.ref }
|
|
1285
|
+
}
|
|
1286
|
+
if (typeof first.value !== 'string' || first.value === '') {
|
|
1287
|
+
error(
|
|
1288
|
+
ctx,
|
|
1289
|
+
first.location,
|
|
1290
|
+
`@${decorator.name} expects a name (non-empty string) or a schema, got ${describe(first.value)}`,
|
|
1291
|
+
[`@${decorator.name}('id'), @${decorator.name}('id', Schema) or @${decorator.name}(Schema)`],
|
|
1292
|
+
)
|
|
1293
|
+
return { ok: false }
|
|
1294
|
+
}
|
|
1295
|
+
if (second === undefined) {
|
|
1296
|
+
return { ok: true, key: first.value, schema: undefined }
|
|
1297
|
+
}
|
|
1298
|
+
const schema = schemaRef(ctx, second, `@${decorator.name}`)
|
|
1299
|
+
return schema ? { ok: true, key: first.value, schema } : { ok: false }
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** A schema argument: a reference to an exported binding. */
|
|
1303
|
+
function schemaRef(ctx: Context, arg: ArgInfo, what: string): ValueRef | undefined {
|
|
1304
|
+
if (arg.error !== undefined) {
|
|
1305
|
+
return undefined // already reported
|
|
1306
|
+
}
|
|
1307
|
+
if (arg.ref === undefined) {
|
|
1308
|
+
error(ctx, arg.location, `${what} expects a Standard Schema, got ${describe(arg.value)}`, [
|
|
1309
|
+
'Pass a schema declared with `export const Schema = z.object({ ... })` (Zod, Valibot, ArkType...)',
|
|
1310
|
+
])
|
|
1311
|
+
return undefined
|
|
1312
|
+
}
|
|
1313
|
+
const symbol = arg.ref.symbol
|
|
1314
|
+
if (symbol.kind === 'declaration' && symbol.declaration.kind === 'class' && !hasStandardSchema(ctx, arg.ref)) {
|
|
1315
|
+
ctx.report({
|
|
1316
|
+
code: Codes.InvalidDecorator,
|
|
1317
|
+
severity: 'warning',
|
|
1318
|
+
message: `${what}(${arg.ref.text}): ${arg.ref.name} is a class, not a Standard Schema; the validation fails at runtime`,
|
|
1319
|
+
location: arg.location,
|
|
1320
|
+
hints: ['Pass a Standard Schema object (Zod, Valibot, ArkType...): classes are not validators'],
|
|
1321
|
+
})
|
|
1322
|
+
}
|
|
1323
|
+
return arg.ref
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function hasStandardSchema(ctx: Context, ref: ValueRef): boolean {
|
|
1327
|
+
const info = ref.identity === undefined ? undefined : ctx.classInfo(ref.identity)
|
|
1328
|
+
return (
|
|
1329
|
+
info === undefined ||
|
|
1330
|
+
info.superClass !== undefined ||
|
|
1331
|
+
info.properties.some(property => property.static && property.name === '~standard') ||
|
|
1332
|
+
info.methods.some(member => member.static && member.name === '~standard')
|
|
1333
|
+
)
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** Coercion of a named parameter / query value / header without schema. */
|
|
1337
|
+
function coercionOf(
|
|
1338
|
+
ctx: Context,
|
|
1339
|
+
declared: ReturnType<typeof parameterType>,
|
|
1340
|
+
param: ParameterInfo,
|
|
1341
|
+
decorator: DecoratorInfo,
|
|
1342
|
+
where: string,
|
|
1343
|
+
source: 'param' | 'query' | 'headers' | 'cookie',
|
|
1344
|
+
key: string,
|
|
1345
|
+
): ParamCoercion | undefined {
|
|
1346
|
+
const result = planCoercion(ctx, declared, param, source === 'param')
|
|
1347
|
+
const label = `${source === 'headers' ? 'header' : source} "${key}"`
|
|
1348
|
+
if (result.ok) {
|
|
1349
|
+
if (source === 'query' && declared.type !== undefined && isAny(declared.type)) {
|
|
1350
|
+
unvalidated(ctx, decorator, `${label} of ${where} is typed any`)
|
|
1351
|
+
}
|
|
1352
|
+
return result.coercion
|
|
1353
|
+
}
|
|
1354
|
+
if (source === 'query') {
|
|
1355
|
+
// unvalidated input: the raw value is passed as is
|
|
1356
|
+
unvalidated(ctx, decorator, `${label} of ${where}: ${result.reason}`, result.hints)
|
|
1357
|
+
return { plan: { kind: 'raw' }, cast: false }
|
|
1358
|
+
}
|
|
1359
|
+
ctx.report({
|
|
1360
|
+
code: Codes.InvalidRoute,
|
|
1361
|
+
severity: 'error',
|
|
1362
|
+
message: `Invalid ${label} of ${where}: ${result.reason}`,
|
|
1363
|
+
location: param.typeLocation,
|
|
1364
|
+
hints: [...result.hints],
|
|
1365
|
+
})
|
|
1366
|
+
return undefined
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** ZNIGHT017 for a non-primitive `@Body()` / `@Query()` without schema. */
|
|
1370
|
+
function checkUnvalidated(
|
|
1371
|
+
ctx: Context,
|
|
1372
|
+
type: TypeInfo | undefined,
|
|
1373
|
+
decorator: DecoratorInfo,
|
|
1374
|
+
param: ParameterInfo,
|
|
1375
|
+
where: string,
|
|
1376
|
+
what: 'body' | 'query' | 'cookies',
|
|
1377
|
+
): void {
|
|
1378
|
+
if (type !== undefined && acceptedWithoutSchema(ctx, type)) {
|
|
1379
|
+
return
|
|
1380
|
+
}
|
|
1381
|
+
const described = type === undefined ? 'untyped' : `typed ${ctx.types.format(type)}`
|
|
1382
|
+
unvalidated(ctx, decorator, `the ${what} of ${where} ('${param.name}', ${described}) has no schema`)
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function unvalidated(ctx: Context, decorator: DecoratorInfo, detail: string, extraHints: readonly string[] = []): void {
|
|
1386
|
+
ctx.report({
|
|
1387
|
+
code: Codes.UnvalidatedInput,
|
|
1388
|
+
severity: ctx.options.strictValidation ? 'error' : 'warning',
|
|
1389
|
+
message: `Unvalidated input: ${detail}`,
|
|
1390
|
+
location: decorator.location,
|
|
1391
|
+
hints: [
|
|
1392
|
+
...extraHints.filter(hint => !hint.startsWith('Or validate')),
|
|
1393
|
+
`Validate it with a Standard Schema: ${schemaHint(decorator.name)}`,
|
|
1394
|
+
'Or type the parameter as unknown and check it yourself',
|
|
1395
|
+
],
|
|
1396
|
+
})
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function schemaHint(decorator: string): string {
|
|
1400
|
+
if (decorator === 'Body') {
|
|
1401
|
+
return '@Body(Schema) or @Validate(Schema)'
|
|
1402
|
+
}
|
|
1403
|
+
if (decorator === 'Cookie') {
|
|
1404
|
+
return "@Cookie(Schema) for every cookie, or @Cookie('name', Schema)"
|
|
1405
|
+
}
|
|
1406
|
+
return `@${decorator}('name', Schema)`
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function acceptedWithoutSchema(ctx: Context, type: TypeInfo): boolean {
|
|
1410
|
+
const { type: base } = ctx.types.stripNullish(type)
|
|
1411
|
+
switch (base.kind) {
|
|
1412
|
+
case 'primitive':
|
|
1413
|
+
return base.name !== 'any' && base.name !== 'object' && base.name !== 'never' && base.name !== 'symbol'
|
|
1414
|
+
case 'literal':
|
|
1415
|
+
return true
|
|
1416
|
+
case 'reference':
|
|
1417
|
+
if (UNVALIDATED_REFERENCES.has(base.identity)) {
|
|
1418
|
+
return true
|
|
1419
|
+
}
|
|
1420
|
+
// Record<string, string | string[]> (the shape of ctx.query and of form bodies)
|
|
1421
|
+
return (
|
|
1422
|
+
base.identity === 'globalThis#Record' &&
|
|
1423
|
+
base.typeArgs.length === 2 &&
|
|
1424
|
+
isStringLike(base.typeArgs[1] as TypeInfo)
|
|
1425
|
+
)
|
|
1426
|
+
default:
|
|
1427
|
+
return false
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function isStringLike(type: TypeInfo): boolean {
|
|
1432
|
+
switch (type.kind) {
|
|
1433
|
+
case 'primitive':
|
|
1434
|
+
return type.name === 'string' || type.name === 'undefined' || type.name === 'unknown'
|
|
1435
|
+
case 'array':
|
|
1436
|
+
return type.element.kind === 'primitive' && type.element.name === 'string'
|
|
1437
|
+
case 'union':
|
|
1438
|
+
return type.types.every(isStringLike)
|
|
1439
|
+
default:
|
|
1440
|
+
return false
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function isAny(type: TypeInfo): boolean {
|
|
1445
|
+
return isRaw(type) && type.kind === 'primitive' && type.name === 'any'
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// -------------------------------------------------------------------------------------------------
|
|
1449
|
+
// Helpers
|
|
1450
|
+
// -------------------------------------------------------------------------------------------------
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Decorators of a parameter without duplicates (a parameter with a default value may list its
|
|
1454
|
+
* decorators twice).
|
|
1455
|
+
*/
|
|
1456
|
+
function unique(decorators: readonly DecoratorInfo[]): DecoratorInfo[] {
|
|
1457
|
+
const seen = new Set<number>()
|
|
1458
|
+
return decorators.filter(decorator => {
|
|
1459
|
+
if (seen.has(decorator.location.start)) {
|
|
1460
|
+
return false
|
|
1461
|
+
}
|
|
1462
|
+
seen.add(decorator.location.start)
|
|
1463
|
+
return true
|
|
1464
|
+
})
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** `false` when the decorator is applied to a target it does not support (reported by the core). */
|
|
1468
|
+
function validTarget(decorator: DecoratorInfo): boolean {
|
|
1469
|
+
const identity = decorator.identity ?? ''
|
|
1470
|
+
if (ROUTE_METHODS.has(identity)) {
|
|
1471
|
+
return decorator.target === 'method'
|
|
1472
|
+
}
|
|
1473
|
+
if (PARAM_SOURCES.has(identity)) {
|
|
1474
|
+
return decorator.target === 'parameter'
|
|
1475
|
+
}
|
|
1476
|
+
switch (identity) {
|
|
1477
|
+
case Ids.Controller:
|
|
1478
|
+
return decorator.target === 'class'
|
|
1479
|
+
case Ids.UseGuard:
|
|
1480
|
+
case Ids.UseInterceptor:
|
|
1481
|
+
case Ids.BodyLimit:
|
|
1482
|
+
return decorator.target === 'class' || decorator.target === 'method'
|
|
1483
|
+
default:
|
|
1484
|
+
return decorator.target === 'method'
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function isPlainObject(value: StaticValue): value is { readonly [key: string]: StaticValue } {
|
|
1489
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && !isValueRef(value)
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function describe(value: StaticValue): string {
|
|
1493
|
+
if (value === null) {
|
|
1494
|
+
return 'null'
|
|
1495
|
+
}
|
|
1496
|
+
if (Array.isArray(value)) {
|
|
1497
|
+
return 'an array'
|
|
1498
|
+
}
|
|
1499
|
+
if (isValueRef(value)) {
|
|
1500
|
+
return `a reference to '${value.text}'`
|
|
1501
|
+
}
|
|
1502
|
+
if (typeof value === 'object') {
|
|
1503
|
+
return 'an object'
|
|
1504
|
+
}
|
|
1505
|
+
if (typeof value === 'string') {
|
|
1506
|
+
return JSON.stringify(value)
|
|
1507
|
+
}
|
|
1508
|
+
return typeof value === 'bigint' ? `${value}n` : String(value)
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function error(ctx: Context, location: Location, message: string, hints?: readonly string[]): void {
|
|
1512
|
+
ctx.report({ code: Codes.InvalidDecorator, severity: 'error', message, location, ...(hints ? { hints } : {}) })
|
|
1513
|
+
}
|