@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,142 @@
|
|
|
1
|
+
import { AppError, ErrorCategory, TimeSpan, TimeUtil } from '@travetto/runtime';
|
|
2
|
+
|
|
3
|
+
import { WebResponse } from '../types/response.ts';
|
|
4
|
+
import { WebRequest } from '../types/request.ts';
|
|
5
|
+
|
|
6
|
+
type List<T> = T[] | readonly T[];
|
|
7
|
+
type OrderedState<T> = { after?: List<T>, before?: List<T>, key: T };
|
|
8
|
+
|
|
9
|
+
const WebRequestParamsSymbol = Symbol();
|
|
10
|
+
|
|
11
|
+
export type CacheControlFlag =
|
|
12
|
+
'must-revalidate' | 'public' | 'private' | 'no-cache' |
|
|
13
|
+
'no-store' | 'no-transform' | 'proxy-revalidate' | 'immutable' |
|
|
14
|
+
'must-understand' | 'stale-if-error' | 'stale-while-revalidate';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mapping from error category to standard http error codes
|
|
18
|
+
*/
|
|
19
|
+
const ERROR_CATEGORY_STATUS: Record<ErrorCategory, number> = {
|
|
20
|
+
general: 500,
|
|
21
|
+
notfound: 404,
|
|
22
|
+
data: 400,
|
|
23
|
+
permissions: 403,
|
|
24
|
+
authentication: 401,
|
|
25
|
+
timeout: 408,
|
|
26
|
+
unavailable: 503,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class WebCommonUtil {
|
|
30
|
+
static #unitMapping: Record<string, number> = {
|
|
31
|
+
kb: 2 ** 10,
|
|
32
|
+
mb: 2 ** 20,
|
|
33
|
+
gb: 2 ** 30,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
static #buildEdgeMap<T, U extends OrderedState<T>>(items: List<U>): Map<T, Set<T>> {
|
|
37
|
+
const edgeMap = new Map(items.map(x => [x.key, new Set(x.after ?? [])]));
|
|
38
|
+
|
|
39
|
+
// Build out edge map
|
|
40
|
+
for (const input of items) {
|
|
41
|
+
for (const bf of input.before ?? []) {
|
|
42
|
+
if (edgeMap.has(bf)) {
|
|
43
|
+
edgeMap.get(bf)!.add(input.key);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const afterSet = edgeMap.get(input.key)!;
|
|
47
|
+
for (const el of input.after ?? []) {
|
|
48
|
+
afterSet.add(el);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return edgeMap;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Produces a satisfied ordering for a list of orderable elements
|
|
56
|
+
*/
|
|
57
|
+
static ordered<T, U extends OrderedState<T>>(items: List<U>): U[] {
|
|
58
|
+
const edgeMap = this.#buildEdgeMap<T, U>(items);
|
|
59
|
+
|
|
60
|
+
// Loop through all items again
|
|
61
|
+
const keys: T[] = [];
|
|
62
|
+
while (edgeMap.size > 0) {
|
|
63
|
+
|
|
64
|
+
// Find node with no dependencies
|
|
65
|
+
const key = [...edgeMap].find(([, after]) => after.size === 0)?.[0];
|
|
66
|
+
if (!key) {
|
|
67
|
+
throw new Error(`Unsatisfiable dependency: ${[...edgeMap.keys()]}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Store, and remove
|
|
71
|
+
keys.push(key);
|
|
72
|
+
edgeMap.delete(key);
|
|
73
|
+
|
|
74
|
+
// Remove node from all other elements in `all`
|
|
75
|
+
for (const [, rem] of edgeMap) {
|
|
76
|
+
rem.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const inputMap = new Map(items.map(x => [x.key, x]));
|
|
81
|
+
return keys.map(k => inputMap.get(k)!);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get status code
|
|
86
|
+
*/
|
|
87
|
+
static getStatusCode(response: WebResponse): number {
|
|
88
|
+
return (response.headers.has('Content-Range') && response.context.httpStatusCode === 200) ?
|
|
89
|
+
206 :
|
|
90
|
+
response.context.httpStatusCode ?? 200;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* From catch value
|
|
95
|
+
*/
|
|
96
|
+
static catchResponse(err: unknown): WebResponse<Error> {
|
|
97
|
+
if (err instanceof WebResponse) {
|
|
98
|
+
return err;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const body = err instanceof Error ? err :
|
|
102
|
+
(!!err && typeof err === 'object' && ('message' in err && typeof err.message === 'string')) ?
|
|
103
|
+
new AppError(err.message, { details: err }) :
|
|
104
|
+
new AppError(`${err}`);
|
|
105
|
+
|
|
106
|
+
const error: Error & { category?: ErrorCategory, details?: { statusCode: number } } = body;
|
|
107
|
+
const statusCode = error.details?.statusCode ?? ERROR_CATEGORY_STATUS[error.category!] ?? 500;
|
|
108
|
+
|
|
109
|
+
return new WebResponse({ body, context: { httpStatusCode: statusCode } });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get request parameters
|
|
114
|
+
*/
|
|
115
|
+
static getRequestParams(request: WebRequest & { [WebRequestParamsSymbol]?: unknown[] }): unknown[] {
|
|
116
|
+
return request[WebRequestParamsSymbol] ?? [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Set request parameters
|
|
121
|
+
*/
|
|
122
|
+
static setRequestParams(request: WebRequest & { [WebRequestParamsSymbol]?: unknown[] }, params: unknown[]): void {
|
|
123
|
+
request[WebRequestParamsSymbol] ??= params;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get a cache control value
|
|
128
|
+
*/
|
|
129
|
+
static getCacheControlValue(value: number | TimeSpan, flags: CacheControlFlag[] = []): string {
|
|
130
|
+
const delta = TimeUtil.asSeconds(value);
|
|
131
|
+
const finalFlags = delta === 0 ? ['no-cache'] : flags;
|
|
132
|
+
return [...finalFlags, `max-age=${delta}`].join(',');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse byte size
|
|
137
|
+
*/
|
|
138
|
+
static parseByteSize(input: `${number}${'mb' | 'kb' | 'gb' | 'b' | ''}`): number {
|
|
139
|
+
const [, num, unit] = input.toLowerCase().split(/(\d+)/);
|
|
140
|
+
return parseInt(num, 10) * (this.#unitMapping[unit] ?? 1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import keygrip from 'keygrip';
|
|
2
|
+
import { AppError, castKey, castTo } from '@travetto/runtime';
|
|
3
|
+
|
|
4
|
+
import { Cookie, CookieGetOptions } from '../types/cookie.ts';
|
|
5
|
+
|
|
6
|
+
const pairText = (c: Cookie): string => `${c.name}=${c.value}`;
|
|
7
|
+
const pair = (k: string, v: unknown): string => `${k}=${v}`;
|
|
8
|
+
|
|
9
|
+
export class CookieJar {
|
|
10
|
+
|
|
11
|
+
static fromHeaderValue(header: string): Cookie {
|
|
12
|
+
const parts = header.split(/\s{0,4};\s{0,4}/g);
|
|
13
|
+
const [name, value] = parts[0].split(/\s{0,4}=\s{0,4}/);
|
|
14
|
+
const c: Cookie = { name, value };
|
|
15
|
+
for (const p of parts.slice(1)) {
|
|
16
|
+
// eslint-disable-next-line prefer-const
|
|
17
|
+
let [k, v = ''] = p.split(/\s{0,4}=\s{0,4}/);
|
|
18
|
+
if (v[0] === '"') {
|
|
19
|
+
v = v.slice(1, -1);
|
|
20
|
+
}
|
|
21
|
+
if (k === 'expires') {
|
|
22
|
+
c[k] = new Date(v);
|
|
23
|
+
} else {
|
|
24
|
+
c[castKey(k)] = castTo(v || true);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return c;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static toHeaderValue(c: Cookie, response = true): string {
|
|
31
|
+
const header = [pair(c.name, c.value)];
|
|
32
|
+
if (response) {
|
|
33
|
+
if (!c.value) {
|
|
34
|
+
c.expires = new Date(0);
|
|
35
|
+
c.maxAge = undefined;
|
|
36
|
+
}
|
|
37
|
+
if (c.maxAge) {
|
|
38
|
+
c.expires = new Date(Date.now() + c.maxAge);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (c.path) { header.push(pair('path', c.path)); }
|
|
42
|
+
if (c.expires) { header.push(pair('expires', c.expires.toUTCString())); }
|
|
43
|
+
if (c.domain) { header.push(pair('domain', c.domain)); }
|
|
44
|
+
if (c.priority) { header.push(pair('priority', c.priority.toLowerCase())); }
|
|
45
|
+
if (c.sameSite) { header.push(pair('samesite', c.sameSite.toLowerCase())); }
|
|
46
|
+
if (c.secure) { header.push('secure'); }
|
|
47
|
+
if (c.httpOnly) { header.push('httponly'); }
|
|
48
|
+
if (c.partitioned) { header.push('partitioned'); }
|
|
49
|
+
}
|
|
50
|
+
return header.join(';');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#secure?: boolean;
|
|
54
|
+
#grip?: keygrip;
|
|
55
|
+
#cookies: Record<string, Cookie> = {};
|
|
56
|
+
|
|
57
|
+
constructor(input?: string | string[] | null | undefined | Cookie[] | CookieJar, options?: { keys?: string[], secure?: boolean }) {
|
|
58
|
+
this.#grip = options?.keys?.length ? new keygrip(options.keys) : undefined;
|
|
59
|
+
this.#secure = options?.secure ?? false;
|
|
60
|
+
if (input instanceof CookieJar) {
|
|
61
|
+
this.#cookies = { ...input.#cookies };
|
|
62
|
+
} else if (Array.isArray(input)) {
|
|
63
|
+
this.#import(input);
|
|
64
|
+
} else {
|
|
65
|
+
this.#import(input?.split(/\s{0,4},\s{0,4}/) ?? []);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#checkSignature(c: Cookie): Cookie | undefined {
|
|
70
|
+
if (!this.#grip) { return; }
|
|
71
|
+
const key = pairText(c);
|
|
72
|
+
const sc = this.#cookies[`${c.name}.sig`];
|
|
73
|
+
if (!sc.value) { return; }
|
|
74
|
+
|
|
75
|
+
const index = this.#grip.index(key, sc.value);
|
|
76
|
+
c.signed = index >= 0;
|
|
77
|
+
sc.signed = false;
|
|
78
|
+
sc.secure = c.secure;
|
|
79
|
+
|
|
80
|
+
if (index >= 1) {
|
|
81
|
+
sc.value = this.#grip.sign(key);
|
|
82
|
+
sc.response = true;
|
|
83
|
+
return sc;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#signCookie(c: Cookie): Cookie {
|
|
88
|
+
if (!this.#grip) {
|
|
89
|
+
throw new AppError('.keys required for signed cookies');
|
|
90
|
+
} else if (!this.#secure && c.secure) {
|
|
91
|
+
throw new AppError('Cannot send secure cookie over unencrypted connection');
|
|
92
|
+
}
|
|
93
|
+
return { ...c, name: `${c.name}.sig`, value: this.#grip.sign(pairText(c)) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#import(inputs: (string | Cookie)[]): void {
|
|
97
|
+
const toCheck = [];
|
|
98
|
+
for (const input of inputs) {
|
|
99
|
+
const c = typeof input === 'string' ? CookieJar.fromHeaderValue(input) : input;
|
|
100
|
+
this.#cookies[c.name] = c;
|
|
101
|
+
if (this.#grip && !c.name.endsWith('.sig')) {
|
|
102
|
+
toCheck.push(c);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const c of toCheck) {
|
|
106
|
+
const sc = this.#checkSignature(c);
|
|
107
|
+
if (sc) {
|
|
108
|
+
this.set(sc);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get(name: string, opts: CookieGetOptions = {}): string | undefined {
|
|
114
|
+
const c = this.#cookies[name];
|
|
115
|
+
return (c?.signed || !(opts.signed ?? !!this.#grip)) ? c?.value : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
set(c: Cookie): void {
|
|
119
|
+
this.#cookies[c.name] = c;
|
|
120
|
+
c.secure ??= this.#secure;
|
|
121
|
+
c.signed ??= !!this.#grip;
|
|
122
|
+
c.response = true;
|
|
123
|
+
|
|
124
|
+
if (c.value === null || c.value === undefined) {
|
|
125
|
+
c.maxAge = -1;
|
|
126
|
+
c.expires = undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (c.signed) {
|
|
130
|
+
const sc = this.#signCookie(c);
|
|
131
|
+
this.#cookies[sc.name] = sc;
|
|
132
|
+
sc.response = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export(response = true): string[] {
|
|
137
|
+
return this.getAll()
|
|
138
|
+
.filter(x => !response || x.response)
|
|
139
|
+
.map(c => CookieJar.toHeaderValue(c, response));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getAll(): Cookie[] {
|
|
143
|
+
return Object.values(this.#cookies);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { asConstructable, castTo, Class, Runtime, TypedObject } from '@travetto/runtime';
|
|
2
|
+
import { BindUtil, FieldConfig, SchemaRegistry, SchemaValidator, ValidationResultError } from '@travetto/schema';
|
|
3
|
+
import { DependencyRegistry } from '@travetto/di';
|
|
4
|
+
import { RetargettingProxy } from '@travetto/registry';
|
|
5
|
+
|
|
6
|
+
import { WebChainedFilter, WebChainedContext, WebFilter } from '../types/filter.ts';
|
|
7
|
+
import { WebResponse } from '../types/response.ts';
|
|
8
|
+
import { WebInterceptor } from '../types/interceptor.ts';
|
|
9
|
+
import { WebRequest } from '../types/request.ts';
|
|
10
|
+
import { WEB_INTERCEPTOR_CATEGORIES } from '../types/core.ts';
|
|
11
|
+
import { EndpointConfig, ControllerConfig, EndpointParamConfig } from '../registry/types.ts';
|
|
12
|
+
import { ControllerRegistry } from '../registry/controller.ts';
|
|
13
|
+
import { WebCommonUtil } from './common.ts';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
const WebQueryExpandedSymbol = Symbol();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Endpoint specific utilities
|
|
20
|
+
*/
|
|
21
|
+
export class EndpointUtil {
|
|
22
|
+
|
|
23
|
+
static #compareEndpoints(a: number[], b: number[]): number {
|
|
24
|
+
const al = a.length;
|
|
25
|
+
const bl = b.length;
|
|
26
|
+
if (al !== bl) {
|
|
27
|
+
return bl - al;
|
|
28
|
+
}
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < al) {
|
|
31
|
+
if (a[i] !== b[i]) {
|
|
32
|
+
return b[i] - a[i];
|
|
33
|
+
}
|
|
34
|
+
i += 1;
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static MissingParamSymbol = Symbol();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a full filter chain given the provided filters
|
|
43
|
+
* @param filters Filters to chain
|
|
44
|
+
*/
|
|
45
|
+
static createFilterChain(filters: { filter: WebChainedFilter, config?: unknown }[]): WebChainedFilter {
|
|
46
|
+
const len = filters.length - 1;
|
|
47
|
+
return function filterChain(ctx: WebChainedContext, idx: number = 0): Promise<WebResponse> {
|
|
48
|
+
const { filter, config } = filters[idx]!;
|
|
49
|
+
const chainedNext = idx === len ? ctx.next : filterChain.bind(null, ctx, idx + 1);
|
|
50
|
+
return filter({ request: ctx.request, next: chainedNext, config });
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve interceptors with configs
|
|
56
|
+
* @param interceptors
|
|
57
|
+
* @param endpoint
|
|
58
|
+
* @param controller
|
|
59
|
+
*/
|
|
60
|
+
static resolveInterceptorsWithConfig(
|
|
61
|
+
interceptors: WebInterceptor[],
|
|
62
|
+
endpoint: EndpointConfig,
|
|
63
|
+
controller?: ControllerConfig
|
|
64
|
+
): [WebInterceptor, unknown][] {
|
|
65
|
+
|
|
66
|
+
const inputByClass = Map.groupBy(
|
|
67
|
+
[...controller?.interceptorConfigs ?? [], ...endpoint.interceptorConfigs ?? []],
|
|
68
|
+
x => x[0]
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const configs = new Map<Class, unknown>(interceptors.map(inst => {
|
|
72
|
+
const cls = asConstructable<WebInterceptor>(inst).constructor;
|
|
73
|
+
const inputs = (inputByClass.get(cls) ?? []).map(x => x[1]);
|
|
74
|
+
const config = Object.assign({}, inst.config, ...inputs);
|
|
75
|
+
return [cls, inst.finalizeConfig?.({ config, endpoint }, castTo(inputs)) ?? config];
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
return interceptors.map(inst => [
|
|
79
|
+
inst,
|
|
80
|
+
configs.get(asConstructable(inst).constructor)
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract parameter from request
|
|
86
|
+
*/
|
|
87
|
+
static extractParameter(request: WebRequest, param: EndpointParamConfig, field: FieldConfig, value?: unknown): unknown {
|
|
88
|
+
if (value !== undefined && value !== this.MissingParamSymbol) {
|
|
89
|
+
return value;
|
|
90
|
+
} else if (param.extract) {
|
|
91
|
+
return param.extract(request, param);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const name = param.name!;
|
|
95
|
+
switch (param.location) {
|
|
96
|
+
case 'path': return request.context.pathParams?.[name];
|
|
97
|
+
case 'header': return field.array ? request.headers.getList(name) : request.headers.get(name);
|
|
98
|
+
case 'body': return request.body;
|
|
99
|
+
case 'query': {
|
|
100
|
+
const withQuery: typeof request & { [WebQueryExpandedSymbol]?: Record<string, unknown> } = request;
|
|
101
|
+
const q = withQuery[WebQueryExpandedSymbol] ??= BindUtil.expandPaths(request.context.httpQuery ?? {});
|
|
102
|
+
return param.prefix ? q[param.prefix] : (field.type.Ⲑid ? q : q[name]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract all parameters for a given endpoint/request/response combo
|
|
109
|
+
* @param endpoint The endpoint to extract for
|
|
110
|
+
* @param req The request
|
|
111
|
+
* @param res The response
|
|
112
|
+
*/
|
|
113
|
+
static async extractParameters(endpoint: EndpointConfig, request: WebRequest): Promise<unknown[]> {
|
|
114
|
+
const cls = endpoint.class;
|
|
115
|
+
const method = endpoint.name;
|
|
116
|
+
const vals = WebCommonUtil.getRequestParams(request);
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const fields = SchemaRegistry.getMethodSchema(cls, method);
|
|
120
|
+
const extracted = endpoint.params.map((c, i) => this.extractParameter(request, c, fields[i], vals?.[i]));
|
|
121
|
+
const params = BindUtil.coerceMethodParams(cls, method, extracted);
|
|
122
|
+
await SchemaValidator.validateMethod(cls, method, params, endpoint.params.map(x => x.prefix));
|
|
123
|
+
return params;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err instanceof ValidationResultError) {
|
|
126
|
+
for (const el of err.details?.errors ?? []) {
|
|
127
|
+
if (el.kind === 'required') {
|
|
128
|
+
const config = endpoint.params.find(x => x.name === el.path);
|
|
129
|
+
if (config) {
|
|
130
|
+
el.message = `Missing ${config.location.replace(/s$/, '')}: ${config.name}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Endpoint invocation code
|
|
141
|
+
*/
|
|
142
|
+
static async invokeEndpoint(endpoint: EndpointConfig, { request }: WebChainedContext): Promise<WebResponse> {
|
|
143
|
+
try {
|
|
144
|
+
const params = await this.extractParameters(endpoint, request);
|
|
145
|
+
const body = await endpoint.endpoint.apply(endpoint.instance, params);
|
|
146
|
+
const headers = endpoint.responseHeaderMap;
|
|
147
|
+
const response = body instanceof WebResponse ? body : new WebResponse({ body, headers });
|
|
148
|
+
if (response === body) {
|
|
149
|
+
for (const [k, v] of headers) { response.headers.setIfAbsent(k, v); }
|
|
150
|
+
}
|
|
151
|
+
return endpoint.responseFinalizer?.(response) ?? response;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw WebCommonUtil.catchResponse(err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Create a full endpoint handler
|
|
159
|
+
* @param interceptors Interceptors to apply
|
|
160
|
+
* @param endpoint The endpoint to call
|
|
161
|
+
* @param controller The controller to tie to
|
|
162
|
+
*/
|
|
163
|
+
static createEndpointHandler(
|
|
164
|
+
interceptors: WebInterceptor[],
|
|
165
|
+
endpoint: EndpointConfig,
|
|
166
|
+
controller?: ControllerConfig
|
|
167
|
+
): WebFilter {
|
|
168
|
+
|
|
169
|
+
// Filter interceptors if needed
|
|
170
|
+
for (const filter of [controller?.interceptorExclude, endpoint.interceptorExclude]) {
|
|
171
|
+
interceptors = filter ? interceptors.filter(x => !filter(x)) : interceptors;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const interceptorFilters =
|
|
175
|
+
this.resolveInterceptorsWithConfig(interceptors, endpoint, controller)
|
|
176
|
+
.filter(([inst, config]) => inst.applies?.({ endpoint, config }) ?? true)
|
|
177
|
+
.map(([inst, config]) => ({ filter: inst.filter.bind(inst), config }));
|
|
178
|
+
|
|
179
|
+
const endpointFilters = [
|
|
180
|
+
...(controller?.filters ?? []).map(fn => fn.bind(controller?.instance)),
|
|
181
|
+
...(endpoint.filters ?? []).map(fn => fn.bind(endpoint.instance)),
|
|
182
|
+
...(endpoint.params.filter(cfg => cfg.resolve).map(fn => fn.resolve!))
|
|
183
|
+
]
|
|
184
|
+
.map(fn => ({ filter: fn }));
|
|
185
|
+
|
|
186
|
+
const result = this.createFilterChain([
|
|
187
|
+
...interceptorFilters,
|
|
188
|
+
...endpointFilters,
|
|
189
|
+
{ filter: this.invokeEndpoint.bind(this, endpoint) }
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
return castTo(result);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get bound endpoints, honoring the conditional status
|
|
198
|
+
*/
|
|
199
|
+
static async getBoundEndpoints(c: Class): Promise<EndpointConfig[]> {
|
|
200
|
+
const config = ControllerRegistry.get(c);
|
|
201
|
+
|
|
202
|
+
// Skip registering conditional controllers
|
|
203
|
+
if (config.conditional && !await config.conditional()) {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
config.instance = await DependencyRegistry.getInstance(config.class);
|
|
208
|
+
|
|
209
|
+
if (Runtime.dynamic) {
|
|
210
|
+
config.instance = RetargettingProxy.unwrap(config.instance);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Filter out conditional endpoints
|
|
214
|
+
const endpoints = (await Promise.all(
|
|
215
|
+
config.endpoints.map(ep => Promise.resolve(ep.conditional?.() ?? true).then(v => v ? ep : undefined))
|
|
216
|
+
)).filter(x => !!x);
|
|
217
|
+
|
|
218
|
+
if (!endpoints.length) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const ep of endpoints) {
|
|
223
|
+
ep.instance = config.instance;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return endpoints;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Order endpoints by a set of rules, to ensure consistent registration and that precedence is honored
|
|
231
|
+
*/
|
|
232
|
+
static orderEndpoints(endpoints: EndpointConfig[]): EndpointConfig[] {
|
|
233
|
+
return endpoints
|
|
234
|
+
.map(ep => {
|
|
235
|
+
const parts = ep.path.replace(/^[/]|[/]$/g, '').split('/');
|
|
236
|
+
return [ep, parts.map(x => /[*]/.test(x) ? 1 : /:/.test(x) ? 2 : 3)] as const;
|
|
237
|
+
})
|
|
238
|
+
.toSorted((a, b) => this.#compareEndpoints(a[1], b[1]) || a[0].path.localeCompare(b[0].path))
|
|
239
|
+
.map(([ep,]) => ep);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Order interceptors
|
|
245
|
+
*/
|
|
246
|
+
static orderInterceptors(instances: WebInterceptor[]): WebInterceptor[] {
|
|
247
|
+
const cats = WEB_INTERCEPTOR_CATEGORIES.map(x => ({
|
|
248
|
+
key: x,
|
|
249
|
+
start: castTo<Class<WebInterceptor>>({ name: `${x}Start` }),
|
|
250
|
+
end: castTo<Class<WebInterceptor>>({ name: `${x}End` }),
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
const categoryMapping = TypedObject.fromEntries(cats.map(x => [x.key, x]));
|
|
254
|
+
|
|
255
|
+
const ordered = instances.map(x => {
|
|
256
|
+
const group = categoryMapping[x.category];
|
|
257
|
+
const after = [...x.dependsOn ?? [], group.start];
|
|
258
|
+
const before = [...x.runsBefore ?? [], group.end];
|
|
259
|
+
return ({ key: x.constructor, before, after, target: x, placeholder: false });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Add category sets into the ordering
|
|
263
|
+
let i = 0;
|
|
264
|
+
for (const cat of cats) {
|
|
265
|
+
const prevEnd = cats[i - 1]?.end ? [cats[i - 1].end] : [];
|
|
266
|
+
ordered.push(
|
|
267
|
+
{ key: cat.start, before: [cat.end], after: prevEnd, placeholder: true, target: undefined! },
|
|
268
|
+
{ key: cat.end, before: [], after: [cat.start], placeholder: true, target: undefined! }
|
|
269
|
+
);
|
|
270
|
+
i += 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return WebCommonUtil.ordered(ordered)
|
|
274
|
+
.filter(x => !x.placeholder) // Drop out the placeholders
|
|
275
|
+
.map(x => x.target);
|
|
276
|
+
}
|
|
277
|
+
}
|
package/src/util/mime.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Util } from '@travetto/runtime';
|
|
2
|
+
|
|
3
|
+
export type MimeType = { type: string, subtype: string, full: string, parameters: Record<string, string> };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Utils for checking mime patterns
|
|
7
|
+
*/
|
|
8
|
+
export class MimeUtil {
|
|
9
|
+
|
|
10
|
+
static #convert(rule: string): RegExp {
|
|
11
|
+
const core = (rule.endsWith('/*') || !rule.includes('/')) ?
|
|
12
|
+
`${rule.replace(/[/].{0,20}$/, '')}\/.*` : rule;
|
|
13
|
+
return new RegExp(`^${core}[ ]{0,10}(;|$)`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static parse(mimeType?: string): MimeType | undefined {
|
|
17
|
+
if (mimeType) {
|
|
18
|
+
const [full, ...params] = mimeType.split(/;/).map(x => x.trim());
|
|
19
|
+
const [type, subtype] = full.split('/');
|
|
20
|
+
const parameters = Object.fromEntries(params.map(v => v.split('=')).map(([k, v]) => [k.toLowerCase(), v]));
|
|
21
|
+
return { type, subtype, full, parameters };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build matcher
|
|
27
|
+
*/
|
|
28
|
+
static matcher(rules: string[] | string = []): (contentType: string) => boolean {
|
|
29
|
+
return Util.allowDeny<RegExp, [string]>(
|
|
30
|
+
rules,
|
|
31
|
+
this.#convert.bind(this),
|
|
32
|
+
(regex, mime) => regex.test(mime),
|
|
33
|
+
k => k
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/util/net.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
import { ExecUtil } from '@travetto/runtime';
|
|
6
|
+
|
|
7
|
+
/** Net utilities */
|
|
8
|
+
export class NetUtil {
|
|
9
|
+
|
|
10
|
+
/** Is an error an address in use error */
|
|
11
|
+
static isPortUsedError(err: unknown): err is Error & { port: number } {
|
|
12
|
+
return !!err && err instanceof Error && err.message.includes('EADDRINUSE');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Get the port process id */
|
|
16
|
+
static async getPortProcessId(port: number): Promise<number | undefined> {
|
|
17
|
+
const proc = spawn('lsof', ['-t', '-i', `tcp:${port}`]);
|
|
18
|
+
const result = await ExecUtil.getResult(proc, { catch: true });
|
|
19
|
+
const [pid] = result.stdout.trim().split(/\n/g);
|
|
20
|
+
if (pid && +pid > 0) {
|
|
21
|
+
return +pid;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Free port if in use */
|
|
26
|
+
static async freePort(port: number): Promise<void> {
|
|
27
|
+
const pid = await this.getPortProcessId(port);
|
|
28
|
+
if (pid) {
|
|
29
|
+
process.kill(pid);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Find free port */
|
|
34
|
+
static async getFreePort(): Promise<number> {
|
|
35
|
+
return new Promise<number>((resolve, reject) => {
|
|
36
|
+
const server = net.createServer();
|
|
37
|
+
server.unref();
|
|
38
|
+
server.on('error', reject);
|
|
39
|
+
|
|
40
|
+
server.listen({ port: 0 }, () => {
|
|
41
|
+
const addr = server.address();
|
|
42
|
+
if (!addr || typeof addr === 'string') {
|
|
43
|
+
reject(new Error('Unable to get a free port'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const { port } = addr;
|
|
47
|
+
server.close(() => { resolve(port); });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get local address for listening
|
|
54
|
+
*/
|
|
55
|
+
static getLocalAddress(): string {
|
|
56
|
+
const useIPv4 = !![...Object.values(os.networkInterfaces())]
|
|
57
|
+
.find(interfaces => interfaces?.find(nic => nic.family === 'IPv4'));
|
|
58
|
+
|
|
59
|
+
return useIPv4 ? '0.0.0.0' : '::';
|
|
60
|
+
}
|
|
61
|
+
}
|