@znight/http 0.0.0-stage → 0.1.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/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,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
|
+
}
|