@travetto/web 6.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +734 -0
- package/__index__.ts +44 -0
- package/package.json +66 -0
- package/src/common/global.ts +30 -0
- package/src/config.ts +18 -0
- package/src/context.ts +49 -0
- package/src/decorator/common.ts +87 -0
- package/src/decorator/controller.ts +13 -0
- package/src/decorator/endpoint.ts +102 -0
- package/src/decorator/param.ts +64 -0
- package/src/interceptor/accept.ts +70 -0
- package/src/interceptor/body-parse.ts +123 -0
- package/src/interceptor/compress.ts +119 -0
- package/src/interceptor/context.ts +23 -0
- package/src/interceptor/cookies.ts +97 -0
- package/src/interceptor/cors.ts +94 -0
- package/src/interceptor/decompress.ts +91 -0
- package/src/interceptor/etag.ts +99 -0
- package/src/interceptor/logging.ts +71 -0
- package/src/interceptor/respond.ts +26 -0
- package/src/interceptor/response-cache.ts +47 -0
- package/src/interceptor/trust-proxy.ts +53 -0
- package/src/registry/controller.ts +288 -0
- package/src/registry/types.ts +229 -0
- package/src/registry/visitor.ts +52 -0
- package/src/router/base.ts +67 -0
- package/src/router/standard.ts +59 -0
- package/src/types/cookie.ts +18 -0
- package/src/types/core.ts +33 -0
- package/src/types/dispatch.ts +23 -0
- package/src/types/error.ts +10 -0
- package/src/types/filter.ts +7 -0
- package/src/types/headers.ts +108 -0
- package/src/types/interceptor.ts +54 -0
- package/src/types/message.ts +33 -0
- package/src/types/request.ts +22 -0
- package/src/types/response.ts +20 -0
- package/src/util/body.ts +220 -0
- package/src/util/common.ts +142 -0
- package/src/util/cookie.ts +145 -0
- package/src/util/endpoint.ts +277 -0
- package/src/util/mime.ts +36 -0
- package/src/util/net.ts +61 -0
- package/support/test/dispatch-util.ts +90 -0
- package/support/test/dispatcher.ts +15 -0
- package/support/test/suite/base.ts +61 -0
- package/support/test/suite/controller.ts +103 -0
- package/support/test/suite/schema.ts +275 -0
- package/support/test/suite/standard.ts +178 -0
- package/support/transformer.web.ts +207 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Config } from '@travetto/config';
|
|
2
|
+
import { Inject, Injectable } from '@travetto/di';
|
|
3
|
+
import { castTo } from '@travetto/runtime';
|
|
4
|
+
|
|
5
|
+
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
6
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
7
|
+
import { WebResponse } from '../types/response.ts';
|
|
8
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
9
|
+
|
|
10
|
+
@Config('web.trustProxy')
|
|
11
|
+
export class TrustProxyConfig {
|
|
12
|
+
/**
|
|
13
|
+
* Enforces trust rules for X-Forwarded-* headers
|
|
14
|
+
*/
|
|
15
|
+
applies = true;
|
|
16
|
+
/**
|
|
17
|
+
* The accepted ips
|
|
18
|
+
*/
|
|
19
|
+
ips: string[] = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Injectable()
|
|
23
|
+
export class TrustProxyInterceptor implements WebInterceptor<TrustProxyConfig> {
|
|
24
|
+
|
|
25
|
+
category: WebInterceptorCategory = 'pre-request';
|
|
26
|
+
|
|
27
|
+
@Inject()
|
|
28
|
+
config: TrustProxyConfig;
|
|
29
|
+
|
|
30
|
+
applies({ config }: WebInterceptorContext<TrustProxyConfig>): boolean {
|
|
31
|
+
return config.applies;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
filter({ request, next, config }: WebChainedContext<TrustProxyConfig>): Promise<WebResponse> {
|
|
35
|
+
const forwardedFor = request.headers.get('X-Forwarded-For');
|
|
36
|
+
|
|
37
|
+
if (forwardedFor) {
|
|
38
|
+
const connection = request.context.connection ?? {};
|
|
39
|
+
if (config.ips[0] === '*' || (connection.ip && config.ips.includes(connection.ip))) {
|
|
40
|
+
connection.httpProtocol = castTo(request.headers.get('X-Forwarded-Proto')!) || connection.httpProtocol;
|
|
41
|
+
connection.host = request.headers.get('X-Forwarded-Host') || connection.host;
|
|
42
|
+
connection.ip = forwardedFor;
|
|
43
|
+
Object.assign(request.context, { connection });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
request.headers.delete('X-Forwarded-For');
|
|
48
|
+
request.headers.delete('X-Forwarded-Proto');
|
|
49
|
+
request.headers.delete('X-Forwarded-Host');
|
|
50
|
+
|
|
51
|
+
return next();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { DependencyRegistry } from '@travetto/di';
|
|
2
|
+
import { type Primitive, type Class, asFull, castTo, asConstructable, ClassInstance } from '@travetto/runtime';
|
|
3
|
+
import { MetadataRegistry } from '@travetto/registry';
|
|
4
|
+
|
|
5
|
+
import { EndpointConfig, ControllerConfig, EndpointDecorator, EndpointParamConfig, EndpointFunctionDescriptor, EndpointFunction } from './types.ts';
|
|
6
|
+
import { WebChainedFilter, WebFilter } from '../types/filter.ts';
|
|
7
|
+
import { WebInterceptor } from '../types/interceptor.ts';
|
|
8
|
+
import { WebHeaders } from '../types/headers.ts';
|
|
9
|
+
|
|
10
|
+
import { WebAsyncContext } from '../context.ts';
|
|
11
|
+
|
|
12
|
+
type ValidFieldNames<T> = {
|
|
13
|
+
[K in keyof T]:
|
|
14
|
+
(T[K] extends (Primitive | undefined) ? K :
|
|
15
|
+
(T[K] extends (Function | undefined) ? never :
|
|
16
|
+
K))
|
|
17
|
+
}[keyof T];
|
|
18
|
+
|
|
19
|
+
type RetainFields<T> = Pick<T, ValidFieldNames<T>>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Controller registry
|
|
23
|
+
*/
|
|
24
|
+
class $ControllerRegistry extends MetadataRegistry<ControllerConfig, EndpointConfig> {
|
|
25
|
+
|
|
26
|
+
#endpointsById = new Map<string, EndpointConfig>();
|
|
27
|
+
|
|
28
|
+
constructor() {
|
|
29
|
+
super(DependencyRegistry);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async #bindContextParams<T>(inst: ClassInstance<T>): Promise<void> {
|
|
33
|
+
const ctx = await DependencyRegistry.getInstance(WebAsyncContext);
|
|
34
|
+
const map = this.get(inst.constructor).contextParams;
|
|
35
|
+
for (const [field, type] of Object.entries(map)) {
|
|
36
|
+
Object.defineProperty(inst, field, { get: ctx.getSource(type) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getEndpointById(id: string): EndpointConfig | undefined {
|
|
41
|
+
return this.#endpointsById.get(id.replace(':', '#'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
createPending(cls: Class): ControllerConfig {
|
|
45
|
+
return {
|
|
46
|
+
class: cls,
|
|
47
|
+
filters: [],
|
|
48
|
+
interceptorConfigs: [],
|
|
49
|
+
basePath: '',
|
|
50
|
+
externalName: cls.name.replace(/(Controller|Web|Service)$/, ''),
|
|
51
|
+
endpoints: [],
|
|
52
|
+
contextParams: {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
createPendingField(cls: Class, endpoint: EndpointFunction): EndpointConfig {
|
|
57
|
+
const controllerConf = this.getOrCreatePending(cls);
|
|
58
|
+
|
|
59
|
+
const fieldConf: EndpointConfig = {
|
|
60
|
+
id: `${cls.name}#${endpoint.name}`,
|
|
61
|
+
path: '/',
|
|
62
|
+
fullPath: '/',
|
|
63
|
+
cacheable: false,
|
|
64
|
+
allowsBody: false,
|
|
65
|
+
class: cls,
|
|
66
|
+
filters: [],
|
|
67
|
+
params: [],
|
|
68
|
+
interceptorConfigs: [],
|
|
69
|
+
name: endpoint.name,
|
|
70
|
+
endpoint,
|
|
71
|
+
responseHeaderMap: new WebHeaders(),
|
|
72
|
+
responseFinalizer: undefined
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
controllerConf.endpoints!.push(fieldConf);
|
|
76
|
+
|
|
77
|
+
return fieldConf;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register the endpoint config
|
|
82
|
+
* @param cls Controller class
|
|
83
|
+
* @param endpoint Endpoint target function
|
|
84
|
+
*/
|
|
85
|
+
getOrCreateEndpointConfig<T>(cls: Class<T>, endpoint: EndpointFunction): EndpointConfig {
|
|
86
|
+
const fieldConf = this.getOrCreatePendingField(cls, endpoint);
|
|
87
|
+
return asFull(fieldConf);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Register the controller filter
|
|
92
|
+
* @param cls Controller class
|
|
93
|
+
* @param filter The filter to call
|
|
94
|
+
*/
|
|
95
|
+
registerControllerFilter(target: Class, filter: WebFilter | WebChainedFilter): void {
|
|
96
|
+
const config = this.getOrCreatePending(target);
|
|
97
|
+
config.filters!.push(filter);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register the controller filter
|
|
102
|
+
* @param cls Controller class
|
|
103
|
+
* @param endpoint Endpoint function
|
|
104
|
+
* @param filter The filter to call
|
|
105
|
+
*/
|
|
106
|
+
registerEndpointFilter(target: Class, endpoint: EndpointFunction, filter: WebFilter | WebChainedFilter): void {
|
|
107
|
+
const config = this.getOrCreateEndpointConfig(target, endpoint);
|
|
108
|
+
config.filters!.unshift(filter);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Register the endpoint parameter
|
|
113
|
+
* @param cls Controller class
|
|
114
|
+
* @param endpoint Endpoint function
|
|
115
|
+
* @param param The param config
|
|
116
|
+
* @param index The parameter index
|
|
117
|
+
*/
|
|
118
|
+
registerEndpointParameter(target: Class, endpoint: EndpointFunction, param: EndpointParamConfig, index: number): void {
|
|
119
|
+
const config = this.getOrCreateEndpointConfig(target, endpoint);
|
|
120
|
+
if (index >= config.params.length) {
|
|
121
|
+
config.params.length = index + 1;
|
|
122
|
+
}
|
|
123
|
+
config.params[index] = param;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Register the endpoint interceptor config
|
|
128
|
+
* @param cls Controller class
|
|
129
|
+
* @param endpoint Endpoint function
|
|
130
|
+
* @param param The param config
|
|
131
|
+
* @param index The parameter index
|
|
132
|
+
*/
|
|
133
|
+
registerEndpointInterceptorConfig<T extends WebInterceptor>(target: Class, endpoint: EndpointFunction, interceptorCls: Class<T>, config: Partial<T['config']>): void {
|
|
134
|
+
const endpointConfig = this.getOrCreateEndpointConfig(target, endpoint);
|
|
135
|
+
(endpointConfig.interceptorConfigs ??= []).push([interceptorCls, { ...config }]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Register the controller interceptor config
|
|
140
|
+
* @param cls Controller class
|
|
141
|
+
* @param param The param config
|
|
142
|
+
* @param index The parameter index
|
|
143
|
+
*/
|
|
144
|
+
registerControllerInterceptorConfig<T extends WebInterceptor>(target: Class, interceptorCls: Class<T>, config: Partial<T['config']>): void {
|
|
145
|
+
const controllerConfig = this.getOrCreatePending(target);
|
|
146
|
+
(controllerConfig.interceptorConfigs ??= []).push([interceptorCls, { ...config }]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Register a controller context param
|
|
151
|
+
* @param target Controller class
|
|
152
|
+
* @param field Field on controller to bind context param to
|
|
153
|
+
* @param type The context type to bind to field
|
|
154
|
+
*/
|
|
155
|
+
registerControllerContextParam<T>(target: Class, field: string, type: Class<T>): void {
|
|
156
|
+
const controllerConfig = this.getOrCreatePending(target);
|
|
157
|
+
controllerConfig.contextParams![field] = type;
|
|
158
|
+
DependencyRegistry.registerPostConstructHandler(target, 'ContextParam', inst => this.#bindContextParams(inst));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a filter decorator
|
|
163
|
+
* @param filter The filter to call
|
|
164
|
+
*/
|
|
165
|
+
createFilterDecorator(filter: WebFilter): EndpointDecorator {
|
|
166
|
+
return (target: unknown, prop?: symbol | string, descriptor?: EndpointFunctionDescriptor): void => {
|
|
167
|
+
if (prop) {
|
|
168
|
+
this.registerEndpointFilter(asConstructable(target).constructor, descriptor!.value!, filter);
|
|
169
|
+
} else {
|
|
170
|
+
this.registerControllerFilter(castTo(target), filter);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register a controller/endpoint with specific config for an interceptor
|
|
177
|
+
* @param cls The interceptor to register data for
|
|
178
|
+
* @param cfg The partial config override
|
|
179
|
+
*/
|
|
180
|
+
createInterceptorConfigDecorator<T extends WebInterceptor>(
|
|
181
|
+
cls: Class<T>,
|
|
182
|
+
cfg: Partial<RetainFields<T['config']>>,
|
|
183
|
+
extra?: Partial<EndpointConfig & ControllerConfig>
|
|
184
|
+
): EndpointDecorator {
|
|
185
|
+
return (target: unknown, prop?: symbol | string, descriptor?: EndpointFunctionDescriptor): void => {
|
|
186
|
+
const outCls: Class = descriptor ? asConstructable(target).constructor : castTo(target);
|
|
187
|
+
if (prop && descriptor) {
|
|
188
|
+
this.registerEndpointInterceptorConfig(outCls, descriptor!.value!, cls, castTo(cfg));
|
|
189
|
+
extra && this.registerPendingEndpoint(outCls, descriptor, extra);
|
|
190
|
+
} else {
|
|
191
|
+
this.registerControllerInterceptorConfig(outCls, cls, castTo(cfg));
|
|
192
|
+
extra && this.registerPending(outCls, extra);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Merge describable
|
|
199
|
+
* @param src Root describable (controller, endpoint)
|
|
200
|
+
* @param dest Target (controller, endpoint)
|
|
201
|
+
*/
|
|
202
|
+
mergeDescribable(src: Partial<ControllerConfig | EndpointConfig>, dest: Partial<ControllerConfig | EndpointConfig>): void {
|
|
203
|
+
dest.filters = [...(dest.filters ?? []), ...(src.filters ?? [])];
|
|
204
|
+
dest.interceptorConfigs = [...(dest.interceptorConfigs ?? []), ...(src.interceptorConfigs ?? [])];
|
|
205
|
+
dest.interceptorExclude = dest.interceptorExclude ?? src.interceptorExclude;
|
|
206
|
+
dest.title = src.title || dest.title;
|
|
207
|
+
dest.description = src.description || dest.description;
|
|
208
|
+
dest.documented = src.documented ?? dest.documented;
|
|
209
|
+
dest.responseHeaders = { ...src.responseHeaders, ...dest.responseHeaders };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Register an endpoint as pending
|
|
214
|
+
* @param target Controller class
|
|
215
|
+
* @param descriptor Prop descriptor
|
|
216
|
+
* @param config The endpoint config
|
|
217
|
+
*/
|
|
218
|
+
registerPendingEndpoint(target: Class, descriptor: EndpointFunctionDescriptor, config: Partial<EndpointConfig>): EndpointFunctionDescriptor {
|
|
219
|
+
const srcConf = this.getOrCreateEndpointConfig(target, descriptor.value!);
|
|
220
|
+
srcConf.cacheable = config.cacheable ?? srcConf.cacheable;
|
|
221
|
+
srcConf.httpMethod = config.httpMethod ?? srcConf.httpMethod;
|
|
222
|
+
srcConf.allowsBody = config.allowsBody ?? srcConf.allowsBody;
|
|
223
|
+
srcConf.path = config.path || srcConf.path;
|
|
224
|
+
srcConf.responseType = config.responseType ?? srcConf.responseType;
|
|
225
|
+
srcConf.requestType = config.requestType ?? srcConf.requestType;
|
|
226
|
+
srcConf.params = (config.params ?? srcConf.params).map(x => ({ ...x }));
|
|
227
|
+
srcConf.responseFinalizer = config.responseFinalizer ?? srcConf.responseFinalizer;
|
|
228
|
+
|
|
229
|
+
// Ensure path starts with '/'
|
|
230
|
+
if (!srcConf.path.startsWith('/')) {
|
|
231
|
+
srcConf.path = `/${srcConf.path}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.mergeDescribable(config, srcConf);
|
|
235
|
+
|
|
236
|
+
return descriptor;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Register a pending configuration
|
|
241
|
+
* @param target The target class
|
|
242
|
+
* @param config The controller configuration
|
|
243
|
+
*/
|
|
244
|
+
registerPending(target: Class, config: Partial<ControllerConfig>): void {
|
|
245
|
+
const srcConf = this.getOrCreatePending(target);
|
|
246
|
+
srcConf.basePath = config.basePath || srcConf.basePath;
|
|
247
|
+
|
|
248
|
+
if (!srcConf.basePath!.startsWith('/')) {
|
|
249
|
+
srcConf.basePath = `/${srcConf.basePath}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
srcConf.contextParams = { ...srcConf.contextParams, ...config.contextParams };
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
this.mergeDescribable(config, srcConf);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Finalize endpoints, removing duplicates based on ids
|
|
260
|
+
*/
|
|
261
|
+
onInstallFinalize(cls: Class): ControllerConfig {
|
|
262
|
+
const final = asFull(this.getOrCreatePending(cls));
|
|
263
|
+
|
|
264
|
+
// Store for lookup
|
|
265
|
+
for (const ep of final.endpoints) {
|
|
266
|
+
this.#endpointsById.set(ep.id, ep);
|
|
267
|
+
// Store full path from base for use in other contexts
|
|
268
|
+
ep.fullPath = `/${final.basePath}/${ep.path}`.replace(/[/]{1,4}/g, '/').replace(/(.)[/]$/, (_, a) => a);
|
|
269
|
+
ep.responseHeaderMap = new WebHeaders({ ...final.responseHeaders ?? {}, ...ep.responseHeaders ?? {} });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (this.has(final.basePath)) {
|
|
273
|
+
console.debug('Reloading controller', { name: cls.name, path: final.basePath });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return final;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
onUninstallFinalize<T>(cls: Class<T>): void {
|
|
280
|
+
const toDelete = [...this.#endpointsById.values()].filter(x => x.class.name === cls.name);
|
|
281
|
+
for (const k of toDelete) {
|
|
282
|
+
this.#endpointsById.delete(k.id);
|
|
283
|
+
}
|
|
284
|
+
super.onUninstallFinalize(cls);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const ControllerRegistry = new $ControllerRegistry();
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type { Any, Class, TypedFunction } from '@travetto/runtime';
|
|
2
|
+
import type { FieldConfig, ClassConfig } from '@travetto/schema';
|
|
3
|
+
|
|
4
|
+
import type { WebInterceptor } from '../types/interceptor.ts';
|
|
5
|
+
import type { WebChainedFilter, WebFilter } from '../types/filter.ts';
|
|
6
|
+
import { HttpMethod } from '../types/core.ts';
|
|
7
|
+
import { WebHeaders } from '../types/headers.ts';
|
|
8
|
+
import { WebResponse } from '../types/response.ts';
|
|
9
|
+
import { WebRequest } from '../types/request.ts';
|
|
10
|
+
|
|
11
|
+
export type EndpointFunction = TypedFunction<Any, unknown>;
|
|
12
|
+
export type EndpointFunctionDescriptor = TypedPropertyDescriptor<EndpointFunction>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Endpoint decorator for composition of routing logic
|
|
16
|
+
*/
|
|
17
|
+
export type EndpointDecorator = (
|
|
18
|
+
(<T extends Class>(target: T) => void) &
|
|
19
|
+
(<U>(target: U, prop: string, descriptor?: EndpointFunctionDescriptor) => void)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Endpoint type
|
|
24
|
+
*/
|
|
25
|
+
export type EndpointIOType = {
|
|
26
|
+
type: Class;
|
|
27
|
+
array?: boolean;
|
|
28
|
+
description?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Describable elements
|
|
33
|
+
*/
|
|
34
|
+
export interface DescribableConfig {
|
|
35
|
+
/**
|
|
36
|
+
* The title
|
|
37
|
+
*/
|
|
38
|
+
title?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Description
|
|
41
|
+
*/
|
|
42
|
+
description?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Is the resource documented
|
|
45
|
+
*/
|
|
46
|
+
documented?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Core configuration for endpoints and controllers
|
|
51
|
+
*/
|
|
52
|
+
interface CoreConfig {
|
|
53
|
+
/**
|
|
54
|
+
* The related class
|
|
55
|
+
*/
|
|
56
|
+
class: Class;
|
|
57
|
+
/**
|
|
58
|
+
* The actual class instance
|
|
59
|
+
*/
|
|
60
|
+
instance?: unknown;
|
|
61
|
+
/**
|
|
62
|
+
* List of filters to run on request
|
|
63
|
+
*/
|
|
64
|
+
filters: (WebFilter | WebChainedFilter)[];
|
|
65
|
+
/**
|
|
66
|
+
* Set of interceptor configs
|
|
67
|
+
*/
|
|
68
|
+
interceptorConfigs?: [Class<WebInterceptor>, unknown][];
|
|
69
|
+
/**
|
|
70
|
+
* Should the resource only be used conditionally?
|
|
71
|
+
*/
|
|
72
|
+
conditional?: () => (boolean | Promise<boolean>);
|
|
73
|
+
/**
|
|
74
|
+
* Control which interceptors are excluded
|
|
75
|
+
*/
|
|
76
|
+
interceptorExclude?: (val: WebInterceptor) => boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Response headers
|
|
79
|
+
*/
|
|
80
|
+
responseHeaders?: Record<string, string>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Endpoint param configuration
|
|
85
|
+
*/
|
|
86
|
+
export interface EndpointParamConfig {
|
|
87
|
+
/**
|
|
88
|
+
* Name of the parameter
|
|
89
|
+
*/
|
|
90
|
+
name?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Raw text of parameter at source
|
|
93
|
+
*/
|
|
94
|
+
sourceText?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Location of the parameter
|
|
97
|
+
*/
|
|
98
|
+
location: 'path' | 'query' | 'body' | 'header';
|
|
99
|
+
/**
|
|
100
|
+
* Resolves the value by executing with req/res as input
|
|
101
|
+
*/
|
|
102
|
+
resolve?: WebFilter;
|
|
103
|
+
/**
|
|
104
|
+
* Extract the value from request
|
|
105
|
+
* @param context The http context with the endpoint param config
|
|
106
|
+
*/
|
|
107
|
+
extract?: (ctx: WebRequest, config: EndpointParamConfig) => unknown;
|
|
108
|
+
/**
|
|
109
|
+
* Input prefix for parameter
|
|
110
|
+
*/
|
|
111
|
+
prefix?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Endpoint configuration
|
|
116
|
+
*/
|
|
117
|
+
export interface EndpointConfig extends CoreConfig, DescribableConfig {
|
|
118
|
+
/**
|
|
119
|
+
* Unique identifier
|
|
120
|
+
*/
|
|
121
|
+
id: string;
|
|
122
|
+
/**
|
|
123
|
+
* The name of the method
|
|
124
|
+
*/
|
|
125
|
+
name: string;
|
|
126
|
+
/**
|
|
127
|
+
* Instance the endpoint is for
|
|
128
|
+
*/
|
|
129
|
+
instance?: unknown;
|
|
130
|
+
/**
|
|
131
|
+
* Method alias for the endpoint
|
|
132
|
+
*/
|
|
133
|
+
httpMethod?: HttpMethod;
|
|
134
|
+
/**
|
|
135
|
+
* Is this endpoint cacheable
|
|
136
|
+
*/
|
|
137
|
+
cacheable: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Does this endpoint allow a body
|
|
140
|
+
*/
|
|
141
|
+
allowsBody: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* The path of the endpoint
|
|
144
|
+
*/
|
|
145
|
+
path: string;
|
|
146
|
+
/**
|
|
147
|
+
* The function the endpoint will call
|
|
148
|
+
*/
|
|
149
|
+
endpoint: EndpointFunction;
|
|
150
|
+
/**
|
|
151
|
+
* The compiled and finalized handler
|
|
152
|
+
*/
|
|
153
|
+
filter?: WebFilter;
|
|
154
|
+
/**
|
|
155
|
+
* List of params for the endpoint
|
|
156
|
+
*/
|
|
157
|
+
params: EndpointParamConfig[];
|
|
158
|
+
/**
|
|
159
|
+
* The response type
|
|
160
|
+
*/
|
|
161
|
+
responseType?: EndpointIOType;
|
|
162
|
+
/**
|
|
163
|
+
* The request type
|
|
164
|
+
*/
|
|
165
|
+
requestType?: EndpointIOType;
|
|
166
|
+
/**
|
|
167
|
+
* Full path including controller
|
|
168
|
+
*/
|
|
169
|
+
fullPath: string;
|
|
170
|
+
/**
|
|
171
|
+
* Response header map
|
|
172
|
+
*/
|
|
173
|
+
responseHeaderMap: WebHeaders;
|
|
174
|
+
/**
|
|
175
|
+
* Response finalizer
|
|
176
|
+
*/
|
|
177
|
+
responseFinalizer?: (res: WebResponse) => WebResponse;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Controller configuration
|
|
182
|
+
*/
|
|
183
|
+
export interface ControllerConfig extends CoreConfig, DescribableConfig {
|
|
184
|
+
/**
|
|
185
|
+
* The base path of the controller
|
|
186
|
+
*/
|
|
187
|
+
basePath: string;
|
|
188
|
+
/**
|
|
189
|
+
* List of all endpoints
|
|
190
|
+
*/
|
|
191
|
+
endpoints: EndpointConfig[];
|
|
192
|
+
/**
|
|
193
|
+
* Client name, used by consuming tools/clients
|
|
194
|
+
*/
|
|
195
|
+
externalName: string;
|
|
196
|
+
/**
|
|
197
|
+
* Context parameters to bind at create
|
|
198
|
+
*/
|
|
199
|
+
contextParams: Record<string, Class>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Controller visitor options
|
|
204
|
+
*/
|
|
205
|
+
export type ControllerVisitorOptions = { skipUndocumented?: boolean };
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Controller visitor pattern
|
|
209
|
+
*/
|
|
210
|
+
export interface ControllerVisitor<T = unknown> {
|
|
211
|
+
|
|
212
|
+
getOptions?: () => ControllerVisitorOptions;
|
|
213
|
+
|
|
214
|
+
onControllerStart?(controller: ControllerConfig): Promise<unknown> | unknown;
|
|
215
|
+
onControllerEnd?(controller: ControllerConfig): Promise<unknown> | unknown;
|
|
216
|
+
|
|
217
|
+
onEndpointStart?(endpoint: EndpointConfig, controller: ControllerConfig, methodParams: FieldConfig[]): Promise<unknown> | unknown;
|
|
218
|
+
onEndpointEnd?(endpoint: EndpointConfig, controller: ControllerConfig, methodParams: FieldConfig[]): Promise<unknown> | unknown;
|
|
219
|
+
|
|
220
|
+
onSchema?(schema: ClassConfig): Promise<unknown> | unknown;
|
|
221
|
+
|
|
222
|
+
onControllerAdd?(cls: Class): Promise<unknown> | unknown;
|
|
223
|
+
onControllerRemove?(cls: Class): Promise<unknown> | unknown;
|
|
224
|
+
|
|
225
|
+
onSchemaAdd?(cls: Class): Promise<boolean> | boolean;
|
|
226
|
+
onSchemaRemove?(cls: Class): Promise<boolean> | boolean;
|
|
227
|
+
|
|
228
|
+
onComplete?(): T | Promise<T>;
|
|
229
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Class } from '@travetto/runtime';
|
|
2
|
+
import { SchemaRegistry } from '@travetto/schema';
|
|
3
|
+
|
|
4
|
+
import { ControllerVisitor, ControllerVisitorOptions } from './types.ts';
|
|
5
|
+
import { ControllerRegistry } from './controller.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Supports visiting the controller structure
|
|
9
|
+
*/
|
|
10
|
+
export class ControllerVisitUtil {
|
|
11
|
+
|
|
12
|
+
static #onSchemaEvent(visitor: ControllerVisitor, type?: Class): unknown | Promise<unknown> {
|
|
13
|
+
return type && SchemaRegistry.has(type) ? visitor.onSchema?.(SchemaRegistry.get(type)) : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static async visitController(visitor: ControllerVisitor, cls: Class, options: ControllerVisitorOptions = {}): Promise<void> {
|
|
17
|
+
if (visitor.getOptions) {
|
|
18
|
+
options = Object.assign(visitor.getOptions(), options);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
options.skipUndocumented ??= true;
|
|
22
|
+
|
|
23
|
+
const controller = ControllerRegistry.get(cls);
|
|
24
|
+
if (!controller || controller.documented === false && options.skipUndocumented) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await visitor.onControllerStart?.(controller);
|
|
29
|
+
for (const endpoint of controller.endpoints) {
|
|
30
|
+
if (endpoint.documented === false && options.skipUndocumented) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const params = SchemaRegistry.getMethodSchema(cls, endpoint.name);
|
|
35
|
+
await visitor.onEndpointStart?.(endpoint, controller, params);
|
|
36
|
+
await this.#onSchemaEvent(visitor, endpoint.responseType?.type);
|
|
37
|
+
await this.#onSchemaEvent(visitor, endpoint.requestType?.type);
|
|
38
|
+
for (const param of params) {
|
|
39
|
+
await this.#onSchemaEvent(visitor, param.type);
|
|
40
|
+
}
|
|
41
|
+
await visitor.onEndpointEnd?.(endpoint, controller, params);
|
|
42
|
+
}
|
|
43
|
+
await visitor.onControllerEnd?.(controller);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static async visit<T = unknown>(visitor: ControllerVisitor<T>, options: ControllerVisitorOptions = {}): Promise<T> {
|
|
47
|
+
for (const cls of ControllerRegistry.getClasses()) {
|
|
48
|
+
await this.visitController(visitor, cls, options);
|
|
49
|
+
}
|
|
50
|
+
return await visitor.onComplete?.() ?? undefined!;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Class, toConcrete } from '@travetto/runtime';
|
|
2
|
+
import { DependencyRegistry } from '@travetto/di';
|
|
3
|
+
|
|
4
|
+
import { ControllerConfig, EndpointConfig } from '../registry/types.ts';
|
|
5
|
+
import { ControllerRegistry } from '../registry/controller.ts';
|
|
6
|
+
|
|
7
|
+
import type { WebRouter } from '../types/dispatch.ts';
|
|
8
|
+
import { WebInterceptor } from '../types/interceptor.ts';
|
|
9
|
+
import { WebResponse } from '../types/response.ts';
|
|
10
|
+
import type { WebFilterContext } from '../types/filter.ts';
|
|
11
|
+
|
|
12
|
+
import { EndpointUtil } from '../util/endpoint.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Supports the base pattern for the most common web router implementations
|
|
16
|
+
*/
|
|
17
|
+
export abstract class BaseWebRouter implements WebRouter {
|
|
18
|
+
|
|
19
|
+
#cleanup = new Map<string, Function>();
|
|
20
|
+
#interceptors: WebInterceptor[];
|
|
21
|
+
|
|
22
|
+
async #register(c: Class): Promise<void> {
|
|
23
|
+
const config = ControllerRegistry.get(c);
|
|
24
|
+
|
|
25
|
+
let endpoints = await EndpointUtil.getBoundEndpoints(c);
|
|
26
|
+
endpoints = EndpointUtil.orderEndpoints(endpoints);
|
|
27
|
+
|
|
28
|
+
for (const ep of endpoints) {
|
|
29
|
+
ep.filter = EndpointUtil.createEndpointHandler(this.#interceptors, ep, config);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.debug('Registering Controller Instance', { id: config.class.Ⲑid, path: config.basePath, endpointCount: endpoints.length });
|
|
33
|
+
const fn = await this.register(endpoints, config);
|
|
34
|
+
this.#cleanup.set(c.Ⲑid, fn);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Initialize router, encapsulating common patterns for standard router setup
|
|
39
|
+
*/
|
|
40
|
+
async postConstruct(): Promise<void> {
|
|
41
|
+
|
|
42
|
+
this.#interceptors = await DependencyRegistry.getCandidateInstances(toConcrete<WebInterceptor>());
|
|
43
|
+
this.#interceptors = EndpointUtil.orderInterceptors(this.#interceptors);
|
|
44
|
+
const names = this.#interceptors.map(x => x.constructor.name);
|
|
45
|
+
console.debug('Sorting interceptors', { count: names.length, names });
|
|
46
|
+
|
|
47
|
+
// Register all active
|
|
48
|
+
for (const c of ControllerRegistry.getClasses()) {
|
|
49
|
+
await this.#register(c);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Listen for updates
|
|
53
|
+
ControllerRegistry.on(async e => {
|
|
54
|
+
console.debug('Registry event', { type: e.type, target: (e.curr ?? e.prev)?.Ⲑid });
|
|
55
|
+
if (e.prev && ControllerRegistry.hasExpired(e.prev)) {
|
|
56
|
+
this.#cleanup.get(e.prev.Ⲑid)?.();
|
|
57
|
+
this.#cleanup.delete(e.prev.Ⲑid);
|
|
58
|
+
}
|
|
59
|
+
if (e.curr) {
|
|
60
|
+
await this.#register(e.curr);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
abstract register(endpoints: EndpointConfig[], controller: ControllerConfig): Promise<() => void>;
|
|
66
|
+
abstract dispatch(ctx: WebFilterContext): Promise<WebResponse>;
|
|
67
|
+
}
|