Skip to content

Commit e9ef02e

Browse files
authored
feat(handler): update header and cookie support for Response (#296)
1 parent 62ff180 commit e9ef02e

File tree

5 files changed

+200
-15
lines changed

5 files changed

+200
-15
lines changed

packages/runtime-handler/__tests__/dev-runtime/internal/response.test.ts

+132-7
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ test('has correct defaults', () => {
55
const response = new Response();
66
expect(response['body']).toBeNull();
77
expect(response['statusCode']).toBe(200);
8-
expect(response['headers']).toEqual({});
8+
expect(response['headers']).toEqual({
9+
'Set-Cookie': [],
10+
});
911
});
1012

1113
test('sets status code, body and headers from constructor', () => {
@@ -24,6 +26,7 @@ test('sets status code, body and headers from constructor', () => {
2426
'Access-Control-Allow-Origin': 'example.com',
2527
'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE',
2628
'Access-Control-Allow-Headers': 'Content-Type',
29+
'Set-Cookie': [],
2730
});
2831
});
2932

@@ -45,7 +48,9 @@ test('sets body correctly', () => {
4548

4649
test('sets headers correctly', () => {
4750
const response = new Response();
48-
expect(response['headers']).toEqual({});
51+
expect(response['headers']).toEqual({
52+
'Set-Cookie': [],
53+
});
4954
response.setHeaders({
5055
'Access-Control-Allow-Origin': 'example.com',
5156
'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE',
@@ -55,35 +60,136 @@ test('sets headers correctly', () => {
5560
'Access-Control-Allow-Origin': 'example.com',
5661
'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE',
5762
'Access-Control-Allow-Headers': 'Content-Type',
63+
'Set-Cookie': [],
5864
};
5965
expect(response['headers']).toEqual(expected);
6066
// @ts-ignore
6167
response.setHeaders(undefined);
6268
expect(response['headers']).toEqual(expected);
6369
});
6470

71+
test('sets headers with string cookies', () => {
72+
const response = new Response();
73+
expect(response['headers']).toEqual({
74+
'Set-Cookie': [],
75+
});
76+
response.setHeaders({
77+
'Access-Control-Allow-Origin': 'example.com',
78+
'Set-Cookie': 'Hi=Bye',
79+
});
80+
const expected = {
81+
'Access-Control-Allow-Origin': 'example.com',
82+
'Set-Cookie': ['Hi=Bye'],
83+
};
84+
expect(response['headers']).toEqual(expected);
85+
});
86+
87+
test('sets headers with an array of cookies', () => {
88+
const response = new Response();
89+
expect(response['headers']).toEqual({
90+
'Set-Cookie': [],
91+
});
92+
response.setHeaders({
93+
'Access-Control-Allow-Origin': 'example.com',
94+
'Set-Cookie': ['Hi=Bye', 'Hello=World'],
95+
});
96+
const expected = {
97+
'Access-Control-Allow-Origin': 'example.com',
98+
'Set-Cookie': ['Hi=Bye', 'Hello=World'],
99+
};
100+
expect(response['headers']).toEqual(expected);
101+
});
102+
65103
test('appends a new header correctly', () => {
66104
const response = new Response();
67-
expect(response['headers']).toEqual({});
105+
expect(response['headers']).toEqual({
106+
'Set-Cookie': [],
107+
});
68108
response.appendHeader('Access-Control-Allow-Origin', 'dkundel.com');
69109
expect(response['headers']).toEqual({
70110
'Access-Control-Allow-Origin': 'dkundel.com',
111+
'Set-Cookie': [],
71112
});
72113
response.appendHeader('Content-Type', 'application/json');
73114
expect(response['headers']).toEqual({
74115
'Access-Control-Allow-Origin': 'dkundel.com',
75116
'Content-Type': 'application/json',
117+
'Set-Cookie': [],
76118
});
77119
});
78120

79121
test('appends a header correctly with no existing one', () => {
80122
const response = new Response();
81-
expect(response['headers']).toEqual({});
123+
expect(response['headers']).toEqual({
124+
'Set-Cookie': [],
125+
});
82126
// @ts-ignore
83127
response['headers'] = undefined;
84128
response.appendHeader('Access-Control-Allow-Origin', 'dkundel.com');
85129
expect(response['headers']).toEqual({
86130
'Access-Control-Allow-Origin': 'dkundel.com',
131+
'Set-Cookie': [],
132+
});
133+
});
134+
135+
test('appends multi value headers', () => {
136+
const response = new Response();
137+
expect(response['headers']).toEqual({
138+
'Set-Cookie': [],
139+
});
140+
response.appendHeader('Access-Control-Allow-Origin', 'dkundel.com');
141+
response.appendHeader('Access-Control-Allow-Origin', 'philna.sh');
142+
response.appendHeader('Access-Control-Allow-Methods', 'GET');
143+
response.appendHeader('Access-Control-Allow-Methods', 'DELETE');
144+
response.appendHeader('Access-Control-Allow-Methods', ['PUT', 'POST']);
145+
expect(response['headers']).toEqual({
146+
'Access-Control-Allow-Origin': ['dkundel.com', 'philna.sh'],
147+
'Access-Control-Allow-Methods': ['GET', 'DELETE', 'PUT', 'POST'],
148+
'Set-Cookie': [],
149+
});
150+
});
151+
152+
test('sets a single cookie correctly', () => {
153+
const response = new Response();
154+
expect(response['headers']).toEqual({
155+
'Set-Cookie': [],
156+
});
157+
response.setCookie('name', 'value');
158+
expect(response['headers']).toEqual({
159+
'Set-Cookie': ['name=value'],
160+
});
161+
});
162+
163+
test('sets a cookie with attributes', () => {
164+
const response = new Response();
165+
expect(response['headers']).toEqual({
166+
'Set-Cookie': [],
167+
});
168+
response.setCookie('Hello', 'World', [
169+
'HttpOnly',
170+
'Secure',
171+
'SameSite=Strict',
172+
'Max-Age=86400',
173+
]);
174+
expect(response['headers']).toEqual({
175+
'Set-Cookie': ['Hello=World;HttpOnly;Secure;SameSite=Strict;Max-Age=86400'],
176+
});
177+
});
178+
179+
test('removes a cookie', () => {
180+
const response = new Response();
181+
expect(response['headers']).toEqual({
182+
'Set-Cookie': [],
183+
});
184+
response.setCookie('Hello', 'World', [
185+
'HttpOnly',
186+
'Secure',
187+
'SameSite=Strict',
188+
'Max-Age=86400',
189+
]);
190+
response.removeCookie('Hello');
191+
expect(response['headers']).toEqual({
192+
'Set-Cookie': ['Hello=;Max-Age=0'],
87193
});
88194
});
89195

@@ -107,6 +213,16 @@ test('appendHeader returns the response', () => {
107213
expect(response.appendHeader('X-Test', 'Hello')).toBe(response);
108214
});
109215

216+
test('setCookie returns the response', () => {
217+
const response = new Response();
218+
expect(response.setCookie('name', 'value')).toBe(response);
219+
});
220+
221+
test('removeCookie returns the response', () => {
222+
const response = new Response();
223+
expect(response.removeCookie('name')).toBe(response);
224+
});
225+
110226
test('calls express response correctly', () => {
111227
const mockRes = {
112228
status: jest.fn(),
@@ -121,7 +237,10 @@ test('calls express response correctly', () => {
121237

122238
expect(mockRes.send).toHaveBeenCalledWith(`I'm a teapot!`);
123239
expect(mockRes.status).toHaveBeenCalledWith(418);
124-
expect(mockRes.set).toHaveBeenCalledWith({ 'Content-Type': 'text/plain' });
240+
expect(mockRes.set).toHaveBeenCalledWith({
241+
'Content-Type': 'text/plain',
242+
'Set-Cookie': [],
243+
});
125244
});
126245

127246
test('serializes a response', () => {
@@ -134,7 +253,10 @@ test('serializes a response', () => {
134253

135254
expect(serialized.body).toEqual("I'm a teapot!");
136255
expect(serialized.statusCode).toEqual(418);
137-
expect(serialized.headers).toEqual({ 'Content-Type': 'text/plain' });
256+
expect(serialized.headers).toEqual({
257+
'Content-Type': 'text/plain',
258+
'Set-Cookie': [],
259+
});
138260
});
139261

140262
test('serializes a response with content type set to application/json', () => {
@@ -149,5 +271,8 @@ test('serializes a response with content type set to application/json', () => {
149271
JSON.stringify({ url: 'https://dkundel.com' })
150272
);
151273
expect(serialized.statusCode).toEqual(200);
152-
expect(serialized.headers).toEqual({ 'Content-Type': 'application/json' });
274+
expect(serialized.headers).toEqual({
275+
'Content-Type': 'application/json',
276+
'Set-Cookie': [],
277+
});
153278
});

packages/runtime-handler/__tests__/dev-runtime/route.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@ describe('handleSuccess function', () => {
525525
expect(mockResponse.send).toHaveBeenCalledWith({ data: 'Something' });
526526
expect(mockResponse.set).toHaveBeenCalledWith({
527527
'Content-Type': 'application/json',
528+
'Set-Cookie': [],
528529
});
529530
expect(mockResponse.type).not.toHaveBeenCalled();
530531
});

packages/runtime-handler/src/dev-runtime/internal/functionRunner.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types';
22
import { serializeError } from 'serialize-error';
33
import { constructContext, constructGlobalScope, isTwiml } from '../route';
4-
import { ServerConfig } from '../types';
4+
import { ServerConfig, Headers } from '../types';
55
import { Response } from './response';
66
import { setRoutes } from './route-cache';
77

@@ -11,7 +11,7 @@ const sendDebugMessage = (debugMessage: string, ...debugArgs: any) => {
1111

1212
export type Reply = {
1313
body?: string | number | boolean | object;
14-
headers?: { [key: string]: number | string };
14+
headers?: Headers;
1515
statusCode: number;
1616
};
1717

packages/runtime-handler/src/dev-runtime/internal/response.ts

+60-6
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
11
import { TwilioResponse } from '@twilio-labs/serverless-runtime-types/types';
2+
import { Headers, HeaderValue } from '../types';
23
import { Response as ExpressResponse } from 'express';
34
import debug from '../utils/debug';
45

56
const log = debug('twilio-runtime-handler:dev:response');
7+
const COOKIE_HEADER = 'Set-Cookie';
68

79
type ResponseOptions = {
810
headers?: Headers;
911
statusCode?: number;
1012
body?: object | string;
1113
};
1214

13-
type HeaderValue = number | string;
14-
type Headers = {
15-
[key: string]: HeaderValue;
16-
};
17-
1815
export class Response implements TwilioResponse {
1916
private body: null | any;
2017
private statusCode: number;
@@ -34,6 +31,15 @@ export class Response implements TwilioResponse {
3431
if (options && options.headers) {
3532
this.headers = options.headers;
3633
}
34+
35+
// if Set-Cookie is not already in the headers, then add it as an empty list
36+
const cookieHeader = this.headers[COOKIE_HEADER];
37+
if (!(COOKIE_HEADER in this.headers)) {
38+
this.headers[COOKIE_HEADER] = [];
39+
}
40+
if (!Array.isArray(cookieHeader) && typeof cookieHeader !== 'undefined') {
41+
this.headers[COOKIE_HEADER] = [cookieHeader];
42+
}
3743
}
3844

3945
setStatusCode(statusCode: number): Response {
@@ -53,14 +59,62 @@ export class Response implements TwilioResponse {
5359
if (typeof headersObject !== 'object') {
5460
return this;
5561
}
62+
if (!(COOKIE_HEADER in headersObject)) {
63+
headersObject[COOKIE_HEADER] = [];
64+
}
65+
66+
const cookieHeader = headersObject[COOKIE_HEADER];
67+
if (!Array.isArray(cookieHeader)) {
68+
headersObject[COOKIE_HEADER] = [cookieHeader];
69+
}
5670
this.headers = headersObject;
71+
5772
return this;
5873
}
5974

6075
appendHeader(key: string, value: HeaderValue): Response {
6176
log('Appending header for %s', key, value);
6277
this.headers = this.headers || {};
63-
this.headers[key] = value;
78+
const existingValue = this.headers[key];
79+
let newHeaderValue: HeaderValue = [];
80+
if (existingValue) {
81+
newHeaderValue = [existingValue, value].flat();
82+
if (newHeaderValue) {
83+
this.headers[key] = newHeaderValue;
84+
}
85+
} else {
86+
if (key === COOKIE_HEADER && !Array.isArray(value)) {
87+
this.headers[key] = [value];
88+
} else {
89+
this.headers[key] = value;
90+
}
91+
}
92+
if (!(COOKIE_HEADER in this.headers)) {
93+
this.headers[COOKIE_HEADER] = [];
94+
}
95+
return this;
96+
}
97+
98+
setCookie(key: string, value: string, attributes: string[] = []): Response {
99+
log('Setting cookie %s=%s', key, value);
100+
const cookie =
101+
`${key}=${value}` +
102+
(attributes.length > 0 ? `;${attributes.join(';')}` : '');
103+
this.appendHeader(COOKIE_HEADER, cookie);
104+
return this;
105+
}
106+
107+
removeCookie(key: string): Response {
108+
log('Removing cookie %s', key);
109+
let cookieHeader = this.headers[COOKIE_HEADER];
110+
if (!Array.isArray(cookieHeader)) {
111+
cookieHeader = [cookieHeader];
112+
}
113+
const newCookies = cookieHeader.filter(
114+
(cookie) => typeof cookie === 'string' && !cookie.startsWith(`${key}=`)
115+
);
116+
newCookies.push(`${key}=;Max-Age=0`);
117+
this.headers[COOKIE_HEADER] = newCookies;
64118
return this;
65119
}
66120

packages/runtime-handler/src/dev-runtime/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,8 @@ export type LoggerInstance = {
7171
error(msg: string, title?: string): void;
7272
log(msg: string, level: number): void;
7373
};
74+
75+
export type HeaderValue = number | string | (string | number)[];
76+
export type Headers = {
77+
[key: string]: HeaderValue;
78+
};

0 commit comments

Comments
 (0)