From c1ab55f915818b9ec5aeaf30d476f4a8307e336d Mon Sep 17 00:00:00 2001 From: sid88in Date: Fri, 29 May 2026 01:40:27 +0000 Subject: [PATCH 1/3] test: add 5 more CFN synthesis fixtures for previously-uncovered code paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the 24-fixture suite from #704. Each new fixture targets a specific plugin code path that the existing fixtures don't exercise: - sync-config-versioned (3 tests): DynamoDB conflict resolution with OPTIMISTIC_CONCURRENCY and AUTOMERGE handlers, plus delta sync table configuration for offline-capable mobile apps. Closes a gap where syncConfig.test.ts had unit coverage but no synthesis coverage. - custom-domain-no-cfn (5 tests): The useCloudFormation: false code path on the domain config. Asserts the absence of CFN resources (DomainName, DomainNameApiAssociation, RecordSet, Certificate) that the useCloudFormation: true variant DOES create. Complements the existing custom-domain fixture. - waf-pre-existing-arn (2 tests): WAF attached via an existing ACL ARN rather than created inline via rules. Different code path in Waf.ts that emits only WebACLAssociation, not WebACL. Complements the existing waf fixture. - pipeline-resolver-with-code (3 tests): A pipeline resolver with its own top-level JS code (before/after handlers) in addition to per- function code. Different from the existing pipeline-resolvers fixture which only has function-level code. - api-key-import-existing (3 tests): Importing an existing API key via apiKeyId for stable migrations / blue-green deploys, alongside an auto-generated key. Verifies ApiKeyId is passed through faithfully. Test surface grows from 25 -> 30 suites and 68 -> 84 assertions. Real plugin behavior caught while building the fixtures: - DataSource config 'versioned: true' is silently ignored unless 'deltaSyncConfig' is ALSO provided. Without delta sync config, the CFN output omits the Versioned attribute entirely. Worth noting in docs eventually, but not breaking — included both in this fixture to demonstrate the combination that actually works. --- e2e/api-key-import-existing.e2e.test.ts | 47 +++++++++++ e2e/custom-domain-no-cfn.e2e.test.ts | 49 +++++++++++ e2e/pipeline-resolver-with-code.e2e.test.ts | 65 +++++++++++++++ e2e/sync-config-versioned.e2e.test.ts | 78 ++++++++++++++++++ e2e/waf-pre-existing-arn.e2e.test.ts | 34 ++++++++ examples/README.md | 59 +++++++------- .../api-key-import-existing/schema.graphql | 3 + .../api-key-import-existing/serverless.yml | 43 ++++++++++ examples/custom-domain-no-cfn/schema.graphql | 3 + examples/custom-domain-no-cfn/serverless.yml | 44 ++++++++++ .../functions/fetchProfile.js | 12 +++ .../functions/fetchUser.js | 12 +++ .../resolvers/getUserDetails.js | 18 +++++ .../schema.graphql | 15 ++++ .../serverless.yml | 77 ++++++++++++++++++ examples/sync-config-versioned/schema.graphql | 15 ++++ examples/sync-config-versioned/serverless.yml | 81 +++++++++++++++++++ examples/waf-pre-existing-arn/schema.graphql | 3 + examples/waf-pre-existing-arn/serverless.yml | 37 +++++++++ 19 files changed, 668 insertions(+), 27 deletions(-) create mode 100644 e2e/api-key-import-existing.e2e.test.ts create mode 100644 e2e/custom-domain-no-cfn.e2e.test.ts create mode 100644 e2e/pipeline-resolver-with-code.e2e.test.ts create mode 100644 e2e/sync-config-versioned.e2e.test.ts create mode 100644 e2e/waf-pre-existing-arn.e2e.test.ts create mode 100644 examples/api-key-import-existing/schema.graphql create mode 100644 examples/api-key-import-existing/serverless.yml create mode 100644 examples/custom-domain-no-cfn/schema.graphql create mode 100644 examples/custom-domain-no-cfn/serverless.yml create mode 100644 examples/pipeline-resolver-with-code/functions/fetchProfile.js create mode 100644 examples/pipeline-resolver-with-code/functions/fetchUser.js create mode 100644 examples/pipeline-resolver-with-code/resolvers/getUserDetails.js create mode 100644 examples/pipeline-resolver-with-code/schema.graphql create mode 100644 examples/pipeline-resolver-with-code/serverless.yml create mode 100644 examples/sync-config-versioned/schema.graphql create mode 100644 examples/sync-config-versioned/serverless.yml create mode 100644 examples/waf-pre-existing-arn/schema.graphql create mode 100644 examples/waf-pre-existing-arn/serverless.yml diff --git a/e2e/api-key-import-existing.e2e.test.ts b/e2e/api-key-import-existing.e2e.test.ts new file mode 100644 index 00000000..9e44d99a --- /dev/null +++ b/e2e/api-key-import-existing.e2e.test.ts @@ -0,0 +1,47 @@ +import { synthesize } from './helpers/synthesize'; +import { + countResourcesByType, + findResourcesByType, +} from './helpers/assertions'; + +describe('examples/api-key-import-existing', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/api-key-import-existing'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates two API key resources', () => { + expect(countResourcesByType(result.template, 'AWS::AppSync::ApiKey')).toBe( + 2, + ); + }); + + it('passes the apiKeyId through to ApiKeyId on the stable key', () => { + const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey'); + const stable = keys.find( + (k) => + (k.resource.Properties?.Description as string) === + 'Stable key migrated from previous infrastructure', + ); + if (!stable) throw new Error('stable api key not found'); + expect(stable.resource.Properties?.ApiKeyId).toBe( + 'da2-rotated-stable-key-id-abc123', + ); + }); + + it('does NOT set ApiKeyId on the rotating key (lets AppSync generate one)', () => { + const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey'); + const rotating = keys.find( + (k) => + (k.resource.Properties?.Description as string) === + 'Net-new key created by this stack', + ); + if (!rotating) throw new Error('rotating api key not found'); + expect(rotating.resource.Properties?.ApiKeyId).toBeUndefined(); + }); +}); diff --git a/e2e/custom-domain-no-cfn.e2e.test.ts b/e2e/custom-domain-no-cfn.e2e.test.ts new file mode 100644 index 00000000..c7c55d5c --- /dev/null +++ b/e2e/custom-domain-no-cfn.e2e.test.ts @@ -0,0 +1,49 @@ +import { synthesize } from './helpers/synthesize'; +import { countResourcesByType, getGraphQlApi } from './helpers/assertions'; + +describe('examples/custom-domain-no-cfn', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/custom-domain-no-cfn'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('still creates the GraphQLApi (domain is independent of API)', () => { + const { resource } = getGraphQlApi(result.template); + expect(resource.Properties?.Name).toBe('custom-domain-no-cfn'); + }); + + it('does NOT create an AWS::AppSync::DomainName resource', () => { + expect( + countResourcesByType(result.template, 'AWS::AppSync::DomainName'), + ).toBe(0); + }); + + it('does NOT create an AWS::AppSync::DomainNameApiAssociation resource', () => { + expect( + countResourcesByType( + result.template, + 'AWS::AppSync::DomainNameApiAssociation', + ), + ).toBe(0); + }); + + it('does NOT create an AWS::Route53::RecordSet resource', () => { + expect( + countResourcesByType(result.template, 'AWS::Route53::RecordSet'), + ).toBe(0); + }); + + it('does NOT create an AWS::CertificateManager::Certificate resource', () => { + expect( + countResourcesByType( + result.template, + 'AWS::CertificateManager::Certificate', + ), + ).toBe(0); + }); +}); diff --git a/e2e/pipeline-resolver-with-code.e2e.test.ts b/e2e/pipeline-resolver-with-code.e2e.test.ts new file mode 100644 index 00000000..0da55a30 --- /dev/null +++ b/e2e/pipeline-resolver-with-code.e2e.test.ts @@ -0,0 +1,65 @@ +import { synthesize } from './helpers/synthesize'; +import { + countResourcesByType, + findResourcesByType, +} from './helpers/assertions'; + +describe('examples/pipeline-resolver-with-code', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/pipeline-resolver-with-code'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates one pipeline resolver and two pipeline functions', () => { + expect( + countResourcesByType(result.template, 'AWS::AppSync::Resolver'), + ).toBe(1); + expect( + countResourcesByType( + result.template, + 'AWS::AppSync::FunctionConfiguration', + ), + ).toBe(2); + }); + + it('the pipeline resolver has both its own Code AND a Runtime', () => { + const resolvers = findResourcesByType( + result.template, + 'AWS::AppSync::Resolver', + ); + expect(resolvers).toHaveLength(1); + const props = resolvers[0].resource.Properties as Record; + expect(props.Kind).toBe('PIPELINE'); + // This is the key assertion: the resolver itself has Code (the + // before/after JS), not just delegating to its functions. + expect(props.Code).toBeDefined(); + expect(props.Runtime).toEqual({ + Name: 'APPSYNC_JS', + RuntimeVersion: '1.0.0', + }); + // And the PipelineConfig references both functions + const pipelineConfig = props.PipelineConfig as { Functions: unknown[] }; + expect(pipelineConfig.Functions).toHaveLength(2); + }); + + it('each pipeline function also has its own Code + Runtime', () => { + const functions = findResourcesByType( + result.template, + 'AWS::AppSync::FunctionConfiguration', + ); + expect(functions).toHaveLength(2); + functions.forEach((fn) => { + const props = fn.resource.Properties as Record; + expect(props.Code).toBeDefined(); + expect(props.Runtime).toEqual({ + Name: 'APPSYNC_JS', + RuntimeVersion: '1.0.0', + }); + }); + }); +}); diff --git a/e2e/sync-config-versioned.e2e.test.ts b/e2e/sync-config-versioned.e2e.test.ts new file mode 100644 index 00000000..bd9ee3ea --- /dev/null +++ b/e2e/sync-config-versioned.e2e.test.ts @@ -0,0 +1,78 @@ +import { synthesize } from './helpers/synthesize'; +import { findResourcesByType } from './helpers/assertions'; + +describe('examples/sync-config-versioned', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/sync-config-versioned'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('emits SyncConfig with OPTIMISTIC_CONCURRENCY on updatePost resolver', () => { + const resolvers = findResourcesByType( + result.template, + 'AWS::AppSync::Resolver', + ); + const updatePost = resolvers.find( + (r) => + r.resource.Properties?.TypeName === 'Mutation' && + r.resource.Properties?.FieldName === 'updatePost', + ); + if (!updatePost) throw new Error('Mutation.updatePost resolver not found'); + const sync = updatePost.resource.Properties?.SyncConfig as Record< + string, + unknown + >; + expect(sync).toBeDefined(); + expect(sync.ConflictDetection).toBe('VERSION'); + expect(sync.ConflictHandler).toBe('OPTIMISTIC_CONCURRENCY'); + }); + + it('emits SyncConfig with AUTOMERGE on mergePost resolver', () => { + const resolvers = findResourcesByType( + result.template, + 'AWS::AppSync::Resolver', + ); + const mergePost = resolvers.find( + (r) => + r.resource.Properties?.TypeName === 'Mutation' && + r.resource.Properties?.FieldName === 'mergePost', + ); + if (!mergePost) throw new Error('Mutation.mergePost resolver not found'); + const sync = mergePost.resource.Properties?.SyncConfig as Record< + string, + unknown + >; + expect(sync).toBeDefined(); + expect(sync.ConflictDetection).toBe('VERSION'); + expect(sync.ConflictHandler).toBe('AUTOMERGE'); + }); + + it('marks the DynamoDB data source as versioned with delta sync config', () => { + const dataSources = findResourcesByType( + result.template, + 'AWS::AppSync::DataSource', + ); + const posts = dataSources.find( + (ds) => ds.resource.Properties?.Name === 'posts', + ); + if (!posts) throw new Error('posts data source not found'); + const ddbConfig = posts.resource.Properties?.DynamoDBConfig as Record< + string, + unknown + >; + expect(ddbConfig).toBeDefined(); + // The plugin only emits Versioned: true when deltaSyncConfig is ALSO + // provided — this fixture covers both together. + expect(ddbConfig.Versioned).toBe(true); + const delta = ddbConfig.DeltaSyncConfig as Record; + expect(delta).toBeDefined(); + expect(delta.BaseTableTTL).toBe(43200); + expect(delta.DeltaSyncTableTTL).toBe(1440); + expect(delta.DeltaSyncTableName).toBeDefined(); + }); +}); diff --git a/e2e/waf-pre-existing-arn.e2e.test.ts b/e2e/waf-pre-existing-arn.e2e.test.ts new file mode 100644 index 00000000..7245195b --- /dev/null +++ b/e2e/waf-pre-existing-arn.e2e.test.ts @@ -0,0 +1,34 @@ +import { synthesize } from './helpers/synthesize'; +import { + countResourcesByType, + findOneResourceByType, +} from './helpers/assertions'; + +describe('examples/waf-pre-existing-arn', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/waf-pre-existing-arn'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('does NOT create an AWS::WAFv2::WebACL resource (uses pre-existing)', () => { + expect(countResourcesByType(result.template, 'AWS::WAFv2::WebACL')).toBe(0); + }); + + it('creates an AWS::WAFv2::WebACLAssociation pointing at the imported ARN', () => { + const { resource } = findOneResourceByType( + result.template, + 'AWS::WAFv2::WebACLAssociation', + ); + const props = resource.Properties as Record; + expect(props.ResourceArn).toBeDefined(); + + // WebACLArn is the user-provided intrinsic, faithfully preserved + const webAclArn = props.WebACLArn as Record; + expect(webAclArn['Fn::ImportValue']).toBe('SharedWafAclArn'); + }); +}); diff --git a/examples/README.md b/examples/README.md index c51a39f9..70dbb25d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,33 +14,38 @@ stay current with the plugin's actual behavior — if they break, CI fails. ## Index -| Example | What it shows | -| --------------------------------------------------- | ------------------------------------------------------------------------------ | -| [basic-api-key](./basic-api-key/) | Simplest possible setup: API key auth, one DynamoDB data source, one resolver | -| [cognito-userpools](./cognito-userpools/) | Cognito User Pools authentication with default action and user groups | -| [iam-auth](./iam-auth/) | AWS IAM authentication | -| [oidc-auth](./oidc-auth/) | OpenID Connect authentication | -| [lambda-authorizer](./lambda-authorizer/) | Custom AWS Lambda authorizer | -| [multi-auth](./multi-auth/) | Multiple authentication providers (API Key primary + Cognito + IAM additional) | -| [lambda-resolvers-js](./lambda-resolvers-js/) | JS resolvers bundled with esbuild + Lambda data sources | -| [lambda-resolvers-vtl](./lambda-resolvers-vtl/) | VTL request/response mapping templates | -| [pipeline-resolvers](./pipeline-resolvers/) | Pipeline resolvers with reusable functions | -| [datasource-http](./datasource-http/) | HTTP data source with optional IAM signing | -| [datasource-none](./datasource-none/) | NONE data source (local resolvers) | -| [datasource-eventbridge](./datasource-eventbridge/) | EventBridge data source | -| [datasource-opensearch](./datasource-opensearch/) | Amazon OpenSearch Service data source | -| [datasource-rds](./datasource-rds/) | Relational Database (Aurora Serverless) data source | -| [caching](./caching/) | Server-side caching configuration | -| [waf](./waf/) | AWS WAF v2 rules attached to the API | -| [logging-xray](./logging-xray/) | Field-level logging plus X-Ray tracing | -| [custom-domain](./custom-domain/) | Custom domain with route53 record management | -| [introspection-disabled](./introspection-disabled/) | Disabled introspection and query depth limit | -| [substitutions](./substitutions/) | VTL `${variable}` substitutions in resolvers | -| [environment-variables](./environment-variables/) | Environment variables for JS resolvers | -| [api-keys-multiple](./api-keys-multiple/) | Multiple API keys with different expiry policies | -| [tags](./tags/) | Resource tagging on the AppSync API | -| [visibility-private](./visibility-private/) | PRIVATE API visibility for VPC-only access | -| [schema-multiple-files](./schema-multiple-files/) | Schema split across multiple `.graphql` files | +| Example | What it shows | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| [basic-api-key](./basic-api-key/) | Simplest possible setup: API key auth, one DynamoDB data source, one resolver | +| [cognito-userpools](./cognito-userpools/) | Cognito User Pools authentication with default action and user groups | +| [iam-auth](./iam-auth/) | AWS IAM authentication | +| [oidc-auth](./oidc-auth/) | OpenID Connect authentication | +| [lambda-authorizer](./lambda-authorizer/) | Custom AWS Lambda authorizer | +| [multi-auth](./multi-auth/) | Multiple authentication providers (API Key primary + Cognito + IAM additional) | +| [lambda-resolvers-js](./lambda-resolvers-js/) | JS resolvers bundled with esbuild + Lambda data sources | +| [lambda-resolvers-vtl](./lambda-resolvers-vtl/) | VTL request/response mapping templates | +| [pipeline-resolvers](./pipeline-resolvers/) | Pipeline resolvers with reusable functions | +| [datasource-http](./datasource-http/) | HTTP data source with optional IAM signing | +| [datasource-none](./datasource-none/) | NONE data source (local resolvers) | +| [datasource-eventbridge](./datasource-eventbridge/) | EventBridge data source | +| [datasource-opensearch](./datasource-opensearch/) | Amazon OpenSearch Service data source | +| [datasource-rds](./datasource-rds/) | Relational Database (Aurora Serverless) data source | +| [caching](./caching/) | Server-side caching configuration | +| [waf](./waf/) | AWS WAF v2 rules attached to the API | +| [logging-xray](./logging-xray/) | Field-level logging plus X-Ray tracing | +| [custom-domain](./custom-domain/) | Custom domain with route53 record management | +| [introspection-disabled](./introspection-disabled/) | Disabled introspection and query depth limit | +| [substitutions](./substitutions/) | VTL `${variable}` substitutions in resolvers | +| [environment-variables](./environment-variables/) | Environment variables for JS resolvers | +| [api-keys-multiple](./api-keys-multiple/) | Multiple API keys with different expiry policies | +| [tags](./tags/) | Resource tagging on the AppSync API | +| [visibility-private](./visibility-private/) | PRIVATE API visibility for VPC-only access | +| [schema-multiple-files](./schema-multiple-files/) | Schema split across multiple `.graphql` files | +| [sync-config-versioned](./sync-config-versioned/) | DynamoDB conflict resolution (OPTIMISTIC_CONCURRENCY + AUTOMERGE) with delta sync | +| [custom-domain-no-cfn](./custom-domain-no-cfn/) | Custom domain managed outside CloudFormation (via the plugin's CLI commands) | +| [waf-pre-existing-arn](./waf-pre-existing-arn/) | Attach a pre-existing shared WAF WebACL by ARN | +| [pipeline-resolver-with-code](./pipeline-resolver-with-code/) | Pipeline resolver with its own top-level JS (before/after handlers) plus per-function code | +| [api-key-import-existing](./api-key-import-existing/) | Import an existing API key by ID (stable migration) alongside auto-generated keys | ## How to run an example diff --git a/examples/api-key-import-existing/schema.graphql b/examples/api-key-import-existing/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/api-key-import-existing/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/api-key-import-existing/serverless.yml b/examples/api-key-import-existing/serverless.yml new file mode 100644 index 00000000..0db2571b --- /dev/null +++ b/examples/api-key-import-existing/serverless.yml @@ -0,0 +1,43 @@ +service: appsync-api-key-import-existing + +provider: + name: aws + runtime: nodejs22.x + +plugins: + - serverless-appsync-plugin + +# When `apiKeyId` is specified on an API key config, AppSync retains +# the existing key value (the literal API key string) on next deploy +# rather than rotating it. This is the right path when: +# +# - Migrating from another AppSync setup that already issued keys +# to clients you can't easily re-key +# - Doing blue/green deployments where consumers need a stable key +# - Importing an AppSync stack that was previously managed manually +# +# Note: the API key VALUE itself is not in CFN. You'd typically +# resolve it from a Secrets Manager parameter or env var. Here we +# show the apiKeyId being passed through deterministically. + +appSync: + name: api-key-import-existing + authentication: + type: API_KEY + apiKeys: + - name: stable + description: Stable key migrated from previous infrastructure + apiKeyId: da2-rotated-stable-key-id-abc123 + expiresAfter: 365d + - name: rotating + description: Net-new key created by this stack + expiresAfter: 90d + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/custom-domain-no-cfn/schema.graphql b/examples/custom-domain-no-cfn/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/custom-domain-no-cfn/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/custom-domain-no-cfn/serverless.yml b/examples/custom-domain-no-cfn/serverless.yml new file mode 100644 index 00000000..8da4df01 --- /dev/null +++ b/examples/custom-domain-no-cfn/serverless.yml @@ -0,0 +1,44 @@ +service: appsync-custom-domain-no-cfn + +provider: + name: aws + runtime: nodejs22.x + +plugins: + - serverless-appsync-plugin + +# When useCloudFormation is false, the plugin does NOT create +# AWS::AppSync::DomainName, AWS::AppSync::DomainNameApiAssociation, +# or AWS::Route53::RecordSet resources. Domain lifecycle (creation, +# certificate validation, Route53 record) is managed outside the +# stack — typically via the plugin's CLI commands: +# +# sls appsync domain create-domain +# sls appsync domain associate-api +# sls appsync domain create-record +# +# This is the right choice when the domain is shared across stacks, +# managed by a separate team, or needs to outlive any single deploy. + +appSync: + name: custom-domain-no-cfn + authentication: + type: API_KEY + apiKeys: + - name: default + + domain: + name: api.example.com + useCloudFormation: false + certificateArn: + Fn::Sub: 'arn:${AWS::Partition}:acm:us-east-1:${AWS::AccountId}:certificate/abc-123' + hostedZoneId: Z1234567890ABC + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/pipeline-resolver-with-code/functions/fetchProfile.js b/examples/pipeline-resolver-with-code/functions/fetchProfile.js new file mode 100644 index 00000000..e6fd080e --- /dev/null +++ b/examples/pipeline-resolver-with-code/functions/fetchProfile.js @@ -0,0 +1,12 @@ +import { util } from '@aws-appsync/utils'; + +export function request(ctx) { + return { + operation: 'GetItem', + key: util.dynamodb.toMapValues({ userId: ctx.prev.result.id }), + }; +} + +export function response(ctx) { + return { ...ctx.prev.result, profile: ctx.result }; +} diff --git a/examples/pipeline-resolver-with-code/functions/fetchUser.js b/examples/pipeline-resolver-with-code/functions/fetchUser.js new file mode 100644 index 00000000..636adfae --- /dev/null +++ b/examples/pipeline-resolver-with-code/functions/fetchUser.js @@ -0,0 +1,12 @@ +import { util } from '@aws-appsync/utils'; + +export function request(ctx) { + return { + operation: 'GetItem', + key: util.dynamodb.toMapValues({ id: ctx.args.id }), + }; +} + +export function response(ctx) { + return ctx.result; +} diff --git a/examples/pipeline-resolver-with-code/resolvers/getUserDetails.js b/examples/pipeline-resolver-with-code/resolvers/getUserDetails.js new file mode 100644 index 00000000..3d83f25c --- /dev/null +++ b/examples/pipeline-resolver-with-code/resolvers/getUserDetails.js @@ -0,0 +1,18 @@ +// Top-level pipeline resolver: runs BEFORE the function chain in +// `request()` and AFTER all functions complete in `response()`. + +import { util } from '@aws-appsync/utils'; + +export function request(ctx) { + // Pre-flight validation: reject empty IDs before the pipeline runs + if (!ctx.args.id) { + util.error('Missing required argument: id'); + } + return {}; +} + +export function response(ctx) { + // ctx.prev.result is the output of the last function (fetchProfile) + // which itself wove together the user and profile data. + return ctx.prev.result; +} diff --git a/examples/pipeline-resolver-with-code/schema.graphql b/examples/pipeline-resolver-with-code/schema.graphql new file mode 100644 index 00000000..d6174f26 --- /dev/null +++ b/examples/pipeline-resolver-with-code/schema.graphql @@ -0,0 +1,15 @@ +type User { + id: ID! + name: String! + profile: Profile +} + +type Profile { + userId: ID! + bio: String + avatar: String +} + +type Query { + getUserDetails(id: ID!): User +} diff --git a/examples/pipeline-resolver-with-code/serverless.yml b/examples/pipeline-resolver-with-code/serverless.yml new file mode 100644 index 00000000..a89655df --- /dev/null +++ b/examples/pipeline-resolver-with-code/serverless.yml @@ -0,0 +1,77 @@ +service: appsync-pipeline-resolver-with-code + +provider: + name: aws + runtime: nodejs22.x + +plugins: + - serverless-appsync-plugin + +# Pipeline resolvers can have their own top-level `code:` file that +# defines a request() handler running BEFORE the function chain and +# a response() handler running AFTER. This is useful for: +# - Authorization / argument validation before the pipeline runs +# - Stitching / transforming the final result after all functions +# - Selecting which functions to run conditionally +# +# This is a different code path than pipeline functions WITH their +# own code (which the pipeline-resolvers example already exercises). +# Here, BOTH the pipeline resolver AND each function have code. + +appSync: + name: pipeline-resolver-with-code + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Query.getUserDetails: + kind: PIPELINE + code: ./resolvers/getUserDetails.js + functions: + - fetchUser + - fetchProfile + + pipelineFunctions: + fetchUser: + dataSource: users + code: ./functions/fetchUser.js + fetchProfile: + dataSource: profiles + code: ./functions/fetchProfile.js + + dataSources: + users: + type: AMAZON_DYNAMODB + config: + tableName: !Ref UsersTable + profiles: + type: AMAZON_DYNAMODB + config: + tableName: !Ref ProfilesTable + +resources: + Resources: + UsersTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: ${self:service}-${sls:stage}-users + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: id + KeyType: HASH + AttributeDefinitions: + - AttributeName: id + AttributeType: S + ProfilesTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: ${self:service}-${sls:stage}-profiles + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: userId + KeyType: HASH + AttributeDefinitions: + - AttributeName: userId + AttributeType: S diff --git a/examples/sync-config-versioned/schema.graphql b/examples/sync-config-versioned/schema.graphql new file mode 100644 index 00000000..35c34c0e --- /dev/null +++ b/examples/sync-config-versioned/schema.graphql @@ -0,0 +1,15 @@ +type Post { + id: ID! + title: String! + body: String + _version: Int +} + +type Mutation { + updatePost(id: ID!, title: String, body: String, expectedVersion: Int!): Post + mergePost(id: ID!, title: String, body: String, expectedVersion: Int!): Post +} + +type Query { + _empty: String +} diff --git a/examples/sync-config-versioned/serverless.yml b/examples/sync-config-versioned/serverless.yml new file mode 100644 index 00000000..3ebc207f --- /dev/null +++ b/examples/sync-config-versioned/serverless.yml @@ -0,0 +1,81 @@ +service: appsync-sync-config-versioned + +provider: + name: aws + runtime: nodejs22.x + +plugins: + - serverless-appsync-plugin + +# DynamoDB versioned conflict detection is used for offline-capable +# mobile apps that need to sync data with the server and resolve +# conflicts when the same item is edited offline by multiple clients. +# AppSync stores a _version attribute on each item; client mutations +# include the version they read, and AppSync rejects mutations whose +# version is stale (OPTIMISTIC_CONCURRENCY) or automatically merges +# them (AUTOMERGE) or delegates to a Lambda (LAMBDA). + +appSync: + name: sync-config-versioned + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Mutation.updatePost: + kind: UNIT + dataSource: posts + sync: + conflictDetection: VERSION + conflictHandler: OPTIMISTIC_CONCURRENCY + + Mutation.mergePost: + kind: UNIT + dataSource: posts + sync: + conflictDetection: VERSION + conflictHandler: AUTOMERGE + + dataSources: + posts: + type: AMAZON_DYNAMODB + config: + tableName: !Ref PostsTable + versioned: true + deltaSyncConfig: + deltaSyncTableName: !Ref PostsDeltaTable + baseTableTTL: 43200 + deltaSyncTableTTL: 1440 + +resources: + Resources: + PostsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: ${self:service}-${sls:stage}-posts + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: id + KeyType: HASH + AttributeDefinitions: + - AttributeName: id + AttributeType: S + PostsDeltaTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: ${self:service}-${sls:stage}-posts-delta + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: ds_pk + KeyType: HASH + - AttributeName: ds_sk + KeyType: RANGE + AttributeDefinitions: + - AttributeName: ds_pk + AttributeType: S + - AttributeName: ds_sk + AttributeType: S + TimeToLiveSpecification: + AttributeName: _ttl + Enabled: true diff --git a/examples/waf-pre-existing-arn/schema.graphql b/examples/waf-pre-existing-arn/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/waf-pre-existing-arn/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/waf-pre-existing-arn/serverless.yml b/examples/waf-pre-existing-arn/serverless.yml new file mode 100644 index 00000000..ecc87bfb --- /dev/null +++ b/examples/waf-pre-existing-arn/serverless.yml @@ -0,0 +1,37 @@ +service: appsync-waf-pre-existing-arn + +provider: + name: aws + runtime: nodejs22.x + +plugins: + - serverless-appsync-plugin + +# When `waf.arn` is set, the plugin assumes the WebACL was created +# elsewhere (e.g. in a shared security stack or via Terraform) and +# only emits the WebACLAssociation tying the existing ACL to this +# API. No WAFv2::WebACL resource is created. +# +# This is the right shape for organizations with centralized WAF rules +# applied across multiple APIs/services. + +appSync: + name: waf-pre-existing-arn + authentication: + type: API_KEY + apiKeys: + - name: default + + waf: + enabled: true + arn: + Fn::ImportValue: SharedWafAclArn + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE From fa71f7b3182914478862682bf45e1a920b3c92e4 Mon Sep 17 00:00:00 2001 From: Siddharth Gupta Date: Fri, 29 May 2026 07:25:39 -0700 Subject: [PATCH 2/3] test: drop unnecessary type casts in api-key-import-existing (review) --- e2e/api-key-import-existing.e2e.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/api-key-import-existing.e2e.test.ts b/e2e/api-key-import-existing.e2e.test.ts index 9e44d99a..a333fe4d 100644 --- a/e2e/api-key-import-existing.e2e.test.ts +++ b/e2e/api-key-import-existing.e2e.test.ts @@ -25,7 +25,7 @@ describe('examples/api-key-import-existing', () => { const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey'); const stable = keys.find( (k) => - (k.resource.Properties?.Description as string) === + k.resource.Properties?.Description === 'Stable key migrated from previous infrastructure', ); if (!stable) throw new Error('stable api key not found'); @@ -38,7 +38,7 @@ describe('examples/api-key-import-existing', () => { const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey'); const rotating = keys.find( (k) => - (k.resource.Properties?.Description as string) === + k.resource.Properties?.Description === 'Net-new key created by this stack', ); if (!rotating) throw new Error('rotating api key not found'); From 91a4916bea71325c06a95c3ca5bbe199603c0706 Mon Sep 17 00:00:00 2001 From: Siddharth Gupta Date: Sat, 30 May 2026 11:22:59 -0700 Subject: [PATCH 3/3] test: guard fetchProfile against null ctx.prev.result (CodeRabbit) --- .../functions/fetchProfile.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/pipeline-resolver-with-code/functions/fetchProfile.js b/examples/pipeline-resolver-with-code/functions/fetchProfile.js index e6fd080e..27b79fdd 100644 --- a/examples/pipeline-resolver-with-code/functions/fetchProfile.js +++ b/examples/pipeline-resolver-with-code/functions/fetchProfile.js @@ -1,6 +1,11 @@ -import { util } from '@aws-appsync/utils'; +import { util, runtime } from '@aws-appsync/utils'; export function request(ctx) { + // fetchUser returns null when the user isn't found; short-circuit the + // pipeline function instead of dereferencing a null result. + if (ctx.prev.result == null) { + runtime.earlyReturn(null); + } return { operation: 'GetItem', key: util.dynamodb.toMapValues({ userId: ctx.prev.result.id }), @@ -8,5 +13,8 @@ export function request(ctx) { } export function response(ctx) { + if (ctx.prev.result == null) { + return null; + } return { ...ctx.prev.result, profile: ctx.result }; }