Skip to content

Commit ada48bb

Browse files
arnabrahmansvozza
andauthored
feat(event-handler): Add includeRouter support to AppSync GraphQL resolver (#4457)
Co-authored-by: Stefano Vozza <svozza@amazon.com>
1 parent c521edd commit ada48bb

File tree

10 files changed

+565
-24
lines changed

10 files changed

+565
-24
lines changed

docs/features/event-handler/appsync-graphql.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,36 @@ Here's a table with their related scalar as a quick reference:
114114

115115
## Advanced
116116

117+
### Split operations with Router
118+
119+
As you grow the number of related GraphQL operations a given Lambda function should handle, it is natural to split them into separate files to ease maintenance - That's when the `Router` feature comes handy.
120+
121+
Let's assume you have `app.ts` as your Lambda function entrypoint and routes in `postRouter.ts` and `userRouter.ts`. This is how you'd use the `Router` feature.
122+
123+
=== "postRouter.ts"
124+
125+
We import **Router** instead of **AppSyncGraphQLResolver**; syntax wise is exactly the same.
126+
127+
```typescript hl_lines="1 3"
128+
--8<-- "examples/snippets/event-handler/appsync-graphql/postRouter.ts"
129+
```
130+
131+
=== "userRouter.ts"
132+
133+
We import **Router** instead of **AppSyncGraphQLResolver**; syntax wise is exactly the same.
134+
135+
```typescript hl_lines="1 3"
136+
--8<-- "examples/snippets/event-handler/appsync-graphql/userRouter.ts"
137+
```
138+
139+
=== "app.ts"
140+
141+
We use `includeRouter` method and include all operations registered in the router instances.
142+
143+
```typescript hl_lines="3-4 8"
144+
--8<-- "examples/snippets/event-handler/appsync-graphql/splitRouter.ts"
145+
```
146+
117147
### Nested mappings
118148

119149
!!! note
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/appsync-graphql';
2+
3+
const postRouter = new Router();
4+
5+
postRouter.onQuery('getPosts', async () => {
6+
return [{ id: 1, title: 'First post', content: 'Hello world!' }];
7+
});
8+
9+
postRouter.onMutation('createPost', async ({ title, content }) => {
10+
return {
11+
id: Date.now(),
12+
title,
13+
content,
14+
createdAt: new Date().toISOString(),
15+
};
16+
});
17+
18+
export { postRouter };
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql';
2+
import type { Context } from 'aws-lambda';
3+
import { postRouter } from './postRouter';
4+
import { userRouter } from './userRouter';
5+
6+
const app = new AppSyncGraphQLResolver();
7+
8+
app.includeRouter([postRouter, userRouter]);
9+
10+
export const handler = async (event: unknown, context: Context) =>
11+
app.resolve(event, context);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/appsync-graphql';
2+
3+
const userRouter = new Router();
4+
5+
userRouter.onQuery('getUsers', async () => {
6+
return [{ id: 1, name: 'John Doe', email: 'john@example.com' }];
7+
});
8+
9+
export { userRouter };

packages/event-handler/src/appsync-graphql/AppSyncGraphQLResolver.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,50 @@ class AppSyncGraphQLResolver extends Router {
180180
);
181181
}
182182

183+
/**
184+
* Includes one or more routers and merges their registries into the current resolver.
185+
*
186+
* This method allows you to compose multiple routers by merging their
187+
* route registries into the current AppSync GraphQL resolver instance.
188+
* All resolver handlers, batch resolver handlers, and exception handlers
189+
* from the included routers will be available in the current resolver.
190+
*
191+
* **Note:** When multiple routers register handlers for the same type and field combination
192+
* (e.g., both `userRouter` and `postRouter` define `Query.getPost`), the handler from the
193+
* last included router takes precedence and will override earlier registrations.
194+
* This behavior also applies to exception handlers registered for the same error class.
195+
* A warning is logged to help you identify potential conflicts when handlers are overridden.
196+
*
197+
* @example
198+
* ```ts
199+
* import { AppSyncGraphQLResolver, Router } from '@aws-lambda-powertools/event-handler/appsync-graphql';
200+
*
201+
* const postRouter = new Router();
202+
* postRouter.onQuery('getPosts', async () => [{ id: 1, title: 'Post 1' }]);
203+
*
204+
* const userRouter = new Router();
205+
* userRouter.onQuery('getUsers', async () => [{ id: 1, name: 'John Doe' }]);
206+
*
207+
* const app = new AppSyncGraphQLResolver();
208+
*
209+
* app.includeRouter([userRouter, postRouter]);
210+
*
211+
* export const handler = async (event, context) =>
212+
* app.resolve(event, context);
213+
* ```
214+
*
215+
* @param router - The router instance or array of router instances whose registries will be merged
216+
*/
217+
public includeRouter(router: Router | Router[]): void {
218+
const routers = Array.isArray(router) ? router : [router];
219+
220+
this.logger.debug('Including router');
221+
for (const routerToBeIncluded of routers) {
222+
this.mergeRegistriesFrom(routerToBeIncluded);
223+
}
224+
this.logger.debug('Router included successfully');
225+
}
226+
183227
/**
184228
* Executes the provided asynchronous function with error handling.
185229
* If the function throws an error, it delegates error processing to `#handleError`

packages/event-handler/src/appsync-graphql/ExceptionHandlerRegistry.ts

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,44 @@ class ExceptionHandlerRegistry {
3737
const errors = Array.isArray(error) ? error : [error];
3838

3939
for (const err of errors) {
40-
this.registerErrorHandler(err, handler);
40+
this.#registerErrorHandler(err, handler);
41+
}
42+
}
43+
44+
/**
45+
* Resolves and returns the appropriate exception handler for a given error instance.
46+
*
47+
* This method attempts to find a registered exception handler based on the error class name.
48+
* If a matching handler is found, it is returned; otherwise, `null` is returned.
49+
*
50+
* @param error - The error instance for which to resolve an exception handler.
51+
*/
52+
public resolve(error: Error): ExceptionHandler | null {
53+
const errorName = error.name;
54+
this.#logger.debug(`Looking for exception handler for error: ${errorName}`);
55+
56+
const handlerOptions = this.handlers.get(errorName);
57+
if (handlerOptions) {
58+
this.#logger.debug(`Found exact match for error class: ${errorName}`);
59+
return handlerOptions.handler;
60+
}
61+
62+
this.#logger.debug(`No exception handler found for error: ${errorName}`);
63+
return null;
64+
}
65+
66+
/**
67+
* Merges handlers from another ExceptionHandlerRegistry into this registry.
68+
* Existing handlers for the same error class will be replaced and a warning will be logged.
69+
*
70+
* @param otherRegistry - The registry to merge handlers from.
71+
*/
72+
public merge(otherRegistry: ExceptionHandlerRegistry): void {
73+
for (const [errorName, handlerOptions] of otherRegistry.handlers) {
74+
if (this.handlers.has(errorName)) {
75+
this.#warnHandlerOverriding(errorName);
76+
}
77+
this.handlers.set(errorName, handlerOptions);
4178
}
4279
}
4380

@@ -47,7 +84,7 @@ class ExceptionHandlerRegistry {
4784
* @param errorClass - The error class to register the handler for.
4885
* @param handler - The exception handler function.
4986
*/
50-
private registerErrorHandler(
87+
#registerErrorHandler(
5188
errorClass: ErrorClass<Error>,
5289
handler: ExceptionHandler
5390
): void {
@@ -56,9 +93,7 @@ class ExceptionHandlerRegistry {
5693
this.#logger.debug(`Adding exception handler for error class ${errorName}`);
5794

5895
if (this.handlers.has(errorName)) {
59-
this.#logger.warn(
60-
`An exception handler for error class '${errorName}' is already registered. The previous handler will be replaced.`
61-
);
96+
this.#warnHandlerOverriding(errorName);
6297
}
6398

6499
this.handlers.set(errorName, {
@@ -68,25 +103,18 @@ class ExceptionHandlerRegistry {
68103
}
69104

70105
/**
71-
* Resolves and returns the appropriate exception handler for a given error instance.
106+
* Logs a warning message when an exception handler is being overridden.
72107
*
73-
* This method attempts to find a registered exception handler based on the error class name.
74-
* If a matching handler is found, it is returned; otherwise, `null` is returned.
108+
* This method is called internally when registering a new exception handler
109+
* for an error class that already has a handler registered. It warns the user
110+
* that the previous handler will be replaced with the new one.
75111
*
76-
* @param error - The error instance for which to resolve an exception handler.
112+
* @param errorName - The name of the error class for which a handler is being overridden
77113
*/
78-
public resolve(error: Error): ExceptionHandler | null {
79-
const errorName = error.name;
80-
this.#logger.debug(`Looking for exception handler for error: ${errorName}`);
81-
82-
const handlerOptions = this.handlers.get(errorName);
83-
if (handlerOptions) {
84-
this.#logger.debug(`Found exact match for error class: ${errorName}`);
85-
return handlerOptions.handler;
86-
}
87-
88-
this.#logger.debug(`No exception handler found for error: ${errorName}`);
89-
return null;
114+
#warnHandlerOverriding(errorName: string): void {
115+
this.#logger.warn(
116+
`An exception handler for error class '${errorName}' is already registered. The previous handler will be replaced.`
117+
);
90118
}
91119
}
92120

packages/event-handler/src/appsync-graphql/RouteHandlerRegistry.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,7 @@ class RouteHandlerRegistry {
5050
this.#logger.debug(`Adding resolver for field ${typeName}.${fieldName}`);
5151
const cacheKey = this.#makeKey(typeName, fieldName);
5252
if (this.resolvers.has(cacheKey)) {
53-
this.#logger.warn(
54-
`A resolver for field '${fieldName}' is already registered for '${typeName}'. The previous resolver will be replaced.`
55-
);
53+
this.#warnResolverOverriding(fieldName, typeName);
5654
}
5755
this.resolvers.set(cacheKey, {
5856
fieldName,
@@ -81,6 +79,21 @@ class RouteHandlerRegistry {
8179
return this.resolvers.get(this.#makeKey(typeName, fieldName));
8280
}
8381

82+
/**
83+
* Merges handlers from another RouteHandlerRegistry into this registry.
84+
* Existing handlers with the same key will be replaced and a warning will be logged.
85+
*
86+
* @param otherRegistry - The registry to merge handlers from.
87+
*/
88+
public merge(otherRegistry: RouteHandlerRegistry): void {
89+
for (const [key, handler] of otherRegistry.resolvers) {
90+
if (this.resolvers.has(key)) {
91+
this.#warnResolverOverriding(handler.fieldName, handler.typeName);
92+
}
93+
this.resolvers.set(key, handler);
94+
}
95+
}
96+
8497
/**
8598
* Generates a unique key by combining the provided GraphQL type name and field name.
8699
*
@@ -90,6 +103,19 @@ class RouteHandlerRegistry {
90103
#makeKey(typeName: string, fieldName: string): string {
91104
return `${typeName}.${fieldName}`;
92105
}
106+
107+
/**
108+
* Logs a warning message indicating that a resolver for the specified field and type
109+
* is already registered and will be replaced by a new resolver.
110+
*
111+
* @param fieldName - The name of the field for which the resolver is being overridden.
112+
* @param typeName - The name of the type associated with the field.
113+
*/
114+
#warnResolverOverriding(fieldName: string, typeName: string): void {
115+
this.#logger.warn(
116+
`A resolver for field '${fieldName}' is already registered for '${typeName}'. The previous resolver will be replaced.`
117+
);
118+
}
93119
}
94120

95121
export { RouteHandlerRegistry };

packages/event-handler/src/appsync-graphql/Router.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ class Router {
6464
this.isDev = isDevMode();
6565
}
6666

67+
/**
68+
* Merges resolver registries from another router into this router.
69+
*
70+
* This method combines the resolver registry, batch resolver registry, and exception handler registry
71+
* from the provided router with the current router's registries.
72+
*
73+
* @param router - The source router whose registries will be merged into this router
74+
*/
75+
protected mergeRegistriesFrom(router: Router): void {
76+
this.resolverRegistry.merge(router.resolverRegistry);
77+
this.batchResolverRegistry.merge(router.batchResolverRegistry);
78+
this.exceptionHandlerRegistry.merge(router.exceptionHandlerRegistry);
79+
}
80+
6781
/**
6882
* Register a resolver function for any GraphQL event.
6983
*

packages/event-handler/src/appsync-graphql/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export {
33
InvalidBatchResponseException,
44
ResolverNotFoundException,
55
} from './errors.js';
6+
export { Router } from './Router.js';
67
export {
78
awsDate,
89
awsDateTime,

0 commit comments

Comments
 (0)