@tsed/platform-serverless 8.0.1 → 8.0.3
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/package.json +14 -13
- package/src/builder/PlatformServerless.spec.ts +219 -0
- package/src/builder/PlatformServerless.ts +202 -0
- package/src/builder/PlatformServerlessHandler.spec.ts +145 -0
- package/src/builder/PlatformServerlessHandler.ts +121 -0
- package/src/domain/ServerlessContext.spec.ts +106 -0
- package/src/domain/ServerlessContext.ts +60 -0
- package/src/domain/ServerlessEvent.ts +24 -0
- package/src/domain/ServerlessRequest.spec.ts +130 -0
- package/src/domain/ServerlessRequest.ts +106 -0
- package/src/domain/ServerlessResponse.spec.ts +139 -0
- package/src/domain/ServerlessResponse.ts +226 -0
- package/src/domain/ServerlessResponseStream.spec.ts +63 -0
- package/src/domain/ServerlessResponseStream.ts +113 -0
- package/src/exports.ts +1 -0
- package/src/index.ts +14 -0
- package/src/utils/getRequestId.spec.ts +43 -0
- package/src/utils/getRequestId.ts +16 -0
- package/src/utils/isInAWS.ts +8 -0
- package/src/utils/setResponseHeaders.spec.ts +156 -0
- package/src/utils/setResponseHeaders.ts +58 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import {getValue} from "@tsed/core";
|
|
2
|
+
import {getStatusMessage} from "@tsed/schema";
|
|
3
|
+
import type {APIGatewayProxyEvent} from "aws-lambda";
|
|
4
|
+
import encodeUrl from "encodeurl";
|
|
5
|
+
import mime from "mime";
|
|
6
|
+
|
|
7
|
+
import {ServerlessContext} from "./ServerlessContext.js";
|
|
8
|
+
|
|
9
|
+
export type HeaderValue = boolean | number | string;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @platform
|
|
13
|
+
*/
|
|
14
|
+
export class ServerlessResponse<Event extends object = APIGatewayProxyEvent> {
|
|
15
|
+
#status: number = 200;
|
|
16
|
+
#body: any = undefined;
|
|
17
|
+
#headers: Record<string, HeaderValue> = {};
|
|
18
|
+
#locals: Record<string, any> = {};
|
|
19
|
+
#isHeadersSent = false;
|
|
20
|
+
|
|
21
|
+
constructor(protected $ctx: ServerlessContext<Event>) {}
|
|
22
|
+
|
|
23
|
+
get event() {
|
|
24
|
+
return this.$ctx.event;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get raw() {
|
|
28
|
+
return this.event;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get request() {
|
|
32
|
+
return this.$ctx.request;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the current statusCode
|
|
37
|
+
*/
|
|
38
|
+
get statusCode() {
|
|
39
|
+
return this.#status;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
set statusCode(status) {
|
|
43
|
+
this.#status = status;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* An object that contains response local variables scoped to the request, and therefore available only to the view(s) rendered during that request / response cycle (if any). Otherwise, this property is identical to app.locals.
|
|
48
|
+
*
|
|
49
|
+
* This property is useful for exposing request-level information such as the request path name, authenticated user, user settings, and so on.
|
|
50
|
+
*/
|
|
51
|
+
get locals() {
|
|
52
|
+
return this.#locals;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get isHeadersSent() {
|
|
56
|
+
return this.#isHeadersSent;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getStatus() {
|
|
60
|
+
return this.#status;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sets the HTTP status for the response.
|
|
65
|
+
*
|
|
66
|
+
* @param status
|
|
67
|
+
*/
|
|
68
|
+
status(status: number) {
|
|
69
|
+
this.#status = status;
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
set(name: string, value: any) {
|
|
74
|
+
return this.setHeader(name, value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns the HTTP response header specified by field. The match is case-insensitive.
|
|
79
|
+
*
|
|
80
|
+
* ```typescript
|
|
81
|
+
* response.get('Content-Type') // => "text/plain"
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* @param name
|
|
85
|
+
*/
|
|
86
|
+
get(name: string): string | number | boolean | undefined {
|
|
87
|
+
return getValue(this.#headers, name.toLowerCase());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getHeaders(): Record<string, HeaderValue> {
|
|
91
|
+
return {
|
|
92
|
+
...this.#headers
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
hasStatus() {
|
|
97
|
+
return this.#status !== 200;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Set header `field` to `val`, or pass
|
|
102
|
+
* an object of header fields.
|
|
103
|
+
*
|
|
104
|
+
* Examples:
|
|
105
|
+
* ```typescript
|
|
106
|
+
* response.setHeaders({ Accept: 'text/plain', 'X-API-Key': 'tobi' });
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* Aliased as `res.header()`.
|
|
110
|
+
*/
|
|
111
|
+
setHeaders(headers: Record<string, HeaderValue>) {
|
|
112
|
+
// apply headers
|
|
113
|
+
Object.entries(headers).forEach(([key, item]) => {
|
|
114
|
+
this.setHeader(key, item);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setHeader(key: string, item: HeaderValue | null | undefined) {
|
|
121
|
+
if (item !== null && item !== undefined) {
|
|
122
|
+
key = key.toLowerCase();
|
|
123
|
+
|
|
124
|
+
if (key === "location") {
|
|
125
|
+
// "back" is an alias for the referrer
|
|
126
|
+
if (item === "back") {
|
|
127
|
+
item = this.request.get("Referrer") || "/";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
item = encodeUrl(String(item));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.#headers = {
|
|
134
|
+
...this.#headers,
|
|
135
|
+
[key.toLowerCase()]: item as any
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Set `Content-Type` response header with `type` through `mime.lookup()`
|
|
144
|
+
* when it does not contain "/", or set the Content-Type to `type` otherwise.
|
|
145
|
+
*
|
|
146
|
+
* Examples:
|
|
147
|
+
*
|
|
148
|
+
* res.type('.html');
|
|
149
|
+
* res.type('html');
|
|
150
|
+
* res.type('json');
|
|
151
|
+
* res.type('application/json');
|
|
152
|
+
* res.type('png');
|
|
153
|
+
*/
|
|
154
|
+
contentType(contentType: string) {
|
|
155
|
+
this.setHeader("Content-Type", mime.getType(contentType));
|
|
156
|
+
|
|
157
|
+
return this;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
contentLength(length: number) {
|
|
161
|
+
this.setHeader("Content-Length", length);
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
getContentLength() {
|
|
166
|
+
if (this.get("Content-Length")) {
|
|
167
|
+
return parseInt(this.get("Content-Length") as any, 10) || 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getContentType() {
|
|
172
|
+
return String(this.get("Content-Type") || "").split(";")[0];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Redirects to the URL derived from the specified path, with specified status, a positive integer that corresponds to an [HTTP status code](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html).
|
|
177
|
+
* If not specified, status defaults to `302 Found`.
|
|
178
|
+
*
|
|
179
|
+
* @param status
|
|
180
|
+
* @param url
|
|
181
|
+
*/
|
|
182
|
+
redirect(status: number, url: string) {
|
|
183
|
+
// Set location header
|
|
184
|
+
const address = this.location(url).get("Location");
|
|
185
|
+
const body = `${getStatusMessage(status)}. Redirecting to ${address}`;
|
|
186
|
+
|
|
187
|
+
this.status(status).set("Content-Length", Buffer.byteLength(body)).body(body);
|
|
188
|
+
|
|
189
|
+
return this;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Sets the response Location HTTP header to the specified path parameter.
|
|
194
|
+
*
|
|
195
|
+
* @param url
|
|
196
|
+
*/
|
|
197
|
+
location(url: string) {
|
|
198
|
+
return this.set("Location", url);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Send any data to your consumer.
|
|
203
|
+
*
|
|
204
|
+
* This method accept a ReadableStream, a plain object, boolean, string, number, null and undefined data.
|
|
205
|
+
* It choose the better way to send the data.
|
|
206
|
+
*
|
|
207
|
+
* @param body
|
|
208
|
+
*/
|
|
209
|
+
body(body: any) {
|
|
210
|
+
this.#body = body;
|
|
211
|
+
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
getBody() {
|
|
216
|
+
return this.#body;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
isDone(): boolean {
|
|
220
|
+
return this.#isHeadersSent;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
destroy() {
|
|
224
|
+
this.#isHeadersSent = true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {ServerlessResponseStream} from "./ServerlessResponseStream.js";
|
|
2
|
+
|
|
3
|
+
describe("ServerlessResponseStream", () => {
|
|
4
|
+
describe("streamifyResponse", () => {
|
|
5
|
+
describe("when isInAWS", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
// @ts-ignore
|
|
8
|
+
globalThis.awslambda = {
|
|
9
|
+
streamifyResponse: vi.fn().mockImplementation((handler) => handler)
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
// @ts-ignore
|
|
14
|
+
delete globalThis.awslambda;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should wrap then handler without fallback responseStream", async () => {
|
|
18
|
+
const handler = vi.fn();
|
|
19
|
+
const event = {};
|
|
20
|
+
const context = {};
|
|
21
|
+
const responseStream = new ServerlessResponseStream();
|
|
22
|
+
|
|
23
|
+
vi.spyOn(ServerlessResponseStream as any, "buildResponse").mockReturnValue({});
|
|
24
|
+
|
|
25
|
+
const wrapped = ServerlessResponseStream.streamifyResponse(handler);
|
|
26
|
+
|
|
27
|
+
const result = await wrapped(event as any, responseStream, context as any);
|
|
28
|
+
|
|
29
|
+
expect(result).toEqual(undefined);
|
|
30
|
+
expect(handler).toHaveBeenCalledWith(event, responseStream, context);
|
|
31
|
+
// @ts-ignore
|
|
32
|
+
expect(ServerlessResponseStream.buildResponse).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
it("should wrap then handler with fallback responseStream", async () => {
|
|
35
|
+
const handler = vi.fn();
|
|
36
|
+
const event = {};
|
|
37
|
+
const context = {};
|
|
38
|
+
|
|
39
|
+
vi.spyOn(ServerlessResponseStream as any, "buildResponse").mockReturnValue({
|
|
40
|
+
statusCode: 200
|
|
41
|
+
});
|
|
42
|
+
vi.spyOn(console, "warn").mockReturnValue(undefined);
|
|
43
|
+
|
|
44
|
+
const wrapped = ServerlessResponseStream.streamifyResponse(handler);
|
|
45
|
+
|
|
46
|
+
const result = await wrapped(event as any, context as any);
|
|
47
|
+
|
|
48
|
+
expect(handler).toHaveBeenCalledWith(event, expect.any(ServerlessResponseStream), context);
|
|
49
|
+
|
|
50
|
+
expect(result).toEqual({
|
|
51
|
+
statusCode: 200
|
|
52
|
+
});
|
|
53
|
+
// @ts-ignore
|
|
54
|
+
expect(ServerlessResponseStream.buildResponse).toHaveBeenCalledWith(expect.any(ServerlessResponseStream));
|
|
55
|
+
expect(console.warn).toHaveBeenCalledWith({
|
|
56
|
+
event: "WRONG_AWS_STREAM_CONFIGURATION",
|
|
57
|
+
message:
|
|
58
|
+
"The lambda didn't receive a ResponseStream from AWS. Ts.ED therefore generated a ServerlessResponseStream as a fallback. This mode is not optimized for use in PRODUCTION. Please check the AWS configuration of your lambda."
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {Stream} from "node:stream";
|
|
2
|
+
|
|
3
|
+
import type {APIGatewayProxyEventV2, Callback, Context, Handler} from "aws-lambda";
|
|
4
|
+
|
|
5
|
+
import {isInAWS} from "../utils/isInAWS.js";
|
|
6
|
+
|
|
7
|
+
export type ServerlessResponseMeta = {statusCode: number; headers: Record<string, string>};
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
const awsLambda: {
|
|
11
|
+
streamifyResponse(handler: Handler): unknown | Promise<unknown>;
|
|
12
|
+
HttpResponseStream: {
|
|
13
|
+
from(resStream: ServerlessResponseStream | unknown, meta: ServerlessResponseMeta): ServerlessResponseStream;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type RequestHandler = (
|
|
19
|
+
ev: APIGatewayProxyEventV2,
|
|
20
|
+
streamResponse?: ServerlessResponseStream,
|
|
21
|
+
ctx?: Context,
|
|
22
|
+
callback?: Callback
|
|
23
|
+
) => unknown | Promise<unknown>;
|
|
24
|
+
|
|
25
|
+
export class ServerlessResponseStream extends Stream.Writable {
|
|
26
|
+
public _meta: ServerlessResponseMeta = {statusCode: 200, headers: {}};
|
|
27
|
+
#response: Buffer[];
|
|
28
|
+
|
|
29
|
+
constructor() {
|
|
30
|
+
super();
|
|
31
|
+
this.#response = [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static setMeta(resStream: ServerlessResponseStream, meta: ServerlessResponseMeta) {
|
|
35
|
+
if (isInAWS()) {
|
|
36
|
+
// @ts-ignore
|
|
37
|
+
return awslambda.HttpResponseStream.from(resStream, meta);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
resStream.setMeta(meta);
|
|
41
|
+
|
|
42
|
+
return resStream;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static streamifyResponse(handler: RequestHandler): RequestHandler {
|
|
46
|
+
// Check for global awslambda
|
|
47
|
+
if (isInAWS()) {
|
|
48
|
+
// @ts-ignore
|
|
49
|
+
return awslambda.streamifyResponse(
|
|
50
|
+
async (event: APIGatewayProxyEventV2, responseStream: ServerlessResponseStream | Context | undefined, context?: Context) => {
|
|
51
|
+
const isFallbackMode = context === undefined;
|
|
52
|
+
|
|
53
|
+
if (context === undefined) {
|
|
54
|
+
context = responseStream as Context;
|
|
55
|
+
responseStream = new ServerlessResponseStream();
|
|
56
|
+
|
|
57
|
+
console.warn({
|
|
58
|
+
event: "WRONG_AWS_STREAM_CONFIGURATION",
|
|
59
|
+
message:
|
|
60
|
+
"The lambda didn't receive a ResponseStream from AWS. Ts.ED therefore generated a ServerlessResponseStream as a fallback. This mode is not optimized for use in PRODUCTION. Please check the AWS configuration of your lambda."
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await handler(event, responseStream as ServerlessResponseStream, context);
|
|
65
|
+
|
|
66
|
+
if (isFallbackMode) {
|
|
67
|
+
return ServerlessResponseStream.buildResponse(responseStream as ServerlessResponseStream);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
return async (event, response, context) => {
|
|
73
|
+
const args: any[] = patchArgs([event, response, context]);
|
|
74
|
+
const responseStream: ServerlessResponseStream = args[1];
|
|
75
|
+
|
|
76
|
+
await (handler as any)(...args);
|
|
77
|
+
|
|
78
|
+
return ServerlessResponseStream.buildResponse(responseStream);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private static buildResponse(responseStream: ServerlessResponseStream) {
|
|
84
|
+
return {
|
|
85
|
+
...responseStream._meta,
|
|
86
|
+
body: responseStream.getBufferedData().toString()
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// streams, `chunk` may be any JavaScript value.
|
|
91
|
+
_write(chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
|
92
|
+
this.#response.push(Buffer.from(chunk, encoding));
|
|
93
|
+
callback();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setMeta(meta: ServerlessResponseMeta) {
|
|
97
|
+
this._meta = meta;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getBufferedData(): Buffer {
|
|
101
|
+
return Buffer.concat(this.#response);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function patchArgs(args: any[]) {
|
|
106
|
+
if (!(args[1] instanceof ServerlessResponseStream)) {
|
|
107
|
+
const responseStream = new ServerlessResponseStream();
|
|
108
|
+
|
|
109
|
+
return [args[0], responseStream, ...args.splice(1, -1)];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return args;
|
|
113
|
+
}
|
package/src/exports.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {Operation as Lambda} from "@tsed/schema";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Automatically generated by @tsed/barrels.
|
|
3
|
+
*/
|
|
4
|
+
export * from "./builder/PlatformServerless.js";
|
|
5
|
+
export * from "./builder/PlatformServerlessHandler.js";
|
|
6
|
+
export * from "./domain/ServerlessContext.js";
|
|
7
|
+
export * from "./domain/ServerlessEvent.js";
|
|
8
|
+
export * from "./domain/ServerlessRequest.js";
|
|
9
|
+
export * from "./domain/ServerlessResponse.js";
|
|
10
|
+
export * from "./domain/ServerlessResponseStream.js";
|
|
11
|
+
export * from "./exports.js";
|
|
12
|
+
export * from "./utils/getRequestId.js";
|
|
13
|
+
export * from "./utils/isInAWS.js";
|
|
14
|
+
export * from "./utils/setResponseHeaders.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {getRequestId} from "./getRequestId.js";
|
|
2
|
+
|
|
3
|
+
describe("getRequestId", () => {
|
|
4
|
+
it("should return request id (uuid)", () => {
|
|
5
|
+
const event: any = {};
|
|
6
|
+
const context: any = {};
|
|
7
|
+
|
|
8
|
+
expect(typeof getRequestId(event, context)).toEqual("string");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("should return request id (requestId)", () => {
|
|
12
|
+
const event: any = {
|
|
13
|
+
requestContext: {
|
|
14
|
+
requestId: "requestId"
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const context: any = {};
|
|
18
|
+
|
|
19
|
+
expect(getRequestId(event, context)).toEqual("requestId");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should return request id (awsRequestId)", () => {
|
|
23
|
+
const event: any = {};
|
|
24
|
+
const context: any = {
|
|
25
|
+
awsRequestId: "awsRequestId"
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
expect(getRequestId(event, context)).toEqual("awsRequestId");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should return request id (headers)", () => {
|
|
32
|
+
const event: any = {
|
|
33
|
+
headers: {
|
|
34
|
+
"x-request-id": "x-request-id"
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const context: any = {
|
|
38
|
+
awsRequestId: "awsRequestId"
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
expect(getRequestId(event, context)).toEqual("x-request-id");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type {Context} from "aws-lambda";
|
|
2
|
+
import {v4} from "uuid";
|
|
3
|
+
|
|
4
|
+
import type {ServerlessEvent} from "../domain/ServerlessEvent.js";
|
|
5
|
+
|
|
6
|
+
export function getRequestId(event: ServerlessEvent, context: Context) {
|
|
7
|
+
if ("headers" in event && event.headers["x-request-id"]) {
|
|
8
|
+
return event.headers["x-request-id"];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if ("requestContext" in event) {
|
|
12
|
+
return event?.requestContext?.requestId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return context?.awsRequestId || v4().replace(/-/gi, "");
|
|
16
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {PlatformTest} from "@tsed/platform-http/testing";
|
|
2
|
+
import {Get, JsonEntityStore, Redirect, Returns} from "@tsed/schema";
|
|
3
|
+
|
|
4
|
+
import {createServerlessContext} from "../../test/utils/createServerlessContext.js";
|
|
5
|
+
import {setResponseHeaders} from "./setResponseHeaders.js";
|
|
6
|
+
|
|
7
|
+
describe("setResponseHeaders", () => {
|
|
8
|
+
beforeEach(() => PlatformTest.create());
|
|
9
|
+
afterEach(() => PlatformTest.reset());
|
|
10
|
+
|
|
11
|
+
it("should set headers, status and contentType", async () => {
|
|
12
|
+
class Test {
|
|
13
|
+
@Get("/")
|
|
14
|
+
@(Returns(200).Header("x-header", "test"))
|
|
15
|
+
test() {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ctx = createServerlessContext({
|
|
19
|
+
endpoint: JsonEntityStore.fromMethod(Test, "test")
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
vi.spyOn(ctx.response, "setHeaders");
|
|
23
|
+
vi.spyOn(ctx.response, "status");
|
|
24
|
+
// WHEN
|
|
25
|
+
await setResponseHeaders(ctx);
|
|
26
|
+
|
|
27
|
+
// THEN
|
|
28
|
+
expect(ctx.response.setHeaders).toHaveBeenCalledWith({"x-header": "test"});
|
|
29
|
+
expect(ctx.response.status).toHaveBeenCalledWith(200);
|
|
30
|
+
});
|
|
31
|
+
it("should not set headers automatically", async () => {
|
|
32
|
+
class Test {
|
|
33
|
+
@Get("/")
|
|
34
|
+
@(Returns(200).Header("x-header"))
|
|
35
|
+
test() {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ctx = createServerlessContext({
|
|
39
|
+
endpoint: JsonEntityStore.fromMethod(Test, "test")
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
vi.spyOn(ctx.response, "setHeaders");
|
|
43
|
+
vi.spyOn(ctx.response, "status");
|
|
44
|
+
// WHEN
|
|
45
|
+
await setResponseHeaders(ctx);
|
|
46
|
+
|
|
47
|
+
// THEN
|
|
48
|
+
expect(ctx.response.setHeaders).toHaveBeenCalledWith({});
|
|
49
|
+
expect(ctx.response.status).toHaveBeenCalledWith(200);
|
|
50
|
+
});
|
|
51
|
+
it("should redirect with 301", async () => {
|
|
52
|
+
class Test {
|
|
53
|
+
@Get("/")
|
|
54
|
+
@Redirect(301, "/path")
|
|
55
|
+
test() {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ctx = createServerlessContext({
|
|
59
|
+
endpoint: JsonEntityStore.fromMethod(Test, "test")
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
vi.spyOn(ctx.response, "redirect");
|
|
63
|
+
|
|
64
|
+
// WHEN
|
|
65
|
+
await setResponseHeaders(ctx as any);
|
|
66
|
+
|
|
67
|
+
// THEN
|
|
68
|
+
expect(ctx.response.redirect).toHaveBeenCalledWith(301, "/path");
|
|
69
|
+
});
|
|
70
|
+
it("should redirect with default", async () => {
|
|
71
|
+
class Test {
|
|
72
|
+
@Get("/")
|
|
73
|
+
@Redirect(302, "/path")
|
|
74
|
+
test() {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ctx = createServerlessContext({
|
|
78
|
+
endpoint: JsonEntityStore.fromMethod(Test, "test")
|
|
79
|
+
});
|
|
80
|
+
vi.spyOn(ctx.response, "redirect");
|
|
81
|
+
|
|
82
|
+
// WHEN
|
|
83
|
+
await setResponseHeaders(ctx as any);
|
|
84
|
+
|
|
85
|
+
// THEN
|
|
86
|
+
expect(ctx.response.redirect).toHaveBeenCalledWith(302, "/path");
|
|
87
|
+
});
|
|
88
|
+
it("should redirect with assigned value", async () => {
|
|
89
|
+
class Test {
|
|
90
|
+
@Get("/")
|
|
91
|
+
@Redirect(302, "/path")
|
|
92
|
+
test() {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ctx = createServerlessContext({
|
|
96
|
+
endpoint: JsonEntityStore.fromMethod(Test, "test")
|
|
97
|
+
});
|
|
98
|
+
vi.spyOn(ctx.response, "redirect");
|
|
99
|
+
|
|
100
|
+
ctx.response.setHeaders({Location: "/path/other"});
|
|
101
|
+
|
|
102
|
+
// WHEN
|
|
103
|
+
await setResponseHeaders(ctx as any);
|
|
104
|
+
|
|
105
|
+
// THEN
|
|
106
|
+
expect(ctx.response.redirect).toHaveBeenCalledWith(302, "/path/other");
|
|
107
|
+
});
|
|
108
|
+
it("should do nothing when headers is already sent", async () => {
|
|
109
|
+
class Test {
|
|
110
|
+
@Get("/")
|
|
111
|
+
@(Returns(200).Header("x-header", "test"))
|
|
112
|
+
test() {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ctx = createServerlessContext({
|
|
116
|
+
endpoint: JsonEntityStore.fromMethod(Test, "test")
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await ctx.destroy();
|
|
120
|
+
|
|
121
|
+
vi.spyOn(ctx.response, "setHeaders");
|
|
122
|
+
vi.spyOn(ctx.response, "isDone");
|
|
123
|
+
|
|
124
|
+
// WHEN
|
|
125
|
+
await setResponseHeaders(ctx);
|
|
126
|
+
|
|
127
|
+
// THEN
|
|
128
|
+
return expect(ctx.response.isDone).toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
it("should do nothing if the endpoint isn't an operation", async () => {
|
|
131
|
+
class Test {
|
|
132
|
+
test() {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const endpoint = JsonEntityStore.fromMethod(Test, "test");
|
|
136
|
+
|
|
137
|
+
const ctx = createServerlessContext({
|
|
138
|
+
endpoint: {} as never
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
vi.spyOn(ctx.response, "setHeaders");
|
|
142
|
+
vi.spyOn(ctx.response, "isDone");
|
|
143
|
+
vi.spyOn(ctx.logger, "debug");
|
|
144
|
+
|
|
145
|
+
// WHEN
|
|
146
|
+
await setResponseHeaders(ctx);
|
|
147
|
+
|
|
148
|
+
// THEN
|
|
149
|
+
expect(ctx.response.isDone).toHaveBeenCalled();
|
|
150
|
+
expect(ctx.logger.debug).toHaveBeenCalledWith({
|
|
151
|
+
event: "MISSING_OPERATION_METADATA",
|
|
152
|
+
message: "No operation found on the endpoint. The response headers are not set.",
|
|
153
|
+
endpoint: {}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {JsonHeader, JsonOperation} from "@tsed/schema";
|
|
2
|
+
|
|
3
|
+
import {ServerlessContext} from "../domain/ServerlessContext.js";
|
|
4
|
+
import type {ServerlessEvent} from "../domain/ServerlessEvent.js";
|
|
5
|
+
import {HeaderValue} from "../domain/ServerlessResponse.js";
|
|
6
|
+
|
|
7
|
+
function mergeHeaders(specHeaders: Record<string, JsonHeader & {example: string}>, headers: Record<string, HeaderValue>) {
|
|
8
|
+
return Object.entries(specHeaders).reduce((headers, [key, item]) => {
|
|
9
|
+
key = key.toLowerCase();
|
|
10
|
+
|
|
11
|
+
const value = headers[key] === undefined ? item.example : headers[key];
|
|
12
|
+
|
|
13
|
+
if (value !== undefined) {
|
|
14
|
+
return {
|
|
15
|
+
...headers,
|
|
16
|
+
[key]: String(value)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return headers;
|
|
21
|
+
}, headers);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @ignore
|
|
26
|
+
*/
|
|
27
|
+
export function setResponseHeaders(ctx: ServerlessContext<ServerlessEvent>) {
|
|
28
|
+
const {response, endpoint} = ctx;
|
|
29
|
+
if (ctx.isDone()) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const operation = endpoint.operation as JsonOperation | undefined;
|
|
34
|
+
|
|
35
|
+
if (!operation) {
|
|
36
|
+
ctx.logger.debug({
|
|
37
|
+
event: "MISSING_OPERATION_METADATA",
|
|
38
|
+
message: "No operation found on the endpoint. The response headers are not set.",
|
|
39
|
+
endpoint: endpoint
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!response.hasStatus()) {
|
|
45
|
+
// apply status only if isn't already modified
|
|
46
|
+
response.status(operation.getStatus());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const statusCode = response.statusCode;
|
|
50
|
+
const headers = operation.getHeadersOf(response.statusCode);
|
|
51
|
+
const mergedHeaders = mergeHeaders(headers, response.getHeaders());
|
|
52
|
+
|
|
53
|
+
response.setHeaders(mergedHeaders);
|
|
54
|
+
|
|
55
|
+
if (operation.isRedirection(statusCode)) {
|
|
56
|
+
response.redirect(statusCode, String(mergedHeaders["location"]));
|
|
57
|
+
}
|
|
58
|
+
}
|