Skip to content

Commit 2a9fd4c

Browse files
committed
feat(event-handler): add CORS middleware for REST API
- Add comprehensive CORS middleware with configurable options - Support string, array, and function-based origin validation - Handle OPTIONS preflight requests with 204 status - Add CORS headers to regular responses - Include comprehensive test suite (26 tests) - Follow Python implementation defaults for consistency - Support global and route-specific middleware usage Signed-off-by: Daniel ABIB <[email protected]>
1 parent cc23367 commit 2a9fd4c

File tree

9 files changed

+801
-0
lines changed

9 files changed

+801
-0
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Router, cors } from '@aws-lambda-powertools/event-handler/experimental-rest';
2+
import type { Context } from 'aws-lambda/handler';
3+
4+
const app = new Router();
5+
6+
// Basic CORS with default configuration
7+
// - origin: '*'
8+
// - allowMethods: ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']
9+
// - allowHeaders: ['Authorization', 'Content-Type', 'X-Amz-Date', 'X-Api-Key', 'X-Amz-Security-Token']
10+
// - exposeHeaders: []
11+
// - credentials: false
12+
app.use(cors());
13+
14+
app.get('/api/users', async () => {
15+
return { users: ['user1', 'user2'] };
16+
});
17+
18+
app.post('/api/users', async (_: unknown, { request }: { request: Request }) => {
19+
const body = await request.json();
20+
return { created: true, user: body };
21+
});
22+
23+
export const handler = async (event: unknown, context: Context) => {
24+
return app.resolve(event, context);
25+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Router, cors } from '@aws-lambda-powertools/event-handler/experimental-rest';
2+
import type { Context } from 'aws-lambda/handler';
3+
4+
const app = new Router();
5+
6+
// Custom CORS configuration
7+
app.use(cors({
8+
origin: 'https://myapp.com',
9+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
10+
allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
11+
exposeHeaders: ['X-Total-Count', 'X-Request-ID'],
12+
credentials: true,
13+
maxAge: 3600, // 1 hour
14+
}));
15+
16+
app.get('/api/data', async () => {
17+
return { data: 'protected endpoint' };
18+
});
19+
20+
app.post('/api/data', async (_: unknown, { request }: { request: Request }) => {
21+
const body = await request.json();
22+
return { created: true, data: body };
23+
});
24+
25+
export const handler = async (event: unknown, context: Context) => {
26+
return app.resolve(event, context);
27+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Router, cors } from '@aws-lambda-powertools/event-handler/experimental-rest';
2+
// When building the package, this import will work correctly
3+
// import type { RequestContext } from '@aws-lambda-powertools/event-handler/experimental-rest';
4+
import type { Context } from 'aws-lambda/handler';
5+
6+
const app = new Router();
7+
8+
// Dynamic origin configuration with function
9+
app.use(cors({
10+
origin: (origin?: string) => {
11+
// Allow requests from trusted domains
12+
const allowedOrigins = [
13+
'https://app.mycompany.com',
14+
'https://admin.mycompany.com',
15+
'https://staging.mycompany.com',
16+
];
17+
18+
// Log the origin for debugging
19+
console.log('CORS request from:', origin);
20+
21+
// Return boolean: true allows the origin, false denies it
22+
return origin ? allowedOrigins.includes(origin) : false;
23+
},
24+
credentials: true,
25+
allowHeaders: ['Content-Type', 'Authorization'],
26+
}));
27+
28+
// Route-specific CORS for public API
29+
app.get('/public/health', [cors({ origin: '*' })], async () => {
30+
return { status: 'healthy', timestamp: new Date().toISOString() };
31+
});
32+
33+
// Protected endpoint using global CORS
34+
app.get('/api/user/profile', async () => {
35+
return { user: 'john_doe', email: '[email protected]' };
36+
});
37+
38+
export const handler = async (event: unknown, context: Context) => {
39+
return app.resolve(event, context);
40+
};

packages/event-handler/src/rest/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {
1919
ServiceUnavailableError,
2020
UnauthorizedError,
2121
} from './errors.js';
22+
export { cors } from './middleware/index.js';
2223
export { Router } from './Router.js';
2324
export {
2425
composeMiddleware,
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import type { Middleware, RequestContext, HandlerResponse } from '../../types/rest.js';
2+
import { HttpErrorCodes, HttpVerbs } from '../constants.js';
3+
4+
/**
5+
* Configuration options for CORS middleware
6+
*/
7+
export interface CorsOptions {
8+
/**
9+
* The Access-Control-Allow-Origin header value.
10+
* Can be a string, array of strings, or a function that returns a string or boolean.
11+
* @default '*'
12+
*/
13+
origin?: string | string[] | ((origin: string | undefined, reqCtx: RequestContext) => string | boolean);
14+
15+
/**
16+
* The Access-Control-Allow-Methods header value.
17+
* @default ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']
18+
*/
19+
allowMethods?: string[];
20+
21+
/**
22+
* The Access-Control-Allow-Headers header value.
23+
* @default ['Authorization', 'Content-Type', 'X-Amz-Date', 'X-Api-Key', 'X-Amz-Security-Token']
24+
*/
25+
allowHeaders?: string[];
26+
27+
/**
28+
* The Access-Control-Expose-Headers header value.
29+
* @default []
30+
*/
31+
exposeHeaders?: string[];
32+
33+
/**
34+
* The Access-Control-Allow-Credentials header value.
35+
* @default false
36+
*/
37+
credentials?: boolean;
38+
39+
/**
40+
* The Access-Control-Max-Age header value in seconds.
41+
* Only applicable for preflight requests.
42+
*/
43+
maxAge?: number;
44+
}
45+
46+
/**
47+
* Resolved CORS configuration with all defaults applied
48+
*/
49+
interface ResolvedCorsConfig {
50+
origin: CorsOptions['origin'];
51+
allowMethods: string[];
52+
allowHeaders: string[];
53+
exposeHeaders: string[];
54+
credentials: boolean;
55+
maxAge?: number;
56+
}
57+
58+
/**
59+
* Default CORS configuration matching Python implementation
60+
*/
61+
const DEFAULT_CORS_OPTIONS: Required<Omit<CorsOptions, 'maxAge'>> = {
62+
origin: '*',
63+
allowMethods: ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'],
64+
allowHeaders: ['Authorization', 'Content-Type', 'X-Amz-Date', 'X-Api-Key', 'X-Amz-Security-Token'],
65+
exposeHeaders: [],
66+
credentials: false,
67+
};
68+
69+
/**
70+
* Resolves and validates the CORS configuration
71+
*/
72+
function resolveConfiguration(userOptions: CorsOptions): ResolvedCorsConfig {
73+
const config: ResolvedCorsConfig = {
74+
origin: userOptions.origin ?? DEFAULT_CORS_OPTIONS.origin,
75+
allowMethods: userOptions.allowMethods ?? DEFAULT_CORS_OPTIONS.allowMethods,
76+
allowHeaders: userOptions.allowHeaders ?? DEFAULT_CORS_OPTIONS.allowHeaders,
77+
exposeHeaders: userOptions.exposeHeaders ?? DEFAULT_CORS_OPTIONS.exposeHeaders,
78+
credentials: userOptions.credentials ?? DEFAULT_CORS_OPTIONS.credentials,
79+
maxAge: userOptions.maxAge,
80+
};
81+
82+
return config;
83+
}
84+
85+
/**
86+
* Resolves the origin value based on the configuration
87+
*/
88+
function resolveOrigin(
89+
originConfig: CorsOptions['origin'],
90+
requestOrigin: string | null | undefined,
91+
reqCtx: RequestContext
92+
): string {
93+
const origin = requestOrigin || undefined;
94+
95+
if (typeof originConfig === 'function') {
96+
const result = originConfig(origin, reqCtx);
97+
if (typeof result === 'boolean') {
98+
return result ? (origin || '*') : '';
99+
}
100+
return result;
101+
}
102+
103+
if (Array.isArray(originConfig)) {
104+
return origin && originConfig.includes(origin) ? origin : '';
105+
}
106+
107+
if (typeof originConfig === 'string') {
108+
return originConfig;
109+
}
110+
111+
return DEFAULT_CORS_OPTIONS.origin as string;
112+
}
113+
114+
/**
115+
* Handles preflight OPTIONS requests
116+
*/
117+
function handlePreflight(config: ResolvedCorsConfig, reqCtx: RequestContext): Response {
118+
const { request } = reqCtx;
119+
const requestOrigin = request.headers.get('Origin');
120+
const resolvedOrigin = resolveOrigin(config.origin, requestOrigin, reqCtx);
121+
122+
const headers = new Headers();
123+
124+
if (resolvedOrigin) {
125+
headers.set('Access-Control-Allow-Origin', resolvedOrigin);
126+
}
127+
128+
if (config.allowMethods.length > 0) {
129+
headers.set('Access-Control-Allow-Methods', config.allowMethods.join(', '));
130+
}
131+
132+
if (config.allowHeaders.length > 0) {
133+
headers.set('Access-Control-Allow-Headers', config.allowHeaders.join(', '));
134+
}
135+
136+
if (config.credentials) {
137+
headers.set('Access-Control-Allow-Credentials', 'true');
138+
}
139+
140+
if (config.maxAge !== undefined) {
141+
headers.set('Access-Control-Max-Age', config.maxAge.toString());
142+
}
143+
144+
return new Response(null, {
145+
status: HttpErrorCodes.NO_CONTENT, // 204
146+
headers,
147+
});
148+
}
149+
150+
/**
151+
* Adds CORS headers to regular requests
152+
*/
153+
function addCorsHeaders(config: ResolvedCorsConfig, reqCtx: RequestContext): void {
154+
const { request, res } = reqCtx;
155+
const requestOrigin = request.headers.get('Origin');
156+
const resolvedOrigin = resolveOrigin(config.origin, requestOrigin, reqCtx);
157+
158+
if (resolvedOrigin) {
159+
res.headers.set('Access-Control-Allow-Origin', resolvedOrigin);
160+
}
161+
162+
if (config.exposeHeaders.length > 0) {
163+
res.headers.set('Access-Control-Expose-Headers', config.exposeHeaders.join(', '));
164+
}
165+
166+
if (config.credentials) {
167+
res.headers.set('Access-Control-Allow-Credentials', 'true');
168+
}
169+
}
170+
171+
/**
172+
* Creates a CORS middleware that adds appropriate CORS headers to responses
173+
* and handles OPTIONS preflight requests.
174+
*
175+
* @param options - CORS configuration options
176+
* @returns A middleware function that handles CORS
177+
*
178+
* @example
179+
* ```typescript
180+
* import { cors } from '@aws-lambda-powertools/event-handler/rest';
181+
*
182+
* // Use default configuration
183+
* app.use(cors());
184+
*
185+
* // Custom configuration
186+
* app.use(cors({
187+
* origin: 'https://example.com',
188+
* allowMethods: ['GET', 'POST'],
189+
* credentials: true,
190+
* }));
191+
*
192+
* // Dynamic origin with function
193+
* app.use(cors({
194+
* origin: (origin, reqCtx) => {
195+
* const allowedOrigins = ['https://app.com', 'https://admin.app.com'];
196+
* return origin && allowedOrigins.includes(origin);
197+
* }
198+
* }));
199+
* ```
200+
*/
201+
export const cors = (options: CorsOptions = {}): Middleware => {
202+
const config = resolveConfiguration(options);
203+
204+
return async (_params: Record<string, string>, reqCtx: RequestContext, next: () => Promise<HandlerResponse | void>) => {
205+
const { request } = reqCtx;
206+
const method = request.method.toUpperCase();
207+
208+
// Handle preflight OPTIONS request
209+
if (method === HttpVerbs.OPTIONS) {
210+
return handlePreflight(config, reqCtx);
211+
}
212+
213+
// Continue to next middleware/handler first
214+
await next();
215+
216+
// Add CORS headers to the response after handler
217+
addCorsHeaders(config, reqCtx);
218+
};
219+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { cors } from './cors.js';
2+
export type { CorsOptions } from './cors.js';

packages/event-handler/src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,5 @@ export type {
4646
RestRouterOptions,
4747
RouteHandler,
4848
} from './rest.js';
49+
50+
export type { CorsOptions } from '../rest/middleware/cors.js';

0 commit comments

Comments
 (0)