serverless-event-orchestrator 1.0.1
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/LICENSE +21 -0
- package/README.md +377 -0
- package/dist/dispatcher.d.ts +18 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +345 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/http/body-parser.d.ts +27 -0
- package/dist/http/body-parser.d.ts.map +1 -0
- package/dist/http/body-parser.js +56 -0
- package/dist/http/body-parser.js.map +1 -0
- package/dist/http/cors.d.ts +32 -0
- package/dist/http/cors.d.ts.map +1 -0
- package/dist/http/cors.js +69 -0
- package/dist/http/cors.js.map +1 -0
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +20 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/response.d.ts +104 -0
- package/dist/http/response.d.ts.map +1 -0
- package/dist/http/response.js +164 -0
- package/dist/http/response.js.map +1 -0
- package/dist/identity/extractor.d.ts +39 -0
- package/dist/identity/extractor.d.ts.map +1 -0
- package/dist/identity/extractor.js +88 -0
- package/dist/identity/extractor.js.map +1 -0
- package/dist/identity/index.d.ts +2 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/index.js +18 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/types/event-type.enum.d.ts +20 -0
- package/dist/types/event-type.enum.d.ts.map +1 -0
- package/dist/types/event-type.enum.js +25 -0
- package/dist/types/event-type.enum.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +19 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/routes.d.ts +163 -0
- package/dist/types/routes.d.ts.map +1 -0
- package/dist/types/routes.js +3 -0
- package/dist/types/routes.js.map +1 -0
- package/dist/utils/headers.d.ts +28 -0
- package/dist/utils/headers.d.ts.map +1 -0
- package/dist/utils/headers.js +61 -0
- package/dist/utils/headers.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +19 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/path-matcher.d.ts +33 -0
- package/dist/utils/path-matcher.d.ts.map +1 -0
- package/dist/utils/path-matcher.js +74 -0
- package/dist/utils/path-matcher.js.map +1 -0
- package/jest.config.js +32 -0
- package/package.json +68 -0
- package/src/dispatcher.ts +415 -0
- package/src/http/body-parser.ts +60 -0
- package/src/http/cors.ts +76 -0
- package/src/http/index.ts +3 -0
- package/src/http/response.ts +194 -0
- package/src/identity/extractor.ts +89 -0
- package/src/identity/index.ts +1 -0
- package/src/index.ts +92 -0
- package/src/types/event-type.enum.ts +20 -0
- package/src/types/index.ts +2 -0
- package/src/types/routes.ts +182 -0
- package/src/utils/headers.ts +72 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/path-matcher.ts +79 -0
- package/tests/cors.test.ts +133 -0
- package/tests/dispatcher.test.ts +425 -0
- package/tests/headers.test.ts +99 -0
- package/tests/identity.test.ts +171 -0
- package/tests/path-matcher.test.ts +102 -0
- package/tests/response.test.ts +155 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path matching utilities for extracting path parameters
|
|
3
|
+
* Supports patterns like /users/{id} and /users/{userId}/posts/{postId}
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Converts a route pattern to a regex and extracts parameter names
|
|
8
|
+
* @param pattern - Route pattern like /users/{id}
|
|
9
|
+
* @returns Object with regex and parameter names
|
|
10
|
+
*/
|
|
11
|
+
export function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } {
|
|
12
|
+
const paramNames: string[] = [];
|
|
13
|
+
|
|
14
|
+
// Escape special regex characters except for our parameter syntax
|
|
15
|
+
let regexPattern = pattern
|
|
16
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
17
|
+
.replace(/\\\{(\w+)\\\}/g, (_, paramName) => {
|
|
18
|
+
paramNames.push(paramName);
|
|
19
|
+
return '([^/]+)';
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Ensure exact match
|
|
23
|
+
regexPattern = `^${regexPattern}$`;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
regex: new RegExp(regexPattern),
|
|
27
|
+
paramNames,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Matches a path against a pattern and extracts parameters
|
|
33
|
+
* @param pattern - Route pattern like /users/{id}
|
|
34
|
+
* @param path - Actual path like /users/123
|
|
35
|
+
* @returns Extracted parameters or null if no match
|
|
36
|
+
*/
|
|
37
|
+
export function matchPath(pattern: string, path: string): Record<string, string> | null {
|
|
38
|
+
const { regex, paramNames } = patternToRegex(pattern);
|
|
39
|
+
const match = path.match(regex);
|
|
40
|
+
|
|
41
|
+
if (!match) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const params: Record<string, string> = {};
|
|
46
|
+
paramNames.forEach((name, index) => {
|
|
47
|
+
params[name] = match[index + 1];
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return params;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Checks if a pattern contains path parameters
|
|
55
|
+
* @param pattern - Route pattern to check
|
|
56
|
+
* @returns True if pattern has parameters
|
|
57
|
+
*/
|
|
58
|
+
export function hasPathParameters(pattern: string): boolean {
|
|
59
|
+
return /\{[\w]+\}/.test(pattern);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalizes a path by removing trailing slashes and ensuring leading slash
|
|
64
|
+
* @param path - Path to normalize
|
|
65
|
+
* @returns Normalized path
|
|
66
|
+
*/
|
|
67
|
+
export function normalizePath(path: string): string {
|
|
68
|
+
if (!path) return '/';
|
|
69
|
+
|
|
70
|
+
// Ensure leading slash
|
|
71
|
+
let normalized = path.startsWith('/') ? path : `/${path}`;
|
|
72
|
+
|
|
73
|
+
// Remove trailing slash (except for root)
|
|
74
|
+
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
75
|
+
normalized = normalized.slice(0, -1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return normalized;
|
|
79
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPreflightRequest,
|
|
3
|
+
createPreflightResponse,
|
|
4
|
+
applyCorsHeaders,
|
|
5
|
+
withCors
|
|
6
|
+
} from '../src/http/cors';
|
|
7
|
+
import { HttpResponse } from '../src/http/response';
|
|
8
|
+
|
|
9
|
+
describe('isPreflightRequest', () => {
|
|
10
|
+
it('should return true for OPTIONS request', () => {
|
|
11
|
+
const event = { httpMethod: 'OPTIONS' };
|
|
12
|
+
expect(isPreflightRequest(event)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should return true for lowercase options', () => {
|
|
16
|
+
const event = { httpMethod: 'options' };
|
|
17
|
+
expect(isPreflightRequest(event)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should return false for GET request', () => {
|
|
21
|
+
const event = { httpMethod: 'GET' };
|
|
22
|
+
expect(isPreflightRequest(event)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return false for POST request', () => {
|
|
26
|
+
const event = { httpMethod: 'POST' };
|
|
27
|
+
expect(isPreflightRequest(event)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return false for undefined httpMethod', () => {
|
|
31
|
+
const event = {};
|
|
32
|
+
expect(isPreflightRequest(event)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('createPreflightResponse', () => {
|
|
37
|
+
it('should return 204 with default CORS headers', () => {
|
|
38
|
+
const response = createPreflightResponse(true);
|
|
39
|
+
|
|
40
|
+
expect(response.statusCode).toBe(204);
|
|
41
|
+
expect(response.body).toBe('');
|
|
42
|
+
expect(response.headers?.['Access-Control-Allow-Origin']).toBe('*');
|
|
43
|
+
expect(response.headers?.['Access-Control-Allow-Methods']).toContain('GET');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should use custom CORS config', () => {
|
|
47
|
+
const response = createPreflightResponse({
|
|
48
|
+
origins: ['https://myapp.com'],
|
|
49
|
+
methods: ['GET', 'POST'],
|
|
50
|
+
credentials: true
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(response.headers?.['Access-Control-Allow-Origin']).toBe('https://myapp.com');
|
|
54
|
+
expect(response.headers?.['Access-Control-Allow-Methods']).toBe('GET, POST');
|
|
55
|
+
expect(response.headers?.['Access-Control-Allow-Credentials']).toBe('true');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('applyCorsHeaders', () => {
|
|
60
|
+
const originalResponse: HttpResponse = {
|
|
61
|
+
statusCode: 200,
|
|
62
|
+
body: JSON.stringify({ data: 'test' }),
|
|
63
|
+
headers: { 'Content-Type': 'application/json' }
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
it('should add CORS headers to response', () => {
|
|
67
|
+
const response = applyCorsHeaders(originalResponse, true);
|
|
68
|
+
|
|
69
|
+
expect(response.headers?.['Access-Control-Allow-Origin']).toBe('*');
|
|
70
|
+
expect(response.headers?.['Content-Type']).toBe('application/json');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should not modify response when cors is false', () => {
|
|
74
|
+
const response = applyCorsHeaders(originalResponse, false);
|
|
75
|
+
|
|
76
|
+
expect(response.headers?.['Access-Control-Allow-Origin']).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should use custom CORS config', () => {
|
|
80
|
+
const response = applyCorsHeaders(originalResponse, {
|
|
81
|
+
origins: ['https://example.com'],
|
|
82
|
+
maxAge: 3600
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(response.headers?.['Access-Control-Allow-Origin']).toBe('https://example.com');
|
|
86
|
+
expect(response.headers?.['Access-Control-Max-Age']).toBe('3600');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('withCors', () => {
|
|
91
|
+
const mockHandler = jest.fn().mockResolvedValue({
|
|
92
|
+
statusCode: 200,
|
|
93
|
+
body: JSON.stringify({ success: true })
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
mockHandler.mockClear();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should handle preflight requests without calling handler', async () => {
|
|
101
|
+
const wrappedHandler = withCors(mockHandler, true);
|
|
102
|
+
|
|
103
|
+
const event = { eventRaw: { httpMethod: 'OPTIONS' } };
|
|
104
|
+
const response = await wrappedHandler(event);
|
|
105
|
+
|
|
106
|
+
expect(response.statusCode).toBe(204);
|
|
107
|
+
expect(mockHandler).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should call handler and add CORS headers for non-preflight', async () => {
|
|
111
|
+
const wrappedHandler = withCors(mockHandler, true);
|
|
112
|
+
|
|
113
|
+
const event = { eventRaw: { httpMethod: 'GET' } };
|
|
114
|
+
const response = await wrappedHandler(event);
|
|
115
|
+
|
|
116
|
+
expect(mockHandler).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(response.statusCode).toBe(200);
|
|
118
|
+
expect(response.headers?.['Access-Control-Allow-Origin']).toBe('*');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should work with custom CORS config', async () => {
|
|
122
|
+
const wrappedHandler = withCors(mockHandler, {
|
|
123
|
+
origins: ['https://myapp.com'],
|
|
124
|
+
credentials: true
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const event = { eventRaw: { httpMethod: 'POST' } };
|
|
128
|
+
const response = await wrappedHandler(event);
|
|
129
|
+
|
|
130
|
+
expect(response.headers?.['Access-Control-Allow-Origin']).toBe('https://myapp.com');
|
|
131
|
+
expect(response.headers?.['Access-Control-Allow-Credentials']).toBe('true');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { dispatchEvent, detectEventType } from '../src/dispatcher';
|
|
2
|
+
import { EventType, RouteSegment } from '../src/types/event-type.enum';
|
|
3
|
+
import { SegmentedHttpRouter, DispatchRoutes, NormalizedEvent } from '../src/types/routes';
|
|
4
|
+
|
|
5
|
+
describe('detectEventType', () => {
|
|
6
|
+
it('should detect EventBridge events', () => {
|
|
7
|
+
const event = { source: 'EVENT_BRIDGE', detail: { operationName: 'test' } };
|
|
8
|
+
expect(detectEventType(event)).toBe(EventType.EventBridge);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should detect API Gateway events', () => {
|
|
12
|
+
const event = { requestContext: { requestId: '123' }, httpMethod: 'GET', path: '/test' };
|
|
13
|
+
expect(detectEventType(event)).toBe(EventType.ApiGateway);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should detect Lambda invocation events', () => {
|
|
17
|
+
const event = { awsRequestId: '1234-5678' };
|
|
18
|
+
expect(detectEventType(event)).toBe(EventType.Lambda);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should detect SQS events', () => {
|
|
22
|
+
const event = {
|
|
23
|
+
Records: [
|
|
24
|
+
{ eventSource: 'aws:sqs', body: '{}', eventSourceARN: 'arn:aws:sqs:us-east-1:123:my-queue' }
|
|
25
|
+
]
|
|
26
|
+
};
|
|
27
|
+
expect(detectEventType(event)).toBe(EventType.Sqs);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return Unknown for unrecognized events', () => {
|
|
31
|
+
const event = { foo: 'bar' };
|
|
32
|
+
expect(detectEventType(event)).toBe(EventType.Unknown);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('dispatchEvent - API Gateway', () => {
|
|
37
|
+
const mockHandler = jest.fn().mockResolvedValue({ statusCode: 200, body: '{}' });
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
mockHandler.mockClear();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should dispatch to flat HTTP router', async () => {
|
|
44
|
+
const routes: DispatchRoutes = {
|
|
45
|
+
apigateway: {
|
|
46
|
+
get: {
|
|
47
|
+
'/users': { handler: mockHandler }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const event = {
|
|
53
|
+
requestContext: { requestId: '123' },
|
|
54
|
+
httpMethod: 'GET',
|
|
55
|
+
resource: '/users',
|
|
56
|
+
path: '/users',
|
|
57
|
+
headers: {},
|
|
58
|
+
queryStringParameters: null,
|
|
59
|
+
body: null
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
await dispatchEvent(event, routes);
|
|
63
|
+
|
|
64
|
+
expect(mockHandler).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(mockHandler).toHaveBeenCalledWith(
|
|
66
|
+
expect.objectContaining({
|
|
67
|
+
eventType: EventType.ApiGateway,
|
|
68
|
+
context: expect.objectContaining({
|
|
69
|
+
segment: RouteSegment.Public
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should dispatch to segmented router', async () => {
|
|
76
|
+
const routes: DispatchRoutes = {
|
|
77
|
+
apigateway: {
|
|
78
|
+
public: {
|
|
79
|
+
get: { '/health': { handler: mockHandler } }
|
|
80
|
+
},
|
|
81
|
+
private: {
|
|
82
|
+
get: { '/profile': { handler: mockHandler } }
|
|
83
|
+
}
|
|
84
|
+
} as SegmentedHttpRouter
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const event = {
|
|
88
|
+
requestContext: { requestId: '123' },
|
|
89
|
+
httpMethod: 'GET',
|
|
90
|
+
resource: '/profile',
|
|
91
|
+
path: '/profile',
|
|
92
|
+
headers: {},
|
|
93
|
+
body: null
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
await dispatchEvent(event, routes);
|
|
97
|
+
|
|
98
|
+
expect(mockHandler).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
context: expect.objectContaining({
|
|
101
|
+
segment: RouteSegment.Private
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should match path parameters', async () => {
|
|
108
|
+
const routes: DispatchRoutes = {
|
|
109
|
+
apigateway: {
|
|
110
|
+
get: {
|
|
111
|
+
'/users/{id}': { handler: mockHandler }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const event = {
|
|
117
|
+
requestContext: { requestId: '123' },
|
|
118
|
+
httpMethod: 'GET',
|
|
119
|
+
resource: '/users/{id}',
|
|
120
|
+
path: '/users/123',
|
|
121
|
+
headers: {},
|
|
122
|
+
body: null
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
await dispatchEvent(event, routes);
|
|
126
|
+
|
|
127
|
+
expect(mockHandler).toHaveBeenCalledWith(
|
|
128
|
+
expect.objectContaining({
|
|
129
|
+
params: { id: '123' }
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should parse JSON body', async () => {
|
|
135
|
+
const routes: DispatchRoutes = {
|
|
136
|
+
apigateway: {
|
|
137
|
+
post: {
|
|
138
|
+
'/users': { handler: mockHandler }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const event = {
|
|
144
|
+
requestContext: { requestId: '123' },
|
|
145
|
+
httpMethod: 'POST',
|
|
146
|
+
resource: '/users',
|
|
147
|
+
path: '/users',
|
|
148
|
+
headers: { 'Content-Type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ name: 'John', email: 'john@example.com' })
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
await dispatchEvent(event, routes);
|
|
153
|
+
|
|
154
|
+
expect(mockHandler).toHaveBeenCalledWith(
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
payload: expect.objectContaining({
|
|
157
|
+
body: { name: 'John', email: 'john@example.com' }
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should return 404 when route not found', async () => {
|
|
164
|
+
const routes: DispatchRoutes = {
|
|
165
|
+
apigateway: {
|
|
166
|
+
get: {
|
|
167
|
+
'/users': { handler: mockHandler }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const event = {
|
|
173
|
+
requestContext: { requestId: '123' },
|
|
174
|
+
httpMethod: 'GET',
|
|
175
|
+
resource: '/nonexistent',
|
|
176
|
+
path: '/nonexistent',
|
|
177
|
+
headers: {},
|
|
178
|
+
body: null
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const result = await dispatchEvent(event, routes);
|
|
182
|
+
|
|
183
|
+
expect(result.statusCode).toBe(404);
|
|
184
|
+
expect(mockHandler).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should execute global middleware', async () => {
|
|
188
|
+
const middlewareFn = jest.fn().mockImplementation((event: NormalizedEvent) => {
|
|
189
|
+
return { ...event, payload: { ...event.payload, modified: true } };
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const routes: DispatchRoutes = {
|
|
193
|
+
apigateway: {
|
|
194
|
+
get: { '/test': { handler: mockHandler } }
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const event = {
|
|
199
|
+
requestContext: { requestId: '123' },
|
|
200
|
+
httpMethod: 'GET',
|
|
201
|
+
resource: '/test',
|
|
202
|
+
path: '/test',
|
|
203
|
+
headers: {},
|
|
204
|
+
body: null
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
await dispatchEvent(event, routes, {
|
|
208
|
+
globalMiddleware: [middlewareFn]
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(middlewareFn).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(mockHandler).toHaveBeenCalledTimes(1);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('dispatchEvent - EventBridge', () => {
|
|
217
|
+
const mockHandler = jest.fn().mockResolvedValue({ success: true });
|
|
218
|
+
|
|
219
|
+
beforeEach(() => {
|
|
220
|
+
mockHandler.mockClear();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should dispatch to named operation handler', async () => {
|
|
224
|
+
const routes: DispatchRoutes = {
|
|
225
|
+
eventbridge: {
|
|
226
|
+
'user.created': mockHandler
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const event = {
|
|
231
|
+
source: 'EVENT_BRIDGE',
|
|
232
|
+
detail: { operationName: 'user.created', userId: '123' }
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await dispatchEvent(event, routes);
|
|
236
|
+
|
|
237
|
+
expect(mockHandler).toHaveBeenCalledTimes(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should fallback to default handler', async () => {
|
|
241
|
+
const routes: DispatchRoutes = {
|
|
242
|
+
eventbridge: {
|
|
243
|
+
default: mockHandler
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const event = {
|
|
248
|
+
source: 'EVENT_BRIDGE',
|
|
249
|
+
detail: { operationName: 'unknown.event' }
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
await dispatchEvent(event, routes);
|
|
253
|
+
|
|
254
|
+
expect(mockHandler).toHaveBeenCalledTimes(1);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('dispatchEvent - SQS', () => {
|
|
259
|
+
const mockHandler = jest.fn().mockResolvedValue({ success: true });
|
|
260
|
+
|
|
261
|
+
beforeEach(() => {
|
|
262
|
+
mockHandler.mockClear();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should dispatch to queue handler', async () => {
|
|
266
|
+
const routes: DispatchRoutes = {
|
|
267
|
+
sqs: {
|
|
268
|
+
'my-queue': mockHandler
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const event = {
|
|
273
|
+
Records: [
|
|
274
|
+
{
|
|
275
|
+
eventSource: 'aws:sqs',
|
|
276
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789:my-queue',
|
|
277
|
+
body: JSON.stringify({ message: 'Hello' }),
|
|
278
|
+
messageId: 'msg-123'
|
|
279
|
+
}
|
|
280
|
+
]
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
await dispatchEvent(event, routes);
|
|
284
|
+
|
|
285
|
+
expect(mockHandler).toHaveBeenCalledTimes(1);
|
|
286
|
+
expect(mockHandler).toHaveBeenCalledWith(
|
|
287
|
+
expect.objectContaining({
|
|
288
|
+
payload: expect.objectContaining({
|
|
289
|
+
body: { message: 'Hello' }
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('dispatchEvent - Lambda', () => {
|
|
297
|
+
const mockHandler = jest.fn().mockResolvedValue({ result: 'ok' });
|
|
298
|
+
|
|
299
|
+
beforeEach(() => {
|
|
300
|
+
mockHandler.mockClear();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should dispatch to default Lambda handler', async () => {
|
|
304
|
+
const routes: DispatchRoutes = {
|
|
305
|
+
lambda: {
|
|
306
|
+
default: mockHandler
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const event = {
|
|
311
|
+
awsRequestId: '1234-5678',
|
|
312
|
+
customData: { foo: 'bar' }
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
await dispatchEvent(event, routes);
|
|
316
|
+
|
|
317
|
+
expect(mockHandler).toHaveBeenCalledTimes(1);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('dispatchEvent - User Pool Validation', () => {
|
|
322
|
+
const mockHandler = jest.fn().mockResolvedValue({ statusCode: 200 });
|
|
323
|
+
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
mockHandler.mockClear();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should allow public routes without token', async () => {
|
|
329
|
+
const routes: DispatchRoutes = {
|
|
330
|
+
apigateway: {
|
|
331
|
+
public: {
|
|
332
|
+
get: { '/health': { handler: mockHandler } }
|
|
333
|
+
}
|
|
334
|
+
} as SegmentedHttpRouter
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const event = {
|
|
338
|
+
requestContext: { requestId: '123' },
|
|
339
|
+
httpMethod: 'GET',
|
|
340
|
+
resource: '/health',
|
|
341
|
+
path: '/health',
|
|
342
|
+
headers: {},
|
|
343
|
+
body: null
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
await dispatchEvent(event, routes, {
|
|
347
|
+
userPools: {
|
|
348
|
+
private: 'us-east-1_ABC123'
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(mockHandler).toHaveBeenCalledTimes(1);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should reject private routes with wrong User Pool', async () => {
|
|
356
|
+
const routes: DispatchRoutes = {
|
|
357
|
+
apigateway: {
|
|
358
|
+
private: {
|
|
359
|
+
get: { '/profile': { handler: mockHandler } }
|
|
360
|
+
}
|
|
361
|
+
} as SegmentedHttpRouter
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const event = {
|
|
365
|
+
requestContext: {
|
|
366
|
+
requestId: '123',
|
|
367
|
+
authorizer: {
|
|
368
|
+
claims: {
|
|
369
|
+
sub: 'user-123',
|
|
370
|
+
iss: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_WRONG'
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
httpMethod: 'GET',
|
|
375
|
+
resource: '/profile',
|
|
376
|
+
path: '/profile',
|
|
377
|
+
headers: {},
|
|
378
|
+
body: null
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const result = await dispatchEvent(event, routes, {
|
|
382
|
+
userPools: {
|
|
383
|
+
private: 'us-east-1_ABC123'
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
expect(result.statusCode).toBe(403);
|
|
388
|
+
expect(mockHandler).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('should allow private routes with correct User Pool', async () => {
|
|
392
|
+
const routes: DispatchRoutes = {
|
|
393
|
+
apigateway: {
|
|
394
|
+
private: {
|
|
395
|
+
get: { '/profile': { handler: mockHandler } }
|
|
396
|
+
}
|
|
397
|
+
} as SegmentedHttpRouter
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const event = {
|
|
401
|
+
requestContext: {
|
|
402
|
+
requestId: '123',
|
|
403
|
+
authorizer: {
|
|
404
|
+
claims: {
|
|
405
|
+
sub: 'user-123',
|
|
406
|
+
iss: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123'
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
httpMethod: 'GET',
|
|
411
|
+
resource: '/profile',
|
|
412
|
+
path: '/profile',
|
|
413
|
+
headers: {},
|
|
414
|
+
body: null
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
await dispatchEvent(event, routes, {
|
|
418
|
+
userPools: {
|
|
419
|
+
private: 'us-east-1_ABC123'
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
expect(mockHandler).toHaveBeenCalledTimes(1);
|
|
424
|
+
});
|
|
425
|
+
});
|