diff --git a/packages/aws-cdk-lib/aws-appsync/README.md b/packages/aws-cdk-lib/aws-appsync/README.md index 5ff80a900112c..32bb899643b23 100644 --- a/packages/aws-cdk-lib/aws-appsync/README.md +++ b/packages/aws-cdk-lib/aws-appsync/README.md @@ -1421,3 +1421,75 @@ api.addChannelNamespace('lambda-direct-async-ns', { }, }); ``` + +### Advanced Handler Configuration + +For more fine-grained control over channel namespace handlers, you can use the `handlerConfigs` property to directly configure the handler behavior and integration options. This provides a more flexible way to define how your handlers work with data sources. + +```ts +declare const api: appsync.EventApi; +declare const lambdaDataSource: appsync.AppSyncLambdaDataSource; +declare const ddbDataSource: appsync.AppSyncDynamoDbDataSource; + +// Using direct handlerConfigs property for advanced configuration +api.addChannelNamespace('advanced-handlers', { + handlerConfigs: { + onPublish: { + behavior: appsync.HandlerBehavior.DIRECT, + integration: { + dataSourceName: lambdaDataSource.name, + lambdaConfig: { + invokeType: appsync.LambdaInvokeType.EVENT, + }, + }, + }, + onSubscribe: { + behavior: appsync.HandlerBehavior.CODE, + integration: { + dataSourceName: ddbDataSource.name, + }, + }, + }, + code: appsync.Code.fromInline('/* Used for CODE behavior handler only */'), +}); + +// Using handlerConfigs with only publish handler +new appsync.ChannelNamespace(this, 'PublishOnlyHandler', { + api, + handlerConfigs: { + onPublish: { + behavior: appsync.HandlerBehavior.DIRECT, + integration: { + dataSourceName: lambdaDataSource.name, + }, + }, + }, +}); +``` + +The `handlerConfigs` property accepts a configuration object with optional `onPublish` and `onSubscribe` handlers. Each handler requires a `behavior` that can be either `CODE` or `DIRECT`, and an `integration` configuration specifying the data source to use. + +When using `HandlerBehavior.DIRECT` with Lambda data sources, you can also specify the Lambda invocation type: + +```ts +declare const api: appsync.EventApi; +declare const lambdaDataSource: appsync.AppSyncLambdaDataSource; + +// Configure Lambda invocation type for direct handler +new appsync.ChannelNamespace(this, 'AsyncLambdaHandler', { + api, + handlerConfigs: { + onPublish: { + behavior: appsync.HandlerBehavior.DIRECT, + integration: { + dataSourceName: lambdaDataSource.name, + lambdaConfig: { + invokeType: appsync.LambdaInvokeType.EVENT, // Asynchronous invocation + }, + }, + }, + }, +}); +``` + +Note that when both publish and subscribe handlers use `HandlerBehavior.DIRECT`, you should not provide a `code` property, as code handlers are not used with direct data source behavior. diff --git a/packages/aws-cdk-lib/aws-appsync/lib/channel-namespace.ts b/packages/aws-cdk-lib/aws-appsync/lib/channel-namespace.ts index 11a7aefcc8e11..0f06cf236f004 100644 --- a/packages/aws-cdk-lib/aws-appsync/lib/channel-namespace.ts +++ b/packages/aws-cdk-lib/aws-appsync/lib/channel-namespace.ts @@ -56,6 +56,69 @@ export enum HandlerBehavior { DIRECT = 'DIRECT', } +/** + * Lambda invocation type configuration for handler integration + */ +export interface LambdaConfigOptions { + /** + * The Lambda invoke type for the integration + * + * @default - LambdaInvokeType.REQUEST_RESPONSE + */ + readonly invokeType?: LambdaInvokeType; +} + +/** + * Integration configuration for handlers + */ +export interface IntegrationOptions { + /** + * Data source to invoke for this integration + */ + readonly dataSourceName: string; + + /** + * Configuration for Lambda integration + * + * @default - No Lambda specific configuration + */ + readonly lambdaConfig?: LambdaConfigOptions; +} + +/** + * Configuration for individual event handlers + */ +export interface HandlerConfigOptions { + /** + * The behavior of the handler + */ + readonly behavior: HandlerBehavior; + + /** + * Integration configuration for the handler + */ + readonly integration: IntegrationOptions; +} + +/** + * Configuration for all handlers in the channel namespace + */ +export interface HandlerConfigsOptions { + /** + * Handler configuration for publish events + * + * @default - No publish handler configured + */ + readonly onPublish?: HandlerConfigOptions; + + /** + * Handler configuration for subscribe events + * + * @default - No subscribe handler configured + */ + readonly onSubscribe?: HandlerConfigOptions; +} + /** * Handler configuration construct for onPublish and onSubscribe */ @@ -114,6 +177,13 @@ export interface BaseChannelNamespaceProps { */ readonly subscribeHandlerConfig?: HandlerConfig; + /** + * Direct configuration for handler configs + * + * @default - Handler configs derived from publishHandlerConfig and subscribeHandlerConfig + */ + readonly handlerConfigs?: HandlerConfigsOptions; + /** * Authorization config for channel namespace * @@ -213,38 +283,45 @@ export class ChannelNamespace extends Resource implements IChannelNamespace { let handlerConfig: { [key: string]: any } = {}; - if (props.publishHandlerConfig) { - handlerConfig = { - onPublish: { - behavior: props.publishHandlerConfig?.direct ? HandlerBehavior.DIRECT : HandlerBehavior.CODE, - integration: { - dataSourceName: props.publishHandlerConfig?.dataSource?.name || '', + // If direct handlerConfigs is provided, use it + if (props.handlerConfigs) { + handlerConfig = props.handlerConfigs; + } + // Otherwise, build from publishHandlerConfig and subscribeHandlerConfig for backward compatibility + else { + if (props.publishHandlerConfig) { + handlerConfig = { + onPublish: { + behavior: props.publishHandlerConfig?.direct ? HandlerBehavior.DIRECT : HandlerBehavior.CODE, + integration: { + dataSourceName: props.publishHandlerConfig?.dataSource?.name || '', + }, }, - }, - }; - - if (handlerConfig.onPublish.behavior === HandlerBehavior.DIRECT) { - handlerConfig.onPublish.integration.lambdaConfig = { - invokeType: props.publishHandlerConfig?.lambdaInvokeType || LambdaInvokeType.REQUEST_RESPONSE, }; + + if (handlerConfig.onPublish.behavior === HandlerBehavior.DIRECT) { + handlerConfig.onPublish.integration.lambdaConfig = { + invokeType: props.publishHandlerConfig?.lambdaInvokeType || LambdaInvokeType.REQUEST_RESPONSE, + }; + } } - } - if (props.subscribeHandlerConfig) { - handlerConfig = { - ...handlerConfig, - onSubscribe: { - behavior: props.subscribeHandlerConfig?.direct ? HandlerBehavior.DIRECT : HandlerBehavior.CODE, - integration: { - dataSourceName: props.subscribeHandlerConfig?.dataSource?.name || '', + if (props.subscribeHandlerConfig) { + handlerConfig = { + ...handlerConfig, + onSubscribe: { + behavior: props.subscribeHandlerConfig?.direct ? HandlerBehavior.DIRECT : HandlerBehavior.CODE, + integration: { + dataSourceName: props.subscribeHandlerConfig?.dataSource?.name || '', + }, }, - }, - }; - - if (handlerConfig.onSubscribe.behavior === HandlerBehavior.DIRECT) { - handlerConfig.onSubscribe.integration.lambdaConfig = { - invokeType: props.subscribeHandlerConfig?.lambdaInvokeType || LambdaInvokeType.REQUEST_RESPONSE, }; + + if (handlerConfig.onSubscribe.behavior === HandlerBehavior.DIRECT) { + handlerConfig.onSubscribe.integration.lambdaConfig = { + invokeType: props.subscribeHandlerConfig?.lambdaInvokeType || LambdaInvokeType.REQUEST_RESPONSE, + }; + } } } @@ -317,6 +394,17 @@ export class ChannelNamespace extends Resource implements IChannelNamespace { } private validateHandlerConfig(props?: ChannelNamespaceProps) { + // Handle the case when direct handlerConfigs is provided + if (props?.handlerConfigs) { + // If code is provided when both handlers are direct, it's an error + const onPublishDirect = props.handlerConfigs.onPublish?.behavior === HandlerBehavior.DIRECT; + const onSubscribeDirect = props.handlerConfigs.onSubscribe?.behavior === HandlerBehavior.DIRECT; + if (onPublishDirect && onSubscribeDirect && props.code) { + throw new ValidationError('Code handlers are not supported when both publish and subscribe use the Direct data source behavior', this); + } + return; + } + // Handle the case when no handler configs are defined for publish or subscribe if (!props?.publishHandlerConfig && !props?.subscribeHandlerConfig) return undefined; diff --git a/packages/aws-cdk-lib/aws-appsync/test/appsync-eventapi-dynamodb.test.ts b/packages/aws-cdk-lib/aws-appsync/test/appsync-eventapi-dynamodb.test.ts index c14d73baba349..bbd1540105fc7 100644 --- a/packages/aws-cdk-lib/aws-appsync/test/appsync-eventapi-dynamodb.test.ts +++ b/packages/aws-cdk-lib/aws-appsync/test/appsync-eventapi-dynamodb.test.ts @@ -76,6 +76,8 @@ describe('DynamoDb Data Source configuration', () => { describe('DynamoDb Data Source association with Channel Namespace', () => { // GIVEN let table: db.Table; + let lambdaDataSource: appsync.LambdaDataSource; + beforeEach(() => { table = new db.Table(stack, 'table', { partitionKey: { @@ -83,6 +85,17 @@ describe('DynamoDb Data Source association with Channel Namespace', () => { type: db.AttributeType.STRING, }, }); + + // Create a Lambda data source for testing DIRECT behavior + lambdaDataSource = new appsync.LambdaDataSource(stack, 'lambdaDS', { + api: eventApi, + name: 'lambdaDS', + lambdaFunction: new cdk.aws_lambda.Function(stack, 'testFunction', { + code: cdk.aws_lambda.Code.fromInline('exports.handler = () => {};'), + handler: 'index.handler', + runtime: cdk.aws_lambda.Runtime.NODEJS_18_X, + }), + }); }); test('Adding a channel namespace with API data source that exists - publish and subscribe handler', () => { @@ -182,6 +195,187 @@ describe('DynamoDb Data Source association with Channel Namespace', () => { // THEN expect(when).toThrow('Direct integration is only supported for AWS_LAMBDA data sources.'); }); + + test('Adding a channel namespace with handlerConfigs - CODE behavior', () => { + // WHEN + const datasource = eventApi.addDynamoDbDataSource('ds', table); + eventApi.addChannelNamespace('configChannel', { + code: appsync.Code.fromInline('/* handler code goes here */'), + handlerConfigs: { + onPublish: { + behavior: appsync.HandlerBehavior.CODE, + integration: { + dataSourceName: datasource.name, + }, + }, + onSubscribe: { + behavior: appsync.HandlerBehavior.CODE, + integration: { + dataSourceName: datasource.name, + }, + }, + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::AppSync::ChannelNamespace', { + Name: 'configChannel', + HandlerConfigs: { + OnPublish: { + Behavior: 'CODE', + Integration: { + DataSourceName: 'ds', + }, + }, + OnSubscribe: { + Behavior: 'CODE', + Integration: { + DataSourceName: 'ds', + }, + }, + }, + }); + }); + + test('Adding a channel namespace with handlerConfigs - DIRECT behavior and Lambda invoke type', () => { + // WHEN + eventApi.addChannelNamespace('directChannel', { + handlerConfigs: { + onPublish: { + behavior: appsync.HandlerBehavior.DIRECT, + integration: { + dataSourceName: lambdaDataSource.name, + lambdaConfig: { + invokeType: appsync.LambdaInvokeType.EVENT, + }, + }, + }, + onSubscribe: { + behavior: appsync.HandlerBehavior.DIRECT, + integration: { + dataSourceName: lambdaDataSource.name, + lambdaConfig: { + invokeType: appsync.LambdaInvokeType.REQUEST_RESPONSE, + }, + }, + }, + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::AppSync::ChannelNamespace', { + Name: 'directChannel', + HandlerConfigs: { + OnPublish: { + Behavior: 'DIRECT', + Integration: { + DataSourceName: 'lambdaDS', + LambdaConfig: { + InvokeType: 'EVENT', + }, + }, + }, + OnSubscribe: { + Behavior: 'DIRECT', + Integration: { + DataSourceName: 'lambdaDS', + LambdaConfig: { + InvokeType: 'REQUEST_RESPONSE', + }, + }, + }, + }, + }); + }); + + test('Adding a channel namespace with handlerConfigs - only onPublish config', () => { + // WHEN + eventApi.addChannelNamespace('publishOnlyChannel', { + code: appsync.Code.fromInline('/* handler code goes here */'), + handlerConfigs: { + onPublish: { + behavior: appsync.HandlerBehavior.CODE, + integration: { + dataSourceName: 'testSource', + }, + }, + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::AppSync::ChannelNamespace', { + Name: 'publishOnlyChannel', + HandlerConfigs: { + OnPublish: { + Behavior: 'CODE', + Integration: { + DataSourceName: 'testSource', + }, + }, + }, + }); + }); + + test('Adding a channel namespace with handlerConfigs - validation failure when both DIRECT with code', () => { + // WHEN + const when = () => { + eventApi.addChannelNamespace('failChannel', { + code: appsync.Code.fromInline('/* handler code goes here */'), + handlerConfigs: { + onPublish: { + behavior: appsync.HandlerBehavior.DIRECT, + integration: { + dataSourceName: lambdaDataSource.name, + }, + }, + onSubscribe: { + behavior: appsync.HandlerBehavior.DIRECT, + integration: { + dataSourceName: lambdaDataSource.name, + }, + }, + }, + }); + }; + + // THEN + expect(when).toThrow('Code handlers are not supported when both publish and subscribe use the Direct data source behavior'); + }); + + test('Backwards compatibility - handlerConfigs takes precedence over handlerConfig props', () => { + // WHEN + const datasource = eventApi.addDynamoDbDataSource('ds', table); + eventApi.addChannelNamespace('precedenceChannel', { + code: appsync.Code.fromInline('/* handler code goes here */'), + publishHandlerConfig: { + dataSource: datasource, + }, + subscribeHandlerConfig: { + dataSource: datasource, + }, + handlerConfigs: { + onPublish: { + behavior: appsync.HandlerBehavior.CODE, + integration: { + dataSourceName: 'overriddenSource', + }, + }, + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::AppSync::ChannelNamespace', { + Name: 'precedenceChannel', + HandlerConfigs: { + OnPublish: { + Behavior: 'CODE', + Integration: { + DataSourceName: 'overriddenSource', + }, + }, + }, + }); + }); }); describe('adding DynamoDb data source from imported api', () => {