@znight/http 0.3.0 → 0.4.0
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/README.md +44 -2
- package/dist/{adapter-J0UXFnNM.d.mts → adapter-DmhQGqAR.d.mts} +18 -5
- package/dist/compiler.d.mts +23 -1
- package/dist/compiler.mjs +126 -9
- package/dist/compiler.mjs.map +1 -1
- package/dist/express.d.mts +1 -1
- package/dist/fastify.d.mts +1 -1
- package/dist/hono.d.mts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +26 -7
- package/dist/index.mjs.map +1 -1
- package/dist/node.d.mts +1 -1
- package/package.json +4 -4
- package/src/compiler/analyze.ts +43 -0
- package/src/compiler/generate.ts +122 -13
- package/src/compiler/model.ts +21 -0
- package/src/context.ts +7 -0
- package/src/metadata.ts +13 -4
- package/src/pipeline.ts +12 -7
- package/src/plugin.ts +33 -0
package/dist/node.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { s as HttpAdapter } from "./adapter-
|
|
1
|
+
import { s as HttpAdapter } from "./adapter-DmhQGqAR.mjs";
|
|
2
2
|
import { IncomingMessage, Server, ServerOptions, ServerResponse } from "node:http";
|
|
3
3
|
/** Options of {@link nodeAdapter}. */
|
|
4
4
|
export interface NodeAdapterOptions {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znight/http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ugho STEPHAN",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"access": "public"
|
|
80
80
|
},
|
|
81
81
|
"dependencies": {
|
|
82
|
-
"@znight/core": "0.
|
|
82
|
+
"@znight/core": "0.4.0"
|
|
83
83
|
},
|
|
84
84
|
"peerDependencies": {
|
|
85
85
|
"@hono/node-server": "^2.1.5",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"fastify": "^5.12.7",
|
|
88
88
|
"hono": "^4.13.14",
|
|
89
89
|
"light-my-request": "^6.8.0",
|
|
90
|
-
"@znight/compiler": "^0.
|
|
90
|
+
"@znight/compiler": "^0.4.0"
|
|
91
91
|
},
|
|
92
92
|
"peerDependenciesMeta": {
|
|
93
93
|
"@hono/node-server": {
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"valibot": "1.5.0",
|
|
119
119
|
"zod": "4.6.7",
|
|
120
120
|
"light-my-request": "6.8.0",
|
|
121
|
-
"@znight/compiler": "0.
|
|
121
|
+
"@znight/compiler": "0.4.0"
|
|
122
122
|
},
|
|
123
123
|
"znight": {
|
|
124
124
|
"apiVersion": 1,
|
package/src/compiler/analyze.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
DecoratorInfo,
|
|
12
12
|
Location,
|
|
13
13
|
MethodInfo,
|
|
14
|
+
ParamDecoratorUse,
|
|
14
15
|
ParameterInfo,
|
|
15
16
|
StaticValue,
|
|
16
17
|
TypeInfo,
|
|
@@ -813,6 +814,24 @@ function analyzeParams(
|
|
|
813
814
|
const decorators = unique(param.decorators).filter(
|
|
814
815
|
decorator => decorator.owner === EXTENSION_NAME && PARAM_SOURCES.has(decorator.identity ?? ''),
|
|
815
816
|
)
|
|
817
|
+
// custom parameter decorators (RFC 0010); compilers before it have no paramDecorator
|
|
818
|
+
const custom = typeof ctx.paramDecorator === 'function' ? ctx.paramDecorator(param, 'http', cls) : undefined
|
|
819
|
+
if (custom !== undefined) {
|
|
820
|
+
for (const other of decorators) {
|
|
821
|
+
error(
|
|
822
|
+
ctx,
|
|
823
|
+
other.location,
|
|
824
|
+
`Parameter '${param.name}' of ${where} has several parameter decorators (@${custom.decorator.name} and @${other.name})`,
|
|
825
|
+
['Keep a single parameter decorator'],
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
if (custom.ok && decorators.length === 0) {
|
|
829
|
+
params.push(customParam(param, custom))
|
|
830
|
+
} else {
|
|
831
|
+
complete = false
|
|
832
|
+
}
|
|
833
|
+
continue
|
|
834
|
+
}
|
|
816
835
|
const [decorator, ...extra] = decorators
|
|
817
836
|
const foreign = decorator === undefined ? foreignParameterDecorator(param) : undefined
|
|
818
837
|
if (foreign !== undefined) {
|
|
@@ -852,6 +871,30 @@ function analyzeParams(
|
|
|
852
871
|
return { params, complete }
|
|
853
872
|
}
|
|
854
873
|
|
|
874
|
+
/** A custom parameter (`source: 'custom'`, the decorator name as `name`). */
|
|
875
|
+
function customParam(param: ParameterInfo, use: Extract<ParamDecoratorUse, { readonly ok: true }>): HttpParamData {
|
|
876
|
+
const data = use.data
|
|
877
|
+
return {
|
|
878
|
+
index: param.index,
|
|
879
|
+
name: param.name,
|
|
880
|
+
source: 'custom',
|
|
881
|
+
key: use.name,
|
|
882
|
+
type: undefined,
|
|
883
|
+
optional: param.optional || param.hasDefault,
|
|
884
|
+
schema: undefined,
|
|
885
|
+
coercion: undefined,
|
|
886
|
+
custom: {
|
|
887
|
+
identity: use.identity,
|
|
888
|
+
name: use.name,
|
|
889
|
+
data: data === undefined ? undefined : { value: data.ref ?? (data.value as StaticValue) },
|
|
890
|
+
resolver: use.resolver.kind === 'class' ? use.resolver.ref : undefined,
|
|
891
|
+
...(use.resolver.kind === 'class' && use.resolver.arity !== undefined ? { arity: use.resolver.arity } : {}),
|
|
892
|
+
cast: use.cast,
|
|
893
|
+
},
|
|
894
|
+
location: use.location,
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
855
898
|
function analyzeParam(
|
|
856
899
|
ctx: Context,
|
|
857
900
|
cls: ClassInfo,
|
package/src/compiler/generate.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* (README, "Compiler contract"). Controllers are sorted by token, routes keep their source order and
|
|
4
4
|
* every route gets an `invoke(c, ctx)` arrow extracting, coercing and validating its arguments.
|
|
5
5
|
*/
|
|
6
|
-
import type { ClassRef, GenerateContext, GenInput, GenNode, ValueRef } from '@znight/compiler'
|
|
6
|
+
import type { ClassRef, GenerateContext, GenInput, GenNode, StaticValue, ValueRef } from '@znight/compiler'
|
|
7
7
|
import type {
|
|
8
8
|
CoercionPlan,
|
|
9
|
+
CustomParamData,
|
|
9
10
|
HttpControllerData,
|
|
10
11
|
HttpParamData,
|
|
11
12
|
HttpRouteData,
|
|
@@ -17,6 +18,7 @@ import type {
|
|
|
17
18
|
|
|
18
19
|
import { compareStrings } from '../path.ts'
|
|
19
20
|
import { HTTP_PACKAGE } from './model.ts'
|
|
21
|
+
import { isValueRef } from './route-decorators.ts'
|
|
20
22
|
|
|
21
23
|
type Context = GenerateContext<HttpControllerData>
|
|
22
24
|
|
|
@@ -33,6 +35,9 @@ interface Helpers {
|
|
|
33
35
|
isThenable(): GenNode
|
|
34
36
|
uploads(): GenNode
|
|
35
37
|
context(): GenNode
|
|
38
|
+
paramFunctionOf(): GenNode
|
|
39
|
+
/** The resolver instances of the route being generated (`x`), with the index of each token. */
|
|
40
|
+
readonly resolvers?: { readonly x: GenNode; readonly index: ReadonlyMap<string, number> }
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/** Generates `metadata.extensions.http` (`undefined` when the application has no controller). */
|
|
@@ -102,6 +107,7 @@ function createHelpers(ctx: Context): Helpers {
|
|
|
102
107
|
let isThenable: GenNode | undefined
|
|
103
108
|
let uploads: GenNode | undefined
|
|
104
109
|
let context: GenNode | undefined
|
|
110
|
+
let paramFunctionOf: GenNode | undefined
|
|
105
111
|
return {
|
|
106
112
|
coerce: () => {
|
|
107
113
|
coerce ??= ctx.importFrom(HTTP_PACKAGE, 'coerce')
|
|
@@ -123,6 +129,10 @@ function createHelpers(ctx: Context): Helpers {
|
|
|
123
129
|
context ??= ctx.importFrom(HTTP_PACKAGE, 'HttpContext', { type: true })
|
|
124
130
|
return context
|
|
125
131
|
},
|
|
132
|
+
paramFunctionOf: () => {
|
|
133
|
+
paramFunctionOf ??= ctx.importFrom('@znight/core', 'ParamFunctionOf', { type: true })
|
|
134
|
+
return paramFunctionOf
|
|
135
|
+
},
|
|
126
136
|
}
|
|
127
137
|
}
|
|
128
138
|
|
|
@@ -169,7 +179,11 @@ function routeNode(ctx: Context, helpers: Helpers, controller: HttpControllerDat
|
|
|
169
179
|
if (route.sse !== undefined) {
|
|
170
180
|
entries.push(['sse', sseNode(ctx, route.sse)])
|
|
171
181
|
}
|
|
172
|
-
|
|
182
|
+
const resolvers = paramResolvers(route)
|
|
183
|
+
if (resolvers.length > 0) {
|
|
184
|
+
entries.push(['paramResolvers', tokens(ctx, resolvers)])
|
|
185
|
+
}
|
|
186
|
+
entries.push(['invoke', invokeNode(ctx, helpers, controller, route, resolvers)])
|
|
173
187
|
return ctx.obj(entries)
|
|
174
188
|
}
|
|
175
189
|
|
|
@@ -251,12 +265,63 @@ function tokens(ctx: Context, refs: readonly ClassRef[]): GenNode {
|
|
|
251
265
|
* `@Body()` parameter reads the body in `invoke`: that arrow is `async` and `await`s every
|
|
252
266
|
* `validate()`.
|
|
253
267
|
*/
|
|
254
|
-
function invokeNode(
|
|
268
|
+
function invokeNode(
|
|
269
|
+
ctx: Context,
|
|
270
|
+
helpers: Helpers,
|
|
271
|
+
controller: HttpControllerData,
|
|
272
|
+
route: HttpRouteData,
|
|
273
|
+
resolvers: readonly ClassRef[],
|
|
274
|
+
): GenNode {
|
|
255
275
|
const params: [string, GenNode][] = [['c', ctx.importType(controller.identity)]]
|
|
256
|
-
// `ctx` is declared only when used (generated code type-checks with `noUnusedParameters`)
|
|
276
|
+
// `ctx` is declared only when used (generated code type-checks with `noUnusedParameters`), and as
|
|
277
|
+
// `_ctx` before `x` when only class resolvers whose `resolve()` takes no parameter need arguments
|
|
257
278
|
if (route.params.length > 0 || route.bodySchema !== undefined) {
|
|
258
|
-
params.push(['ctx', helpers.context()])
|
|
279
|
+
params.push([readsContext(route) ? 'ctx' : '_ctx', helpers.context()])
|
|
280
|
+
}
|
|
281
|
+
// `x`: the instances of the parameter resolvers (RFC 0010), only for routes that have some
|
|
282
|
+
if (resolvers.length > 0) {
|
|
283
|
+
const tuple = resolvers.map(ref => ctx.importType(ref))
|
|
284
|
+
params.push(['x', ctx.code`readonly [${joinNodes(ctx, tuple)}]`])
|
|
285
|
+
const index = new Map(resolvers.map((ref, position) => [ref.token, position]))
|
|
286
|
+
const withResolvers = (x: GenNode | undefined): Helpers =>
|
|
287
|
+
x === undefined ? helpers : { ...helpers, resolvers: { x, index } }
|
|
288
|
+
return invokeArrow(ctx, params, route, (c, http, x) => [c, http, withResolvers(x)])
|
|
289
|
+
}
|
|
290
|
+
return invokeArrow(ctx, params, route, (c, http) => [c, http, helpers])
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** `true` when an argument (or the body validation) reads the context: all but a `resolve()` without parameter. */
|
|
294
|
+
function readsContext(route: HttpRouteData): boolean {
|
|
295
|
+
return (
|
|
296
|
+
route.bodySchema !== undefined ||
|
|
297
|
+
route.params.some(param => param.custom?.resolver === undefined || param.custom.arity !== 0)
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The provider classes resolving custom parameters, in order of first use (`x[i]`). */
|
|
302
|
+
function paramResolvers(route: HttpRouteData): ClassRef[] {
|
|
303
|
+
const found = new Map<string, ClassRef>()
|
|
304
|
+
for (const param of route.params) {
|
|
305
|
+
const resolver = param.custom?.resolver
|
|
306
|
+
if (resolver !== undefined && !found.has(resolver.token)) {
|
|
307
|
+
found.set(resolver.token, resolver)
|
|
308
|
+
}
|
|
259
309
|
}
|
|
310
|
+
return [...found.values()]
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** `a, b, c`. */
|
|
314
|
+
function joinNodes(ctx: Context, nodes: readonly GenNode[]): GenNode {
|
|
315
|
+
return nodes.slice(1).reduce((joined, node) => ctx.code`${joined}, ${node}`, nodes[0] as GenNode)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The arrow of {@link invokeNode}; `bind` maps the arrow parameters to the controller, the context and the helpers. */
|
|
319
|
+
function invokeArrow(
|
|
320
|
+
ctx: Context,
|
|
321
|
+
params: readonly [string, GenNode][],
|
|
322
|
+
route: HttpRouteData,
|
|
323
|
+
bind: (...params: GenNode[]) => readonly [GenNode, GenNode | undefined, Helpers],
|
|
324
|
+
): GenNode {
|
|
260
325
|
// arguments in evaluation order (handler parameter order); a hole is passed as `undefined`
|
|
261
326
|
const slots: (HttpParamData | undefined)[] = []
|
|
262
327
|
for (const param of route.params) {
|
|
@@ -266,7 +331,8 @@ function invokeNode(ctx: Context, helpers: Helpers, controller: HttpControllerDa
|
|
|
266
331
|
if (bodySchema !== undefined) {
|
|
267
332
|
return ctx.arrow(
|
|
268
333
|
params,
|
|
269
|
-
(
|
|
334
|
+
(...bound) => {
|
|
335
|
+
const [c, http, helpers] = bind(...bound)
|
|
270
336
|
const call = handlerCall(
|
|
271
337
|
ctx,
|
|
272
338
|
c,
|
|
@@ -281,17 +347,26 @@ function invokeNode(ctx: Context, helpers: Helpers, controller: HttpControllerDa
|
|
|
281
347
|
{ async: true },
|
|
282
348
|
)
|
|
283
349
|
}
|
|
284
|
-
const last = slots.findLastIndex(param => param
|
|
285
|
-
return ctx.arrow(params, (
|
|
286
|
-
|
|
350
|
+
const last = slots.findLastIndex(param => param !== undefined && isDeferred(param))
|
|
351
|
+
return ctx.arrow(params, (...bound) => {
|
|
352
|
+
const [c, http, helpers] = bind(...bound)
|
|
353
|
+
return last === -1
|
|
287
354
|
? handlerCall(
|
|
288
355
|
ctx,
|
|
289
356
|
c,
|
|
290
357
|
route,
|
|
291
358
|
Array.from(slots, param => plainArgument(ctx, helpers, http as GenNode, param)),
|
|
292
359
|
)
|
|
293
|
-
: validatingBlock(ctx, helpers, route, slots, last, c, http as GenNode)
|
|
294
|
-
)
|
|
360
|
+
: validatingBlock(ctx, helpers, route, slots, last, c, http as GenNode)
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* `true` for an argument whose expression may return a thenable: a validation with a schema, or a
|
|
366
|
+
* custom parameter (its resolver may return a promise).
|
|
367
|
+
*/
|
|
368
|
+
function isDeferred(param: HttpParamData): boolean {
|
|
369
|
+
return param.schema !== undefined || param.custom !== undefined
|
|
295
370
|
}
|
|
296
371
|
|
|
297
372
|
/** `c.handler(args)`. */
|
|
@@ -346,7 +421,7 @@ function validatingBlock(
|
|
|
346
421
|
const param = slots[index] as HttpParamData
|
|
347
422
|
const name = local.get(index) as GenNode
|
|
348
423
|
statements.push(ctx.code`const ${name} = ${plainArgument(ctx, helpers, http, param)}`)
|
|
349
|
-
if (param
|
|
424
|
+
if (isDeferred(param)) {
|
|
350
425
|
const rest = Array.from(slots, (other, position) => {
|
|
351
426
|
if (position < index) {
|
|
352
427
|
return evaluated(position)
|
|
@@ -369,7 +444,7 @@ function validatingBlock(
|
|
|
369
444
|
/** An argument evaluated in an `async` arrow: its validation, if any, is `await`ed. */
|
|
370
445
|
function awaitedArgument(ctx: Context, helpers: Helpers, http: GenNode, param: HttpParamData | undefined): GenNode {
|
|
371
446
|
const node = plainArgument(ctx, helpers, http, param)
|
|
372
|
-
return param
|
|
447
|
+
return param === undefined || !isDeferred(param) ? node : ctx.code`await ${node}`
|
|
373
448
|
}
|
|
374
449
|
|
|
375
450
|
/** An argument as an expression (`validate()` not awaited); `undefined` for a hole. */
|
|
@@ -435,9 +510,43 @@ function argumentNode(ctx: Context, helpers: Helpers, http: GenNode, param: Http
|
|
|
435
510
|
}
|
|
436
511
|
return coercion(ctx, helpers, param.coercion ?? { plan: { kind: 'raw' }, cast: false }, value, label)
|
|
437
512
|
}
|
|
513
|
+
case 'custom':
|
|
514
|
+
return customArgument(ctx, helpers, http, param.custom as CustomParamData)
|
|
438
515
|
}
|
|
439
516
|
}
|
|
440
517
|
|
|
518
|
+
/**
|
|
519
|
+
* A custom parameter (RFC 0010): `(D.resolvers.http as ParamFunctionOf<typeof D, 'http'>)(ctx, data)`
|
|
520
|
+
* for a function resolver, `x[i].resolve(ctx, data)` for a class resolver; cast with `as never` when
|
|
521
|
+
* the value type could not be checked against the parameter.
|
|
522
|
+
*/
|
|
523
|
+
function customArgument(ctx: Context, helpers: Helpers, http: GenNode, custom: CustomParamData): GenNode {
|
|
524
|
+
const data = custom.data === undefined ? ctx.lit(undefined) : dataNode(ctx, custom.data.value)
|
|
525
|
+
let call: GenNode
|
|
526
|
+
if (custom.resolver === undefined) {
|
|
527
|
+
const decorator = ctx.importValue(custom.identity)
|
|
528
|
+
const callee = ctx.code`${ctx.member(decorator, 'resolvers', 'http')} as ${helpers.paramFunctionOf()}<typeof ${decorator}, 'http'>`
|
|
529
|
+
call = ctx.call(callee, [http, data])
|
|
530
|
+
} else {
|
|
531
|
+
const resolvers = helpers.resolvers as NonNullable<Helpers['resolvers']>
|
|
532
|
+
const index = resolvers.index.get(custom.resolver.token) as number
|
|
533
|
+
if (custom.arity === undefined) {
|
|
534
|
+
// a `resolve` the compiler cannot read (inherited from a mixin...): the instance is cast to a
|
|
535
|
+
// method taking both arguments (method parameters are bivariant), the value with `as never`
|
|
536
|
+
return ctx.code`(${resolvers.x}[${index}] as { resolve(ctx: unknown, data: unknown): unknown }).resolve(${http}, ${data}) as never`
|
|
537
|
+
}
|
|
538
|
+
// a `resolve(ctx)` that ignores the data gets no data argument (TypeScript checks the arity)
|
|
539
|
+
const args = [http, data].slice(0, custom.arity)
|
|
540
|
+
call = ctx.code`${resolvers.x}[${index}].resolve(${args.length === 0 ? ctx.code`` : joinNodes(ctx, args)})`
|
|
541
|
+
}
|
|
542
|
+
return custom.cast ? ctx.code`${call} as never` : call
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** The data argument of a custom parameter: a literal (references imported) or a reference. */
|
|
546
|
+
function dataNode(ctx: Context, value: StaticValue): GenNode {
|
|
547
|
+
return isValueRef(value) ? ctx.ref(value) : ctx.lit(value as Parameters<Context['lit']>[0])
|
|
548
|
+
}
|
|
549
|
+
|
|
441
550
|
/** `validate(schema, value, location)` (the value, or a promise when the schema validates asynchronously). */
|
|
442
551
|
function validation(ctx: Context, helpers: Helpers, schema: ValueRef, value: GenNode, location: string): GenNode {
|
|
443
552
|
return ctx.call(helpers.validate(), [ctx.ref(schema), value, location])
|
package/src/compiler/model.ts
CHANGED
|
@@ -150,6 +150,25 @@ export interface UploadData {
|
|
|
150
150
|
readonly cast: boolean
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** A custom parameter (`createParamDecorator`, RFC 0010). */
|
|
154
|
+
export interface CustomParamData {
|
|
155
|
+
/** Canonical identity of the decorator `const` (imported by the generated module). */
|
|
156
|
+
readonly identity: string
|
|
157
|
+
/** Name of the decorator (`CurrentUser`). */
|
|
158
|
+
readonly name: string
|
|
159
|
+
/** The data argument: evaluated value (it may contain references) or a reference; `undefined` without argument. */
|
|
160
|
+
readonly data: { readonly value: StaticValue } | undefined
|
|
161
|
+
/** The class resolver (`x[i].resolve(ctx, data)`); `undefined` for a function resolver. */
|
|
162
|
+
readonly resolver: ClassRef | undefined
|
|
163
|
+
/**
|
|
164
|
+
* Parameters of the class resolver's `resolve` (own, inherited or an arrow function property);
|
|
165
|
+
* `undefined` for a function resolver, or when the compiler cannot read it (the call is cast).
|
|
166
|
+
*/
|
|
167
|
+
readonly arity?: number
|
|
168
|
+
/** The value type could not be checked against the parameter: `as never`. */
|
|
169
|
+
readonly cast: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
153
172
|
/** A handler parameter. */
|
|
154
173
|
export interface HttpParamData {
|
|
155
174
|
/** Position in the handler signature. */
|
|
@@ -169,6 +188,8 @@ export interface HttpParamData {
|
|
|
169
188
|
readonly coercion: ParamCoercion | undefined
|
|
170
189
|
/** Upload parameter (`source` `file` / `files`). */
|
|
171
190
|
readonly upload?: UploadData
|
|
191
|
+
/** Custom parameter (`source` `custom`). */
|
|
192
|
+
readonly custom?: CustomParamData
|
|
172
193
|
/**
|
|
173
194
|
* The default value of the handler parameter (`@Query('page') page = 1` → `1`), when it is
|
|
174
195
|
* statically evaluable (literals, `const` references; references to exported bindings are left
|
package/src/context.ts
CHANGED
|
@@ -63,6 +63,13 @@ export interface HttpRouteInfo {
|
|
|
63
63
|
readonly meta: Readonly<Record<string, unknown>>
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
declare module '@znight/core' {
|
|
67
|
+
/** Custom parameter decorators (`createParamDecorator`) resolve HTTP parameters from the {@link HttpContext}. */
|
|
68
|
+
interface ParamContexts {
|
|
69
|
+
http: HttpContext
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
/**
|
|
67
74
|
* Adapter-neutral view of the current HTTP request, shared by guards, interceptors, exception
|
|
68
75
|
* mappers and handlers (`@Ctx()`), and injectable in request-scoped providers.
|
package/src/metadata.ts
CHANGED
|
@@ -26,6 +26,8 @@ export type HttpParamSource =
|
|
|
26
26
|
| 'files'
|
|
27
27
|
| 'cookie'
|
|
28
28
|
| 'signedCookie'
|
|
29
|
+
/** A custom parameter decorator (`createParamDecorator`, RFC 0010); `name` is the decorator's name. */
|
|
30
|
+
| 'custom'
|
|
29
31
|
|
|
30
32
|
/** A handler parameter (informational: extraction, coercion and validation happen in `invoke`). */
|
|
31
33
|
export interface HttpParamDefinition {
|
|
@@ -114,11 +116,18 @@ export interface HttpRouteDefinition {
|
|
|
114
116
|
*/
|
|
115
117
|
readonly sse?: HttpSseDefinition
|
|
116
118
|
/**
|
|
117
|
-
*
|
|
118
|
-
* the
|
|
119
|
+
* Provider classes resolving custom parameters (`ParamResolver`, RFC 0010), in order of first use:
|
|
120
|
+
* the plugin resolves them in the request scope and passes the instances to `invoke` as `x`
|
|
121
|
+
* (`x[i]` is the instance of `paramResolvers[i]`). Absent when the route has none.
|
|
119
122
|
*/
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
readonly paramResolvers?: readonly Token[]
|
|
124
|
+
/**
|
|
125
|
+
* Calls the handler: extracts, coerces and validates every argument from the context (custom
|
|
126
|
+
* parameters included), then calls the method on the controller instance. May return a promise.
|
|
127
|
+
* `resolvers` is passed when the route has {@link paramResolvers}.
|
|
128
|
+
*/
|
|
129
|
+
// biome-ignore lint/suspicious/noExplicitAny: generated functions take the concrete controller class and resolver tuple
|
|
130
|
+
readonly invoke: (controller: any, ctx: HttpContext, resolvers?: any) => unknown
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
/** A controller: a provider whose methods are routes. */
|
package/src/pipeline.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Container, CoreLogger, Hooks, HttpRequestEvent, OpenedScope, Token } from '@znight/core'
|
|
2
2
|
import type { AdapterHandler, AdapterReply, AdapterRequest, ReplyBody } from './adapter.ts'
|
|
3
|
+
import type { HttpContext } from './context.ts'
|
|
3
4
|
import type { CookieSettings } from './cookie-settings.ts'
|
|
4
5
|
import type { SerializedCookie } from './cookies.ts'
|
|
5
6
|
import type { CorsPolicy } from './cors.ts'
|
|
@@ -53,6 +54,11 @@ export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
|
|
|
53
54
|
/** Everything the pipeline needs to serve one registered route (internal). */
|
|
54
55
|
export interface RoutePlan {
|
|
55
56
|
readonly definition: HttpRouteDefinition
|
|
57
|
+
/**
|
|
58
|
+
* Calls the handler: `definition.invoke`, or for a route with parameter resolvers a function
|
|
59
|
+
* passing their instances (resolved in the request scope) as the third argument.
|
|
60
|
+
*/
|
|
61
|
+
readonly invoke: (controller: object, ctx: HttpContext) => unknown
|
|
56
62
|
/** The registered path (a trailing-slash variant has an extra empty static segment). */
|
|
57
63
|
readonly path: RoutePath
|
|
58
64
|
readonly info: HttpRouteInfo
|
|
@@ -436,7 +442,7 @@ export class Pipeline {
|
|
|
436
442
|
let result: unknown
|
|
437
443
|
let reply: AdapterReply | undefined
|
|
438
444
|
try {
|
|
439
|
-
result = plan.
|
|
445
|
+
result = plan.invoke(this.#resolve(plan.controller), ctx)
|
|
440
446
|
if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime)
|
|
441
447
|
reply = this.#fastReply(route, ctx, result)
|
|
442
448
|
} catch (error) {
|
|
@@ -480,7 +486,7 @@ export class Pipeline {
|
|
|
480
486
|
if (result === NO_RESULT) {
|
|
481
487
|
const controller = this.#resolve(plan.controller)
|
|
482
488
|
await ctx.parseBody()
|
|
483
|
-
result = plan.
|
|
489
|
+
result = plan.invoke(controller, ctx)
|
|
484
490
|
}
|
|
485
491
|
if (isThenable(result)) result = await result
|
|
486
492
|
reply = this.#fastReply(route, ctx, result)
|
|
@@ -584,7 +590,7 @@ export class Pipeline {
|
|
|
584
590
|
if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT)
|
|
585
591
|
let result: unknown
|
|
586
592
|
try {
|
|
587
|
-
result = plan.
|
|
593
|
+
result = plan.invoke(this.#resolve(plan.controller), ctx)
|
|
588
594
|
} catch (error) {
|
|
589
595
|
return this.#fastChecked(x, this.#failed(error, ctx, x.event))
|
|
590
596
|
}
|
|
@@ -601,7 +607,7 @@ export class Pipeline {
|
|
|
601
607
|
if (result === NO_RESULT) {
|
|
602
608
|
const controller = this.#resolve(plan.controller)
|
|
603
609
|
await ctx.parseBody()
|
|
604
|
-
result = plan.
|
|
610
|
+
result = plan.invoke(controller, ctx)
|
|
605
611
|
}
|
|
606
612
|
if (isThenable(result)) result = await result
|
|
607
613
|
} catch (error) {
|
|
@@ -1235,13 +1241,12 @@ export class Pipeline {
|
|
|
1235
1241
|
if (allowed !== true) throw new ForbiddenException('Forbidden resource')
|
|
1236
1242
|
}
|
|
1237
1243
|
if (plan.parseBody) await ctx.parseBody()
|
|
1238
|
-
const definition = plan.definition
|
|
1239
1244
|
const interceptors = plan.interceptors
|
|
1240
1245
|
if (interceptors.length === 0) {
|
|
1241
|
-
result =
|
|
1246
|
+
result = plan.invoke(controller, ctx)
|
|
1242
1247
|
} else {
|
|
1243
1248
|
const chain = async (index: number): Promise<unknown> => {
|
|
1244
|
-
if (index === interceptors.length) return await
|
|
1249
|
+
if (index === interceptors.length) return await plan.invoke(controller, ctx)
|
|
1245
1250
|
const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
|
|
1246
1251
|
return await interceptor.intercept(ctx, () => chain(index + 1))
|
|
1247
1252
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -969,6 +969,7 @@ function buildRegistrations(
|
|
|
969
969
|
const body = bodyPlan(route, options.bodyLimit, options.uploads)
|
|
970
970
|
const parseBody = body.upload || route.params.some(param => param.source === 'body')
|
|
971
971
|
const chain = guards.map(guard => guard.resolvable)
|
|
972
|
+
const invoke = routeInvoke(container, route, lookup, where)
|
|
972
973
|
// computed once every group is declared (guards of every chain, plugin routes), then frozen
|
|
973
974
|
let meta: RouteMeta | undefined
|
|
974
975
|
const effective = (): RouteMeta => {
|
|
@@ -981,6 +982,7 @@ function buildRegistrations(
|
|
|
981
982
|
path,
|
|
982
983
|
plan: (method: string): RoutePlan => ({
|
|
983
984
|
definition: route,
|
|
985
|
+
invoke,
|
|
984
986
|
path,
|
|
985
987
|
info: Object.freeze({
|
|
986
988
|
method,
|
|
@@ -1052,6 +1054,7 @@ function buildRegistrations(
|
|
|
1052
1054
|
path,
|
|
1053
1055
|
plan: (method: string): RoutePlan => ({
|
|
1054
1056
|
definition,
|
|
1057
|
+
invoke: definition.invoke,
|
|
1055
1058
|
path,
|
|
1056
1059
|
info: Object.freeze({ method, path: path.path, controller: route.plugin, handler: route.name, meta }),
|
|
1057
1060
|
controller: instance,
|
|
@@ -1264,6 +1267,9 @@ function assertRoute(route: HttpRouteDefinition, where: string): void {
|
|
|
1264
1267
|
}
|
|
1265
1268
|
if (route.uploads !== undefined) assertUploads(route, where)
|
|
1266
1269
|
if (route.sse !== undefined) assertSse(route, where)
|
|
1270
|
+
if (route.paramResolvers !== undefined && !Array.isArray(route.paramResolvers)) {
|
|
1271
|
+
throw new ZnightError(ErrorCode.InvalidRoute, `Invalid route ${where}: paramResolvers must be an array of tokens`)
|
|
1272
|
+
}
|
|
1267
1273
|
}
|
|
1268
1274
|
|
|
1269
1275
|
function assertSse(route: HttpRouteDefinition, where: string): void {
|
|
@@ -1340,6 +1346,33 @@ function describeValue(value: unknown): string {
|
|
|
1340
1346
|
// Provider resolution
|
|
1341
1347
|
// -------------------------------------------------------------------------------------------------
|
|
1342
1348
|
|
|
1349
|
+
/**
|
|
1350
|
+
* The `invoke` of a route plan: the generated `invoke` itself, unless the route has parameter
|
|
1351
|
+
* resolvers (RFC 0010), whose instances are passed as its third argument: one frozen array resolved
|
|
1352
|
+
* at bootstrap when they are all singletons, otherwise an array resolved in the request scope.
|
|
1353
|
+
*/
|
|
1354
|
+
function routeInvoke(
|
|
1355
|
+
container: Container,
|
|
1356
|
+
route: HttpRouteDefinition,
|
|
1357
|
+
lookup: <T>(token: Token, what: string) => Resolvable<T>,
|
|
1358
|
+
where: string,
|
|
1359
|
+
): RoutePlan['invoke'] {
|
|
1360
|
+
const invoke = route.invoke
|
|
1361
|
+
const tokens = route.paramResolvers
|
|
1362
|
+
if (tokens === undefined || tokens.length === 0) return invoke
|
|
1363
|
+
const resolvers = tokens.map(token => lookup<object>(token, `parameter resolver of ${where}`))
|
|
1364
|
+
if (resolvers.every(resolver => 'instance' in resolver)) {
|
|
1365
|
+
const instances = Object.freeze(resolvers.map(resolver => (resolver as { readonly instance: object }).instance))
|
|
1366
|
+
return (controller, ctx) => invoke(controller, ctx, instances)
|
|
1367
|
+
}
|
|
1368
|
+
return (controller, ctx) =>
|
|
1369
|
+
invoke(
|
|
1370
|
+
controller,
|
|
1371
|
+
ctx,
|
|
1372
|
+
resolvers.map(resolver => ('instance' in resolver ? resolver.instance : container.get(resolver.token))),
|
|
1373
|
+
)
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1343
1376
|
/**
|
|
1344
1377
|
* Singletons (and registered values) are resolved once at bootstrap; request-scoped and transient
|
|
1345
1378
|
* providers on every request.
|