@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,178 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
|
|
3
|
+
import { Test, Suite, BeforeAll } from '@travetto/test';
|
|
4
|
+
|
|
5
|
+
import { BaseWebSuite } from './base.ts';
|
|
6
|
+
import { TestController } from './controller.ts';
|
|
7
|
+
import { ControllerRegistry } from '../../../src/registry/controller.ts';
|
|
8
|
+
|
|
9
|
+
@Suite()
|
|
10
|
+
export abstract class StandardWebServerSuite extends BaseWebSuite {
|
|
11
|
+
|
|
12
|
+
@BeforeAll()
|
|
13
|
+
async init() {
|
|
14
|
+
ControllerRegistry.register(TestController);
|
|
15
|
+
await ControllerRegistry.install(TestController, { type: 'added' });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Test()
|
|
19
|
+
async getJSON() {
|
|
20
|
+
const response = await this.request({ context: { httpMethod: 'GET', path: '/test/json' } });
|
|
21
|
+
assert.deepStrictEqual(response.body, { json: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test()
|
|
25
|
+
async getParam() {
|
|
26
|
+
const response = await this.request({ context: { httpMethod: 'POST', path: '/test/param/bob' } });
|
|
27
|
+
assert.deepStrictEqual(response.body, { param: 'bob' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test()
|
|
31
|
+
async putQuery() {
|
|
32
|
+
const response = await this.request({
|
|
33
|
+
context: {
|
|
34
|
+
httpMethod: 'PUT', path: '/test/query',
|
|
35
|
+
httpQuery: {
|
|
36
|
+
age: '20'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
assert.deepStrictEqual(response.body, { query: 20 });
|
|
41
|
+
|
|
42
|
+
await assert.rejects(() => this.request({
|
|
43
|
+
context: {
|
|
44
|
+
httpMethod: 'PUT', path: '/test/query',
|
|
45
|
+
httpQuery: {
|
|
46
|
+
age: 'blue'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}), /Validation errors have occurred/i);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Test()
|
|
53
|
+
async postBody() {
|
|
54
|
+
const response = await this.request({
|
|
55
|
+
context: {
|
|
56
|
+
httpMethod: 'PUT', path: '/test/body',
|
|
57
|
+
},
|
|
58
|
+
body: {
|
|
59
|
+
age: 20
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
assert.deepStrictEqual(response.body, { body: 20 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Test()
|
|
66
|
+
async testCookie() {
|
|
67
|
+
const response = await this.request({
|
|
68
|
+
context: {
|
|
69
|
+
httpMethod: 'DELETE', path: '/test/cookie',
|
|
70
|
+
},
|
|
71
|
+
headers: {
|
|
72
|
+
Cookie: 'orange=yummy'
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
const [cookie] = response.headers.getSetCookie();
|
|
76
|
+
assert(cookie !== undefined);
|
|
77
|
+
assert(/flavor.*oreo/.test(cookie));
|
|
78
|
+
assert.deepStrictEqual(response.body, { cookie: 'yummy' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@Test()
|
|
82
|
+
async testRegex() {
|
|
83
|
+
const response = await this.request({ context: { httpMethod: 'PATCH', path: '/test/regexp/super-poodle-party' } });
|
|
84
|
+
assert.deepStrictEqual(response.body, { path: 'poodle' });
|
|
85
|
+
assert(response.headers.has('ETag'));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test()
|
|
89
|
+
async testBuffer() {
|
|
90
|
+
const response = await this.request({ context: { httpMethod: 'GET', path: '/test/buffer' } });
|
|
91
|
+
assert(response.body === 'hello');
|
|
92
|
+
assert(response.headers.has('ETag'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Test()
|
|
96
|
+
async testStream() {
|
|
97
|
+
try {
|
|
98
|
+
const response = await this.request({ context: { httpMethod: 'GET', path: '/test/stream' } });
|
|
99
|
+
assert(response.body === 'hello');
|
|
100
|
+
assert(!response.headers.has('ETag'));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(err);
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@Test()
|
|
108
|
+
async testRenderable() {
|
|
109
|
+
const response = await this.request({ context: { httpMethod: 'GET', path: '/test/renderable' } });
|
|
110
|
+
assert(response.body === 'hello');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test()
|
|
114
|
+
async testFullUrl() {
|
|
115
|
+
const response = await this.request({ context: { httpMethod: 'GET', path: '/test/fullUrl' } });
|
|
116
|
+
assert.deepStrictEqual(response.body, { path: '/test/fullUrl' });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@Test()
|
|
120
|
+
async testHeaderFirst() {
|
|
121
|
+
const response = await this.request({
|
|
122
|
+
context: {
|
|
123
|
+
httpMethod: 'GET', path: '/test/headerFirst',
|
|
124
|
+
},
|
|
125
|
+
headers: {
|
|
126
|
+
age: ['1', '2', '3']
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
assert.deepStrictEqual(response.body, { header: '1' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@Test()
|
|
133
|
+
async testGetIp() {
|
|
134
|
+
const response = await this.request<{ ip: string | undefined }>({ context: { httpMethod: 'GET', path: '/test/ip', connection: { ip: '::1' } } });
|
|
135
|
+
assert(response.body?.ip === '127.0.0.1' || response.body?.ip === '::1');
|
|
136
|
+
|
|
137
|
+
const { body: ret2 } = await this.request<{ ip: string | undefined }>({ context: { httpMethod: 'GET', path: '/test/ip' }, headers: { 'X-Forwarded-For': 'bob' } });
|
|
138
|
+
assert(ret2?.ip === 'bob');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@Test()
|
|
142
|
+
async testErrorThrow() {
|
|
143
|
+
const { context: { httpStatusCode: statusCode } } = await this.request<{ ip: string | undefined }>({ context: { httpMethod: 'POST', path: '/test/ip' } }, false);
|
|
144
|
+
assert(statusCode === 500);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@Test()
|
|
148
|
+
async compressionReturned() {
|
|
149
|
+
{
|
|
150
|
+
const response = await this.request({ context: { httpMethod: 'GET', path: '/test/json' }, headers: { 'Accept-Encoding': 'gzip;q=1' } });
|
|
151
|
+
assert(!response.headers.has('Content-Encoding'));
|
|
152
|
+
assert.deepStrictEqual(response.body, { json: true });
|
|
153
|
+
}
|
|
154
|
+
for (const encoding of ['gzip', 'br', 'deflate']) {
|
|
155
|
+
const response = await this.request({ context: { httpMethod: 'GET', path: '/test/json/large/20000' }, headers: { 'Accept-Encoding': `${encoding};q=1` } });
|
|
156
|
+
const value = response.headers.get('Content-Encoding');
|
|
157
|
+
assert(value === encoding);
|
|
158
|
+
|
|
159
|
+
assert(response.body);
|
|
160
|
+
assert(typeof response.body === 'object');
|
|
161
|
+
assert('json' in response.body);
|
|
162
|
+
assert(typeof response.body.json === 'string');
|
|
163
|
+
assert(response.body.json.startsWith('0123456789'));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
const { headers } = await this.request({ context: { httpMethod: 'GET', path: '/test/json/large/50000' }, headers: { 'Accept-Encoding': 'orange' } }, false);
|
|
168
|
+
assert(!('content-encoding' in headers));
|
|
169
|
+
// assert(status === 406);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@Test()
|
|
174
|
+
async testWildcard() {
|
|
175
|
+
const response = await this.request<{ path: string }>({ context: { httpMethod: 'GET', path: '/test/fun/1/2/3/4' } });
|
|
176
|
+
assert(response.body?.path === '1/2/3/4');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
TransformerState, OnClass, OnMethod, DocUtil, DecoratorUtil, DecoratorMeta, LiteralUtil, AnyType,
|
|
5
|
+
OnProperty
|
|
6
|
+
} from '@travetto/transformer';
|
|
7
|
+
|
|
8
|
+
import { SchemaTransformUtil } from '@travetto/schema/support/transformer/util.ts';
|
|
9
|
+
|
|
10
|
+
const PARAM_DEC_IMPORT = '@travetto/web/src/decorator/param.ts';
|
|
11
|
+
const COMMON_DEC_IMPORT = '@travetto/web/src/decorator/common.ts';
|
|
12
|
+
const ENDPOINT_DEC_IMPORT = '@travetto/web/src/decorator/endpoint.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Handle @Controller, @Endpoint
|
|
16
|
+
*/
|
|
17
|
+
export class WebTransformer {
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Handle endpoint parameter
|
|
21
|
+
*/
|
|
22
|
+
static handleEndpointParameter(state: TransformerState, node: ts.ParameterDeclaration, epDec: DecoratorMeta, idx: number): ts.ParameterDeclaration {
|
|
23
|
+
const pDec = state.findDecorator(this, node, 'Param');
|
|
24
|
+
let pDecArg = DecoratorUtil.getPrimaryArgument(pDec)!;
|
|
25
|
+
if (pDecArg && ts.isStringLiteral(pDecArg)) {
|
|
26
|
+
pDecArg = state.fromLiteral({ name: pDecArg });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const paramType = state.resolveType(node);
|
|
30
|
+
let name = node.name.getText();
|
|
31
|
+
if (/[{}\[\]]/.test(name)) { // Destructured
|
|
32
|
+
name = `param__${idx + 1}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let detectedParamType: string | undefined;
|
|
36
|
+
|
|
37
|
+
const config: { type: AnyType, name?: string } = { type: paramType };
|
|
38
|
+
|
|
39
|
+
// Detect default behavior
|
|
40
|
+
// If primitive
|
|
41
|
+
if (paramType.key !== 'managed' && paramType.key !== 'shape') {
|
|
42
|
+
// Get path of endpoint
|
|
43
|
+
const arg = DecoratorUtil.getPrimaryArgument(epDec.dec);
|
|
44
|
+
// If non-regex
|
|
45
|
+
if (arg && ts.isStringLiteral(arg)) {
|
|
46
|
+
const literal = LiteralUtil.toLiteral(arg);
|
|
47
|
+
if (typeof literal !== 'string') {
|
|
48
|
+
throw new Error(`Unexpected literal type: ${literal}`);
|
|
49
|
+
}
|
|
50
|
+
// If param name matches path param, default to @Path
|
|
51
|
+
detectedParamType = new RegExp(`:${name}\\b`).test(literal) ? 'PathParam' : 'QueryParam';
|
|
52
|
+
} else {
|
|
53
|
+
// Default to query for empty or regex endpoints
|
|
54
|
+
detectedParamType = 'QueryParam';
|
|
55
|
+
}
|
|
56
|
+
} else if (epDec.ident.getText() !== 'All') { // Treat all separate
|
|
57
|
+
// Treat as schema, and see if endpoint supports a body for default behavior on untyped
|
|
58
|
+
detectedParamType = epDec.targets?.includes('@travetto/web:HttpRequestBody') ? 'Body' : 'QueryParam';
|
|
59
|
+
config.name = '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
node = SchemaTransformUtil.computeField(state, node, config);
|
|
63
|
+
|
|
64
|
+
const modifiers = (node.modifiers ?? []).filter(x => x !== pDec);
|
|
65
|
+
const conf = state.extendObjectLiteral({ name, sourceText: node.name.getText() }, pDecArg);
|
|
66
|
+
|
|
67
|
+
if (!pDec) { // Handle default, missing
|
|
68
|
+
modifiers.push(state.createDecorator(PARAM_DEC_IMPORT, detectedParamType ?? 'QueryParam', conf));
|
|
69
|
+
} else if (ts.isCallExpression(pDec.expression)) { // if it does exist, update
|
|
70
|
+
modifiers.push(state.factory.createDecorator(
|
|
71
|
+
state.factory.createCallExpression(
|
|
72
|
+
pDec.expression.expression,
|
|
73
|
+
[],
|
|
74
|
+
[conf, ...pDec.expression.arguments.slice(1)]
|
|
75
|
+
)
|
|
76
|
+
));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return state.factory.updateParameterDeclaration(
|
|
80
|
+
node,
|
|
81
|
+
modifiers,
|
|
82
|
+
node.dotDotDotToken,
|
|
83
|
+
node.name,
|
|
84
|
+
node.questionToken,
|
|
85
|
+
node.type,
|
|
86
|
+
node.initializer
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* On @Endpoint method
|
|
92
|
+
*/
|
|
93
|
+
@OnMethod('Endpoint')
|
|
94
|
+
static handleEndpoint(state: TransformerState, node: ts.MethodDeclaration, dec?: DecoratorMeta): ts.MethodDeclaration {
|
|
95
|
+
|
|
96
|
+
const modifiers = (node.modifiers ?? []).slice(0);
|
|
97
|
+
const newDecls: ts.ModifierLike[] = [];
|
|
98
|
+
|
|
99
|
+
const comments = DocUtil.describeDocs(node);
|
|
100
|
+
|
|
101
|
+
// Handle description/title/summary w/e
|
|
102
|
+
if (comments.description) {
|
|
103
|
+
newDecls.push(state.createDecorator(COMMON_DEC_IMPORT, 'Describe', state.fromLiteral({
|
|
104
|
+
title: comments.description
|
|
105
|
+
})));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let nParams = node.parameters;
|
|
109
|
+
|
|
110
|
+
// Handle parameters
|
|
111
|
+
if (node.parameters.length) {
|
|
112
|
+
const params: ts.ParameterDeclaration[] = [];
|
|
113
|
+
// If there are parameters to process
|
|
114
|
+
let i = 0;
|
|
115
|
+
for (const p of node.parameters) {
|
|
116
|
+
params.push(this.handleEndpointParameter(state, p, dec!, i));
|
|
117
|
+
i += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
nParams = state.factory.createNodeArray(params);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// If we have a valid response type, declare it
|
|
124
|
+
const nodeType = state.resolveReturnType(node);
|
|
125
|
+
let targetType = nodeType;
|
|
126
|
+
|
|
127
|
+
if (nodeType.key === 'literal' && nodeType.typeArguments?.length && nodeType.name === 'Promise') {
|
|
128
|
+
targetType = nodeType.typeArguments[0];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let inner: AnyType | undefined;
|
|
132
|
+
if (targetType.key === 'managed' && targetType.name === 'WebResponse' && targetType.importName.startsWith('@travetto/web')) {
|
|
133
|
+
inner = state.getApparentTypeOfField(targetType.original!, 'body');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const returnType = SchemaTransformUtil.ensureType(state, inner ?? nodeType, node);
|
|
137
|
+
if (returnType.type) {
|
|
138
|
+
newDecls.push(state.createDecorator(ENDPOINT_DEC_IMPORT, 'ResponseType', state.fromLiteral({
|
|
139
|
+
...returnType,
|
|
140
|
+
title: comments.return
|
|
141
|
+
})));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (newDecls.length || nParams !== node.parameters) {
|
|
145
|
+
return state.factory.updateMethodDeclaration(
|
|
146
|
+
node,
|
|
147
|
+
[...modifiers, ...newDecls],
|
|
148
|
+
node.asteriskToken,
|
|
149
|
+
node.name,
|
|
150
|
+
node.questionToken,
|
|
151
|
+
node.typeParameters,
|
|
152
|
+
nParams,
|
|
153
|
+
node.type,
|
|
154
|
+
node.body
|
|
155
|
+
);
|
|
156
|
+
} else {
|
|
157
|
+
return node;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Handle @Controller
|
|
163
|
+
*/
|
|
164
|
+
@OnClass('Controller')
|
|
165
|
+
static handleController(state: TransformerState, node: ts.ClassDeclaration): ts.ClassDeclaration {
|
|
166
|
+
// Read title/description/summary from jsdoc on class
|
|
167
|
+
const comments = DocUtil.describeDocs(node);
|
|
168
|
+
|
|
169
|
+
if (!comments.description) {
|
|
170
|
+
return node;
|
|
171
|
+
} else {
|
|
172
|
+
return state.factory.updateClassDeclaration(
|
|
173
|
+
node,
|
|
174
|
+
[
|
|
175
|
+
...(node.modifiers ?? []),
|
|
176
|
+
state.createDecorator(COMMON_DEC_IMPORT, 'Describe', state.fromLiteral({
|
|
177
|
+
title: comments.description
|
|
178
|
+
}))
|
|
179
|
+
],
|
|
180
|
+
node.name,
|
|
181
|
+
node.typeParameters,
|
|
182
|
+
node.heritageClauses,
|
|
183
|
+
node.members
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Handle ContextParam annotation
|
|
190
|
+
*/
|
|
191
|
+
@OnProperty('ContextParam')
|
|
192
|
+
static registerContextParam(state: TransformerState, node: ts.PropertyDeclaration): typeof node {
|
|
193
|
+
const decl = state.findDecorator(this, node, 'ContextParam', PARAM_DEC_IMPORT);
|
|
194
|
+
|
|
195
|
+
// Doing decls
|
|
196
|
+
return state.factory.updatePropertyDeclaration(
|
|
197
|
+
node,
|
|
198
|
+
DecoratorUtil.spliceDecorators(node, decl, [
|
|
199
|
+
state.createDecorator(PARAM_DEC_IMPORT, 'ContextParam', state.fromLiteral({ target: state.getConcreteType(node) }))
|
|
200
|
+
], 0),
|
|
201
|
+
node.name,
|
|
202
|
+
node.questionToken,
|
|
203
|
+
node.type,
|
|
204
|
+
node.initializer
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|