Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions e2e/api-key-import-existing.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { synthesize } from './helpers/synthesize';
import {
countResourcesByType,
findResourcesByType,
} from './helpers/assertions';

describe('examples/api-key-import-existing', () => {
let result: ReturnType<typeof synthesize>;

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();
});
});
49 changes: 49 additions & 0 deletions e2e/custom-domain-no-cfn.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { synthesize } from './helpers/synthesize';
import { countResourcesByType, getGraphQlApi } from './helpers/assertions';

describe('examples/custom-domain-no-cfn', () => {
let result: ReturnType<typeof synthesize>;

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);
});
});
65 changes: 65 additions & 0 deletions e2e/pipeline-resolver-with-code.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { synthesize } from './helpers/synthesize';
import {
countResourcesByType,
findResourcesByType,
} from './helpers/assertions';

describe('examples/pipeline-resolver-with-code', () => {
let result: ReturnType<typeof synthesize>;

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<string, unknown>;
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<string, unknown>;
expect(props.Code).toBeDefined();
expect(props.Runtime).toEqual({
Name: 'APPSYNC_JS',
RuntimeVersion: '1.0.0',
});
});
});
});
78 changes: 78 additions & 0 deletions e2e/sync-config-versioned.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { synthesize } from './helpers/synthesize';
import { findResourcesByType } from './helpers/assertions';

describe('examples/sync-config-versioned', () => {
let result: ReturnType<typeof synthesize>;

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<string, unknown>;
expect(delta).toBeDefined();
expect(delta.BaseTableTTL).toBe(43200);
expect(delta.DeltaSyncTableTTL).toBe(1440);
expect(delta.DeltaSyncTableName).toBeDefined();
});
});
34 changes: 34 additions & 0 deletions e2e/waf-pre-existing-arn.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { synthesize } from './helpers/synthesize';
import {
countResourcesByType,
findOneResourceByType,
} from './helpers/assertions';

describe('examples/waf-pre-existing-arn', () => {
let result: ReturnType<typeof synthesize>;

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<string, unknown>;
expect(props.ResourceArn).toBeDefined();

// WebACLArn is the user-provided intrinsic, faithfully preserved
const webAclArn = props.WebACLArn as Record<string, unknown>;
expect(webAclArn['Fn::ImportValue']).toBe('SharedWafAclArn');
});
});
59 changes: 32 additions & 27 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions examples/api-key-import-existing/schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type Query {
hello: String
}
43 changes: 43 additions & 0 deletions examples/api-key-import-existing/serverless.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions examples/custom-domain-no-cfn/schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type Query {
hello: String
}
Loading