@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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Data model of the `@znight/http` compiler extension: canonical identities of the decorators and
3
+ * the data stored during analysis (`ctx.store()`), read back by `validate()` and `generate()`.
4
+ *
5
+ * This module is pure (types and constants only) so that the compiler entry stays free of runtime
6
+ * dependencies.
7
+ */
8
+ import type { ClassRef, Location, StaticValue, ValueRef } from '@znight/compiler'
9
+ import type { HttpMethod, HttpParamSource } from '../metadata.ts'
10
+
11
+ /** Package name (prefix of every decorator identity). */
12
+ export const HTTP_PACKAGE = '@znight/http'
13
+
14
+ /** Canonical identities of the decorators of `@znight/http`. */
15
+ export const Ids: {
16
+ readonly Controller: string
17
+ readonly Param: string
18
+ readonly Query: string
19
+ readonly Body: string
20
+ readonly Headers: string
21
+ readonly Req: string
22
+ readonly Res: string
23
+ readonly Ctx: string
24
+ readonly HttpCode: string
25
+ readonly Header: string
26
+ readonly UseGuard: string
27
+ readonly UseInterceptor: string
28
+ readonly Validate: string
29
+ readonly UploadedFile: string
30
+ readonly UploadedFiles: string
31
+ readonly BodyLimit: string
32
+ readonly Cookie: string
33
+ readonly SignedCookie: string
34
+ readonly Sse: string
35
+ readonly createRouteDecorator: string
36
+ readonly RouteDecorator: string
37
+ } = Object.freeze({
38
+ Controller: '@znight/http#Controller',
39
+ Param: '@znight/http#Param',
40
+ Query: '@znight/http#Query',
41
+ Body: '@znight/http#Body',
42
+ Headers: '@znight/http#Headers',
43
+ Req: '@znight/http#Req',
44
+ Res: '@znight/http#Res',
45
+ Ctx: '@znight/http#Ctx',
46
+ HttpCode: '@znight/http#HttpCode',
47
+ Header: '@znight/http#Header',
48
+ UseGuard: '@znight/http#UseGuard',
49
+ UseInterceptor: '@znight/http#UseInterceptor',
50
+ Validate: '@znight/http#Validate',
51
+ UploadedFile: '@znight/http#UploadedFile',
52
+ UploadedFiles: '@znight/http#UploadedFiles',
53
+ BodyLimit: '@znight/http#BodyLimit',
54
+ Cookie: '@znight/http#Cookie',
55
+ SignedCookie: '@znight/http#SignedCookie',
56
+ Sse: '@znight/http#Sse',
57
+ createRouteDecorator: '@znight/http#createRouteDecorator',
58
+ RouteDecorator: '@znight/http#RouteDecorator',
59
+ })
60
+
61
+ /** Route method decorators → HTTP method (`@Sse` declares a `GET` route). */
62
+ export const ROUTE_METHODS: ReadonlyMap<string, HttpMethod> = new Map<string, HttpMethod>([
63
+ ['@znight/http#Get', 'GET'],
64
+ ['@znight/http#Post', 'POST'],
65
+ ['@znight/http#Put', 'PUT'],
66
+ ['@znight/http#Patch', 'PATCH'],
67
+ ['@znight/http#Delete', 'DELETE'],
68
+ ['@znight/http#Head', 'HEAD'],
69
+ ['@znight/http#Options', 'OPTIONS'],
70
+ ['@znight/http#All', 'ALL'],
71
+ ['@znight/http#Sse', 'GET'],
72
+ ])
73
+
74
+ /** Parameter decorators → parameter source. */
75
+ export const PARAM_SOURCES: ReadonlyMap<string, HttpParamSource> = new Map<string, HttpParamSource>([
76
+ [Ids.Param, 'param'],
77
+ [Ids.Query, 'query'],
78
+ [Ids.Body, 'body'],
79
+ [Ids.Headers, 'headers'],
80
+ [Ids.Req, 'request'],
81
+ [Ids.Res, 'response'],
82
+ [Ids.Ctx, 'context'],
83
+ [Ids.UploadedFile, 'file'],
84
+ [Ids.UploadedFiles, 'files'],
85
+ [Ids.Cookie, 'cookie'],
86
+ [Ids.SignedCookie, 'signedCookie'],
87
+ ])
88
+
89
+ /** Error codes used by the extension (see `ErrorCode` in `@znight/core`). */
90
+ export const Codes: {
91
+ readonly MissingProvider: 'ZNIGHT001'
92
+ readonly InvalidDecorator: 'ZNIGHT005'
93
+ readonly InvalidRoute: 'ZNIGHT007'
94
+ readonly UnvalidatedInput: 'ZNIGHT017'
95
+ readonly MissingRouteDecision: 'ZNIGHT023'
96
+ } = Object.freeze({
97
+ MissingProvider: 'ZNIGHT001',
98
+ InvalidDecorator: 'ZNIGHT005',
99
+ InvalidRoute: 'ZNIGHT007',
100
+ UnvalidatedInput: 'ZNIGHT017',
101
+ MissingRouteDecision: 'ZNIGHT023',
102
+ })
103
+
104
+ /**
105
+ * Grammar of an explicit route decorator group name (same as the runtime `createRouteDecorator`).
106
+ * A decorator without `group` is its own group, named after its key (any non-empty key).
107
+ */
108
+ export const GROUP_NAME: RegExp = /^[A-Za-z][\w.-]*$/
109
+
110
+ /** Primitive coercions (`coerce.<kind>`). */
111
+ export type ScalarCoercion = 'string' | 'number' | 'integer' | 'boolean' | 'bigint' | 'date'
112
+
113
+ /**
114
+ * How a raw value (route parameter, query value, header) is converted to the declared type of a
115
+ * handler parameter.
116
+ */
117
+ export type CoercionPlan =
118
+ | { readonly kind: ScalarCoercion }
119
+ | { readonly kind: 'enum'; readonly values: readonly (string | number | boolean)[] }
120
+ | { readonly kind: 'array'; readonly item: CoercionPlan }
121
+ | { readonly kind: 'optional'; readonly item: CoercionPlan }
122
+ /** No conversion: the raw value is passed as is (`unknown` / `any` parameters). */
123
+ | { readonly kind: 'raw' }
124
+
125
+ /** The coercion of a handler parameter. */
126
+ export interface ParamCoercion {
127
+ readonly plan: CoercionPlan
128
+ /**
129
+ * The coerced value does not have the declared type (branded primitives, enums, `null` without
130
+ * `undefined`): the generated expression is cast with `as never`.
131
+ */
132
+ readonly cast: boolean
133
+ }
134
+
135
+ /** An upload parameter (`@UploadedFile` / `@UploadedFiles`). */
136
+ export interface UploadData {
137
+ /** Multipart field; `undefined`: any field. */
138
+ readonly field: string | undefined
139
+ /** `@UploadedFiles`. */
140
+ readonly multiple: boolean
141
+ /** A file must be sent (`required` option, else the parameter is not optional). */
142
+ readonly required: boolean
143
+ /** `maxSize` in bytes (`Infinity` allowed). */
144
+ readonly maxSize: number | undefined
145
+ /** `maxFiles` (`@UploadedFiles` only). */
146
+ readonly maxFiles: number | undefined
147
+ /** `mimeTypes` patterns, as written. */
148
+ readonly mimeTypes: readonly string[] | undefined
149
+ /** The injected value does not have the declared type (`node:buffer` File, `File | null`): `as never`. */
150
+ readonly cast: boolean
151
+ }
152
+
153
+ /** A handler parameter. */
154
+ export interface HttpParamData {
155
+ /** Position in the handler signature. */
156
+ readonly index: number
157
+ /** Parameter name (messages). */
158
+ readonly name: string
159
+ readonly source: HttpParamSource
160
+ /** Route parameter name, query key, lower-case header name or cookie name; `undefined` for the whole object. */
161
+ readonly key: string | undefined
162
+ /** Declared (or inferred) type, formatted for humans. */
163
+ readonly type: string | undefined
164
+ /** The parameter accepts `undefined` (`?`, `| undefined`, default value). */
165
+ readonly optional: boolean
166
+ /** Standard Schema validating the value (its output is injected). */
167
+ readonly schema: ValueRef | undefined
168
+ /** Coercion of a named value without schema. */
169
+ readonly coercion: ParamCoercion | undefined
170
+ /** Upload parameter (`source` `file` / `files`). */
171
+ readonly upload?: UploadData
172
+ /**
173
+ * The default value of the handler parameter (`@Query('page') page = 1` → `1`), when it is
174
+ * statically evaluable (literals, `const` references; references to exported bindings are left
175
+ * out). Informational (documentation): the handler applies its own default.
176
+ */
177
+ readonly defaultValue?: StaticValue
178
+ /** Location of the parameter decorator. */
179
+ readonly location: Location
180
+ }
181
+
182
+ /** A `createRouteDecorator` value (`route.meta[key]`) and the definition of its decorator. */
183
+ export interface MetaEntry {
184
+ readonly key: string
185
+ /** Statically evaluated value; may be or contain references to exported bindings. */
186
+ readonly value: StaticValue
187
+ /** Location of the decorator usage. */
188
+ readonly location: Location
189
+ /** Decision group (`options.group ?? key`). */
190
+ readonly group: string
191
+ /** The group is required (`options.required`). */
192
+ readonly required: boolean
193
+ /** Display name of the decorator (`Roles`). */
194
+ readonly decorator: string
195
+ /** Canonical identity of the decorator `const` (`src/auth/access.ts#Roles`). */
196
+ readonly identity: string
197
+ /** Location of the decorator definition (`export const Roles = createRouteDecorator(...)`). */
198
+ readonly definition: Location
199
+ }
200
+
201
+ /** Options of an `@Sse` route (`HttpRouteDefinition.sse`). */
202
+ export interface SseRouteData {
203
+ /** Heartbeat in ms; `0` disables it (`heartbeat: false`). Absent: the plugin default. */
204
+ readonly heartbeat?: number
205
+ readonly retry?: number
206
+ readonly maxDuration?: number
207
+ }
208
+
209
+ /** A route: one route decorator of a handler. */
210
+ export interface HttpRouteData {
211
+ readonly method: HttpMethod
212
+ /** Path of the route decorator as written (`undefined` when absent). */
213
+ readonly rawPath: string
214
+ /** `normalizePath(rawPath)`. */
215
+ readonly path: string
216
+ /** `joinPaths(controller base path, path)`. */
217
+ readonly fullPath: string
218
+ readonly handler: string
219
+ readonly status: number | undefined
220
+ /** `@Header` entries, lower-case names, in source order. */
221
+ readonly headers: readonly (readonly [string, string])[]
222
+ readonly meta: readonly MetaEntry[]
223
+ readonly guards: readonly ClassRef[]
224
+ readonly interceptors: readonly ClassRef[]
225
+ readonly params: readonly HttpParamData[]
226
+ /** `false` when a parameter could not be analysed (an error was reported; it is missing from `params`). */
227
+ readonly complete: boolean
228
+ /** `@Validate(S)` on a handler without `@Body()` parameter: the body is validated and discarded. */
229
+ readonly bodySchema: ValueRef | undefined
230
+ /** `@BodyLimit` of the method (or of the controller), in bytes. */
231
+ readonly bodyLimit: number | undefined
232
+ readonly rawResponse: boolean
233
+ /** `@Sse` route: its options (`{}` without options). */
234
+ readonly sse?: SseRouteData
235
+ /** Location of the route decorator. */
236
+ readonly location: Location
237
+ /** Location of the path argument (the decorator when absent). */
238
+ readonly pathLocation: Location
239
+ }
240
+
241
+ /** A controller (data stored by `analyzeClass`). */
242
+ export interface HttpControllerData {
243
+ /** Provider token of the controller. */
244
+ readonly token: string
245
+ /** Canonical identity of the class (imported as a type by `invoke`). */
246
+ readonly identity: string
247
+ readonly displayName: string
248
+ /** Base path as written. */
249
+ readonly rawBasePath: string
250
+ /** `normalizePath(rawBasePath)`. */
251
+ readonly basePath: string
252
+ readonly guards: readonly ClassRef[]
253
+ readonly interceptors: readonly ClassRef[]
254
+ readonly meta: readonly MetaEntry[]
255
+ readonly routes: readonly HttpRouteData[]
256
+ /** Location of `@Controller`. */
257
+ readonly location: Location
258
+ /** Location of the base path argument (the decorator when absent). */
259
+ readonly pathLocation: Location
260
+ }
@@ -0,0 +1,405 @@
1
+ /**
2
+ * User-defined route metadata decorators (`createRouteDecorator<T>(key, { group?, required? })`).
3
+ *
4
+ * A decorator is a route decorator when its callee is a `const` initialised with a call to
5
+ * `@znight/http#createRouteDecorator` whose first argument (the key) and optional second argument
6
+ * (the options) are statically evaluable. Its argument is evaluated like a `value` decorator
7
+ * argument (literals, `const` references, references to exported bindings); without argument the
8
+ * value is `true`. Decorators of one group are alternatives: one per controller or route.
9
+ */
10
+ import type {
11
+ AnalyzeContext,
12
+ ClassInfo,
13
+ DecoratorInfo,
14
+ Diagnostic,
15
+ Location,
16
+ StaticValue,
17
+ ValueRef,
18
+ } from '@znight/compiler'
19
+ import type { HttpControllerData, MetaEntry } from './model.ts'
20
+
21
+ import { type AstNode, child, children, isNode, unwrap } from './ast.ts'
22
+ import { Codes, GROUP_NAME, Ids } from './model.ts'
23
+
24
+ type Context = AnalyzeContext<HttpControllerData>
25
+ type Project = Context['project']
26
+ type ScopeInput = Parameters<Project['evaluate']>[0]
27
+ type ExpressionInput = Parameters<Project['evaluate']>[1]
28
+ type EntityName = Parameters<Project['resolveIdentifier']>[1]
29
+
30
+ /** A route decorator definition: `export const Roles = createRouteDecorator<T>(key, options)`. */
31
+ export interface RouteDecoratorDefinition {
32
+ readonly kind: 'route'
33
+ readonly key: string
34
+ /** `options.group ?? key`. */
35
+ readonly group: string
36
+ readonly required: boolean
37
+ /** Name of the `const` (`Roles`). */
38
+ readonly name: string
39
+ /** Location of the `const` name. */
40
+ readonly definition: Location
41
+ }
42
+
43
+ /** What a decorator identity refers to. */
44
+ export type RouteDecoratorResolution =
45
+ | RouteDecoratorDefinition
46
+ | { readonly kind: 'other' }
47
+ | {
48
+ readonly kind: 'invalid'
49
+ readonly message: string
50
+ readonly hints: readonly string[]
51
+ /** The definition, when the problem is there. */
52
+ readonly definition?: Location
53
+ }
54
+
55
+ const OTHER: RouteDecoratorResolution = Object.freeze({ kind: 'other' })
56
+
57
+ /** Resolutions per analysis context (one context per compile). */
58
+ const caches = new WeakMap<object, Map<string, RouteDecoratorResolution>>()
59
+
60
+ /** Resolves a decorator identity to a route decorator definition (memoized per compile). */
61
+ export function resolveRouteDecorator(ctx: Context, identity: string): RouteDecoratorResolution {
62
+ let cache = caches.get(ctx)
63
+ if (!cache) {
64
+ cache = new Map()
65
+ caches.set(ctx, cache)
66
+ }
67
+ let resolution = cache.get(identity)
68
+ if (!resolution) {
69
+ resolution = resolve(ctx, identity)
70
+ cache.set(identity, resolution)
71
+ }
72
+ return resolution
73
+ }
74
+
75
+ function resolve(ctx: Context, identity: string): RouteDecoratorResolution {
76
+ if (identity.startsWith('globalThis#')) {
77
+ return OTHER
78
+ }
79
+ const symbol = ctx.project.lookupIdentity(identity)
80
+ if (symbol.kind !== 'declaration') {
81
+ return OTHER
82
+ }
83
+ const declaration = symbol.declarations.find(candidate => candidate.kind === 'const')
84
+ const node = declaration?.node as unknown
85
+ const file = declaration ? ctx.project.getFile(declaration.file) : undefined
86
+ if (!declaration || !isNode(node) || !file) {
87
+ return OTHER
88
+ }
89
+ const scope = { file, namespace: declaration.namespace } as unknown as ScopeInput
90
+ const init = child(node, 'init')
91
+ if (!init) {
92
+ // `export declare const Roles: RouteDecorator<string[]>` (declaration file): the key is lost
93
+ const annotation = child(child(node, 'id') ?? node, 'typeAnnotation')
94
+ const type = annotation
95
+ ? ctx.project.resolveType(scope, annotation as unknown as Parameters<Project['resolveType']>[1])
96
+ : undefined
97
+ if (type?.kind === 'reference' && type.identity === Ids.RouteDecorator) {
98
+ return {
99
+ kind: 'invalid',
100
+ message: `the key of the route decorator ${declaration.name} cannot be read: it is declared without initializer`,
101
+ hints: [
102
+ 'Route decorators must be declared in analysed sources: `export const X = createRouteDecorator(key)`',
103
+ 'For a provider library, publish its sources ("znight": { "library": "source" })',
104
+ ],
105
+ }
106
+ }
107
+ return OTHER
108
+ }
109
+ const call = unwrap(init)
110
+ const callee = call.type === 'CallExpression' ? child(call, 'callee') : undefined
111
+ if (!callee || !isEntityName(unwrap(callee))) {
112
+ return OTHER
113
+ }
114
+ const resolved = ctx.project.resolveIdentifier(scope, unwrap(callee) as unknown as EntityName, 'value')
115
+ if (resolved.identity?.identity !== Ids.createRouteDecorator) {
116
+ return OTHER
117
+ }
118
+ const definition = file.location(declaration.span)
119
+ const [keyNode, optionsNode, ...extra] = children(call, 'arguments')
120
+ if (!keyNode || keyNode.type === 'SpreadElement') {
121
+ return {
122
+ kind: 'invalid',
123
+ message: `createRouteDecorator() requires a key (${declaration.name})`,
124
+ hints: [`export const ${declaration.name} = createRouteDecorator('${declaration.name.toLowerCase()}')`],
125
+ definition,
126
+ }
127
+ }
128
+ const key = ctx.project.evaluate(scope, keyNode as unknown as ExpressionInput, { refs: 'none' })
129
+ if (key.kind !== 'value' || typeof key.value !== 'string' || key.value === '') {
130
+ return {
131
+ kind: 'invalid',
132
+ message: `the key of the route decorator ${declaration.name} must be a statically evaluable non-empty string`,
133
+ hints: ["Use a string literal or a `const` string: createRouteDecorator('roles')"],
134
+ definition,
135
+ }
136
+ }
137
+ const optionsHints = [
138
+ `export const ${declaration.name} = createRouteDecorator('${key.value}', { group: 'access', required: true })`,
139
+ ]
140
+ if (extra.length > 0 || optionsNode?.type === 'SpreadElement') {
141
+ return {
142
+ kind: 'invalid',
143
+ message: `createRouteDecorator() takes a key and an optional { group?, required? } object (${declaration.name})`,
144
+ hints: optionsHints,
145
+ definition,
146
+ }
147
+ }
148
+ let options: StaticValue
149
+ if (optionsNode !== undefined) {
150
+ const evaluated = ctx.project.evaluate(scope, optionsNode as unknown as ExpressionInput, { refs: 'none' })
151
+ if (evaluated.kind !== 'value') {
152
+ return {
153
+ kind: 'invalid',
154
+ message: `the options of the route decorator ${declaration.name} must be statically evaluable (literals and \`const\` references)`,
155
+ hints: optionsHints,
156
+ definition,
157
+ }
158
+ }
159
+ options = evaluated.value
160
+ }
161
+ const parsed = parseRouteDecoratorOptions(key.value, options)
162
+ if ('error' in parsed) {
163
+ return {
164
+ kind: 'invalid',
165
+ message: `the options of the route decorator ${declaration.name} ${parsed.error}`,
166
+ hints: optionsHints,
167
+ definition,
168
+ }
169
+ }
170
+ return { kind: 'route', key: key.value, ...parsed, name: declaration.name, definition }
171
+ }
172
+
173
+ /**
174
+ * The group and `required` of a route decorator from its evaluated options (`undefined`: the
175
+ * defaults), or why the options are invalid.
176
+ */
177
+ export function parseRouteDecoratorOptions(
178
+ key: string,
179
+ options: StaticValue,
180
+ ): { readonly group: string; readonly required: boolean } | { readonly error: string } {
181
+ if (options === undefined) {
182
+ return { group: key, required: false }
183
+ }
184
+ if (!isPlainObject(options)) {
185
+ return { error: 'must be an object { group?, required? }' }
186
+ }
187
+ const unknown = Object.keys(options).find(name => name !== 'group' && name !== 'required')
188
+ if (unknown !== undefined) {
189
+ return { error: `have an unknown property "${unknown}" (expected group and required)` }
190
+ }
191
+ // the grammar applies to an explicit group only: without one, any key is its own group
192
+ const group = options['group'] ?? key
193
+ if (typeof group !== 'string' || (group !== key && !GROUP_NAME.test(group))) {
194
+ const shown = typeof group === 'object' && group !== null ? 'a reference' : JSON.stringify(group)
195
+ return { error: `have an invalid group ${shown}: a letter followed by letters, digits, _ . or -` }
196
+ }
197
+ const required = options['required'] ?? false
198
+ if (typeof required !== 'boolean') {
199
+ return { error: 'have a non-boolean "required"' }
200
+ }
201
+ return { group, required }
202
+ }
203
+
204
+ function isPlainObject(value: unknown): value is Record<string, StaticValue> {
205
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
206
+ return false
207
+ }
208
+ const prototype = Object.getPrototypeOf(value)
209
+ return prototype === Object.prototype || prototype === null
210
+ }
211
+
212
+ function isEntityName(node: AstNode): boolean {
213
+ if (node.type === 'Identifier') {
214
+ return true
215
+ }
216
+ if (node.type === 'MemberExpression' && node['computed'] !== true) {
217
+ const object = child(node, 'object')
218
+ return child(node, 'property')?.type === 'Identifier' && object !== undefined && isEntityName(object)
219
+ }
220
+ return false
221
+ }
222
+
223
+ /**
224
+ * Metadata entries of the route decorators applied to a class or a method (source order).
225
+ * `target` names the class or the handler (`BookController`, `BookController.remove`). Problems are
226
+ * reported as ZNIGHT005: two decisions of one group on the same target included.
227
+ */
228
+ export function routeMeta(
229
+ ctx: Context,
230
+ cls: ClassInfo,
231
+ decorators: readonly DecoratorInfo[],
232
+ target: string,
233
+ ): MetaEntry[] {
234
+ const entries: MetaEntry[] = []
235
+ for (const decorator of decorators) {
236
+ if (decorator.owner !== undefined || decorator.identity === undefined) {
237
+ continue
238
+ }
239
+ const resolution = resolveRouteDecorator(ctx, decorator.identity)
240
+ if (resolution.kind === 'other') {
241
+ continue
242
+ }
243
+ if (resolution.kind === 'invalid') {
244
+ report(
245
+ ctx,
246
+ decorator,
247
+ `Invalid route decorator @${decorator.name}: ${resolution.message}`,
248
+ resolution.hints,
249
+ resolution.definition === undefined
250
+ ? undefined
251
+ : { message: 'The route decorator is defined here', location: resolution.definition },
252
+ )
253
+ continue
254
+ }
255
+ const value = metaValue(ctx, cls, decorator)
256
+ if (value === INVALID) {
257
+ continue
258
+ }
259
+ const previous = entries.find(entry => entry.group === resolution.group)
260
+ if (previous) {
261
+ if (previous.key === resolution.key && resolution.group === resolution.key) {
262
+ report(
263
+ ctx,
264
+ decorator,
265
+ `@${decorator.name} sets the route metadata '${resolution.key}', already set on the same target`,
266
+ ['Apply each route decorator once per controller or route'],
267
+ { message: 'First applied here', location: previous.location },
268
+ )
269
+ } else {
270
+ const alternatives =
271
+ previous.decorator === resolution.name
272
+ ? `@${resolution.name} once`
273
+ : `one of @${previous.decorator}, @${resolution.name}`
274
+ report(
275
+ ctx,
276
+ decorator,
277
+ `@${resolution.name} sets the "${resolution.group}" decision of ${target}, already set by @${previous.decorator} on the same target`,
278
+ [`Keep ${alternatives}: the decorators of the "${resolution.group}" group are alternatives`],
279
+ { message: 'First applied here', location: previous.location },
280
+ )
281
+ }
282
+ continue
283
+ }
284
+ entries.push({
285
+ key: resolution.key,
286
+ value,
287
+ location: decorator.location,
288
+ group: resolution.group,
289
+ required: resolution.required,
290
+ decorator: resolution.name,
291
+ identity: decorator.identity,
292
+ definition: resolution.definition,
293
+ })
294
+ }
295
+ return entries
296
+ }
297
+
298
+ /** `true` when the decorator is a route metadata decorator. */
299
+ export function isRouteMetaDecorator(ctx: Context, decorator: DecoratorInfo): boolean {
300
+ return (
301
+ decorator.owner === undefined &&
302
+ decorator.identity !== undefined &&
303
+ resolveRouteDecorator(ctx, decorator.identity).kind !== 'other'
304
+ )
305
+ }
306
+
307
+ const INVALID: unique symbol = Symbol('invalid')
308
+
309
+ function metaValue(ctx: Context, cls: ClassInfo, decorator: DecoratorInfo): StaticValue | typeof INVALID {
310
+ if (!decorator.called) {
311
+ report(ctx, decorator, `@${decorator.name} must be called: @${decorator.name}()`, [
312
+ `Write @${decorator.name}(${decorator.args.length > 0 ? '...' : ''})`,
313
+ ])
314
+ return INVALID
315
+ }
316
+ const [arg, ...extra] = decorator.args
317
+ if (arg === undefined) {
318
+ return true
319
+ }
320
+ if (extra.length > 0) {
321
+ report(ctx, decorator, `@${decorator.name} takes at most one argument`, [
322
+ 'Pass a single value (an array or an object)',
323
+ ])
324
+ return INVALID
325
+ }
326
+ if (arg.text.startsWith('...')) {
327
+ report(ctx, decorator, `@${decorator.name} does not accept spread arguments`, ['Pass the value directly'])
328
+ return INVALID
329
+ }
330
+ const evaluation = ctx.project.evaluate(cls.file, arg.node as unknown as ExpressionInput, { refs: 'nested' })
331
+ switch (evaluation.kind) {
332
+ case 'value':
333
+ return checkRefs(ctx, decorator, evaluation.value) ? evaluation.value : INVALID
334
+ case 'ref':
335
+ return checkRefs(ctx, decorator, evaluation.ref) ? evaluation.ref : INVALID
336
+ case 'error': {
337
+ ctx.report({
338
+ code: Codes.InvalidDecorator,
339
+ severity: 'error',
340
+ message: `Invalid argument of @${decorator.name}: ${evaluation.error.message}`,
341
+ location: evaluation.error.location,
342
+ hints: [evaluation.error.hint ?? 'Extract the value to `export const X = ...` and reference it'],
343
+ })
344
+ return INVALID
345
+ }
346
+ }
347
+ }
348
+
349
+ /** Checks that every reference of a value can be imported by the generated module. */
350
+ function checkRefs(ctx: Context, decorator: DecoratorInfo, value: StaticValue): boolean {
351
+ let ok = true
352
+ const visit = (current: StaticValue): void => {
353
+ if (isValueRef(current)) {
354
+ const route = ctx.project.importRoute(current.symbol)
355
+ if (!route.ok) {
356
+ ok = false
357
+ ctx.report({
358
+ code: Codes.InvalidDecorator,
359
+ severity: 'error',
360
+ message: `Invalid argument of @${decorator.name}: '${current.text}' cannot be imported by the generated module (${route.error.message})`,
361
+ location: current.location,
362
+ hints: route.error.hints.length > 0 ? [...route.error.hints] : ['Export it from its module'],
363
+ })
364
+ }
365
+ return
366
+ }
367
+ if (Array.isArray(current)) {
368
+ for (const item of current) {
369
+ visit(item)
370
+ }
371
+ } else if (typeof current === 'object' && current !== null) {
372
+ for (const item of Object.values(current)) {
373
+ visit(item)
374
+ }
375
+ }
376
+ }
377
+ visit(value)
378
+ return ok
379
+ }
380
+
381
+ /** `true` for a {@link ValueRef} (the only non-plain object a static value can hold). */
382
+ export function isValueRef(value: unknown): value is ValueRef {
383
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
384
+ return false
385
+ }
386
+ const prototype = Object.getPrototypeOf(value)
387
+ return prototype !== Object.prototype && prototype !== null && (value as { kind?: unknown }).kind === 'ref'
388
+ }
389
+
390
+ function report(
391
+ ctx: Context,
392
+ decorator: DecoratorInfo,
393
+ message: string,
394
+ hints: readonly string[],
395
+ related?: { readonly message: string; readonly location: Diagnostic['location'] },
396
+ ): void {
397
+ ctx.report({
398
+ code: Codes.InvalidDecorator,
399
+ severity: 'error',
400
+ message,
401
+ location: decorator.location,
402
+ hints,
403
+ ...(related?.location ? { related: [{ message: related.message, location: related.location }] } : {}),
404
+ })
405
+ }