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..a333fe4d --- /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 === + '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 === + '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..27b79fdd --- /dev/null +++ b/examples/pipeline-resolver-with-code/functions/fetchProfile.js @@ -0,0 +1,20 @@ +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 }), + }; +} + +export function response(ctx) { + if (ctx.prev.result == null) { + return null; + } + 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