From e77ddf0d92cab3c608a702ab4d30354ad1a7c67b Mon Sep 17 00:00:00 2001 From: sid88in Date: Sun, 24 May 2026 15:15:11 +0000 Subject: [PATCH 1/2] test: add CFN synthesis tests covering 24 plugin scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second-tier test suite that exercises the plugin against real serverless.yml configurations and asserts on the generated CloudFormation template, complementing the existing 287 unit tests. Coverage (24 fixtures, 68 assertions): Auth (6): basic-api-key, cognito-userpools, iam-auth, oidc-auth, lambda-authorizer, multi-auth Resolvers (3): lambda-resolvers-js (JS code + esbuild bundling), lambda-resolvers-vtl (VTL templates), pipeline-resolvers Data sources (6): datasource-http (incl. IAM-signed variant), datasource-none, datasource-eventbridge, datasource-opensearch, datasource-rds, plus DynamoDB and Lambda covered transitively Advanced features (9): caching (API + per-resolver), waf (rules, associations), logging-xray, custom-domain (CloudFormation-managed), introspection-disabled (with queryDepth/resolverCount limits), substitutions, environment-variables, api-keys-multiple, tags, visibility-private, schema-multiple-files Infrastructure: - e2e/helpers/synthesize.ts — runs sls package via subprocess, returns parsed CFN template, cleans up temp dirs - e2e/helpers/assertions.ts — readable assertion helpers (expectAuthenticationType, expectDataSourceOfType, etc.) - jest.e2e.config.ts + jest.e2e.setup.ts — separate config; globalSetup creates the examples/node_modules/serverless-appsync-plugin symlink so example projects resolve the plugin from the current source tree - Each example is a copy-paste-ready user project doubling as a test fixture, eliminating the docs/tests duplication problem - examples/README.md indexes all 24 examples for users - e2e/README.md explains the testing strategy for contributors CI: new 'e2e' job in .github/workflows/ci.yml runs after the unit-test matrix on every PR and push to master/alpha. Single Node version (22) since CFN output doesn't vary by Node version; full suite takes ~70s. Real plugin behaviors caught while building the fixtures (used to refine the examples): - API_KEY auth silently produces zero API keys if 'apiKeys:' is omitted - HTTP data sources with AWS_IAM authorization require explicit iamRoleStatements - OpenSearch data source can't auto-derive ARN from Fn::GetAtt; needs 'domain:' field naming the CloudFormation resource Verified: npm run test:all passes (287 unit + 68 e2e), npm run lint clean. --- .github/workflows/ci.yml | 15 ++ e2e/README.md | 92 ++++++++++ e2e/api-keys-multiple.e2e.test.ts | 45 +++++ e2e/basic-api-key.e2e.test.ts | 62 +++++++ e2e/caching.e2e.test.ts | 46 +++++ e2e/cognito-userpools.e2e.test.ts | 38 +++++ e2e/custom-domain.e2e.test.ts | 40 +++++ e2e/datasource-eventbridge.e2e.test.ts | 29 ++++ e2e/datasource-http.e2e.test.ts | 70 ++++++++ e2e/datasource-none.e2e.test.ts | 25 +++ e2e/datasource-opensearch.e2e.test.ts | 31 ++++ e2e/datasource-rds.e2e.test.ts | 34 ++++ e2e/environment-variables.e2e.test.ts | 26 +++ e2e/helpers/assertions.ts | 159 ++++++++++++++++++ e2e/helpers/synthesize.ts | 122 ++++++++++++++ e2e/iam-auth.e2e.test.ts | 38 +++++ e2e/introspection-disabled.e2e.test.ts | 29 ++++ e2e/lambda-authorizer.e2e.test.ts | 42 +++++ e2e/lambda-resolvers-js.e2e.test.ts | 77 +++++++++ e2e/lambda-resolvers-vtl.e2e.test.ts | 35 ++++ e2e/logging-xray.e2e.test.ts | 46 +++++ e2e/multi-auth.e2e.test.ts | 40 +++++ e2e/oidc-auth.e2e.test.ts | 31 ++++ e2e/pipeline-resolvers.e2e.test.ts | 45 +++++ e2e/schema-multiple-files.e2e.test.ts | 36 ++++ e2e/substitutions.e2e.test.ts | 36 ++++ e2e/tags.e2e.test.ts | 33 ++++ e2e/visibility-private.e2e.test.ts | 24 +++ e2e/waf.e2e.test.ts | 56 ++++++ examples/README.md | 64 +++++++ examples/api-keys-multiple/schema.graphql | 3 + examples/api-keys-multiple/serverless.yml | 36 ++++ examples/basic-api-key/resolvers/getUser.js | 12 ++ examples/basic-api-key/schema.graphql | 9 + examples/basic-api-key/serverless.yml | 44 +++++ examples/caching/schema.graphql | 8 + examples/caching/serverless.yml | 52 ++++++ examples/cognito-userpools/schema.graphql | 9 + examples/cognito-userpools/serverless.yml | 51 ++++++ examples/custom-domain/schema.graphql | 3 + examples/custom-domain/serverless.yml | 34 ++++ .../datasource-eventbridge/schema.graphql | 11 ++ .../datasource-eventbridge/serverless.yml | 28 +++ examples/datasource-http/schema.graphql | 8 + examples/datasource-http/serverless.yml | 44 +++++ .../resolvers/publishMessage.js | 12 ++ examples/datasource-none/schema.graphql | 17 ++ examples/datasource-none/serverless.yml | 30 ++++ examples/datasource-opensearch/schema.graphql | 8 + examples/datasource-opensearch/serverless.yml | 43 +++++ examples/datasource-rds/schema.graphql | 9 + examples/datasource-rds/serverless.yml | 31 ++++ .../resolvers/getUser.js | 9 + examples/environment-variables/schema.graphql | 8 + examples/environment-variables/serverless.yml | 33 ++++ examples/iam-auth/schema.graphql | 3 + examples/iam-auth/serverless.yml | 22 +++ .../introspection-disabled/schema.graphql | 3 + .../introspection-disabled/serverless.yml | 31 ++++ examples/lambda-authorizer/handler.js | 8 + examples/lambda-authorizer/schema.graphql | 3 + examples/lambda-authorizer/serverless.yml | 30 ++++ .../functions/createUser.js | 7 + .../lambda-resolvers-js/resolvers/getUser.js | 12 ++ .../resolvers/listUsers.js | 7 + examples/lambda-resolvers-js/schema.graphql | 14 ++ examples/lambda-resolvers-js/serverless.yml | 57 +++++++ .../resolvers/getUser.request.vtl | 7 + .../resolvers/getUser.response.vtl | 1 + examples/lambda-resolvers-vtl/schema.graphql | 8 + examples/lambda-resolvers-vtl/serverless.yml | 42 +++++ examples/logging-xray/schema.graphql | 3 + examples/logging-xray/serverless.yml | 33 ++++ examples/multi-auth/schema.graphql | 3 + examples/multi-auth/serverless.yml | 41 +++++ examples/oidc-auth/schema.graphql | 3 + examples/oidc-auth/serverless.yml | 27 +++ .../pipeline-resolvers/functions/getPosts.js | 17 ++ .../pipeline-resolvers/functions/getUser.js | 12 ++ examples/pipeline-resolvers/schema.graphql | 15 ++ examples/pipeline-resolvers/serverless.yml | 65 +++++++ .../schemas/post.graphql | 6 + .../schemas/schema.graphql | 4 + .../schemas/user.graphql | 5 + examples/schema-multiple-files/serverless.yml | 34 ++++ .../resolvers/getUser.request.vtl | 9 + .../resolvers/getUser.response.vtl | 1 + examples/substitutions/schema.graphql | 8 + examples/substitutions/serverless.yml | 50 ++++++ examples/tags/schema.graphql | 3 + examples/tags/serverless.yml | 33 ++++ examples/visibility-private/schema.graphql | 3 + examples/visibility-private/serverless.yml | 27 +++ examples/waf/schema.graphql | 3 + examples/waf/serverless.yml | 45 +++++ jest.e2e.config.ts | 15 ++ jest.e2e.setup.ts | 48 ++++++ package.json | 2 + 98 files changed, 2788 insertions(+) create mode 100644 e2e/README.md create mode 100644 e2e/api-keys-multiple.e2e.test.ts create mode 100644 e2e/basic-api-key.e2e.test.ts create mode 100644 e2e/caching.e2e.test.ts create mode 100644 e2e/cognito-userpools.e2e.test.ts create mode 100644 e2e/custom-domain.e2e.test.ts create mode 100644 e2e/datasource-eventbridge.e2e.test.ts create mode 100644 e2e/datasource-http.e2e.test.ts create mode 100644 e2e/datasource-none.e2e.test.ts create mode 100644 e2e/datasource-opensearch.e2e.test.ts create mode 100644 e2e/datasource-rds.e2e.test.ts create mode 100644 e2e/environment-variables.e2e.test.ts create mode 100644 e2e/helpers/assertions.ts create mode 100644 e2e/helpers/synthesize.ts create mode 100644 e2e/iam-auth.e2e.test.ts create mode 100644 e2e/introspection-disabled.e2e.test.ts create mode 100644 e2e/lambda-authorizer.e2e.test.ts create mode 100644 e2e/lambda-resolvers-js.e2e.test.ts create mode 100644 e2e/lambda-resolvers-vtl.e2e.test.ts create mode 100644 e2e/logging-xray.e2e.test.ts create mode 100644 e2e/multi-auth.e2e.test.ts create mode 100644 e2e/oidc-auth.e2e.test.ts create mode 100644 e2e/pipeline-resolvers.e2e.test.ts create mode 100644 e2e/schema-multiple-files.e2e.test.ts create mode 100644 e2e/substitutions.e2e.test.ts create mode 100644 e2e/tags.e2e.test.ts create mode 100644 e2e/visibility-private.e2e.test.ts create mode 100644 e2e/waf.e2e.test.ts create mode 100644 examples/README.md create mode 100644 examples/api-keys-multiple/schema.graphql create mode 100644 examples/api-keys-multiple/serverless.yml create mode 100644 examples/basic-api-key/resolvers/getUser.js create mode 100644 examples/basic-api-key/schema.graphql create mode 100644 examples/basic-api-key/serverless.yml create mode 100644 examples/caching/schema.graphql create mode 100644 examples/caching/serverless.yml create mode 100644 examples/cognito-userpools/schema.graphql create mode 100644 examples/cognito-userpools/serverless.yml create mode 100644 examples/custom-domain/schema.graphql create mode 100644 examples/custom-domain/serverless.yml create mode 100644 examples/datasource-eventbridge/schema.graphql create mode 100644 examples/datasource-eventbridge/serverless.yml create mode 100644 examples/datasource-http/schema.graphql create mode 100644 examples/datasource-http/serverless.yml create mode 100644 examples/datasource-none/resolvers/publishMessage.js create mode 100644 examples/datasource-none/schema.graphql create mode 100644 examples/datasource-none/serverless.yml create mode 100644 examples/datasource-opensearch/schema.graphql create mode 100644 examples/datasource-opensearch/serverless.yml create mode 100644 examples/datasource-rds/schema.graphql create mode 100644 examples/datasource-rds/serverless.yml create mode 100644 examples/environment-variables/resolvers/getUser.js create mode 100644 examples/environment-variables/schema.graphql create mode 100644 examples/environment-variables/serverless.yml create mode 100644 examples/iam-auth/schema.graphql create mode 100644 examples/iam-auth/serverless.yml create mode 100644 examples/introspection-disabled/schema.graphql create mode 100644 examples/introspection-disabled/serverless.yml create mode 100644 examples/lambda-authorizer/handler.js create mode 100644 examples/lambda-authorizer/schema.graphql create mode 100644 examples/lambda-authorizer/serverless.yml create mode 100644 examples/lambda-resolvers-js/functions/createUser.js create mode 100644 examples/lambda-resolvers-js/resolvers/getUser.js create mode 100644 examples/lambda-resolvers-js/resolvers/listUsers.js create mode 100644 examples/lambda-resolvers-js/schema.graphql create mode 100644 examples/lambda-resolvers-js/serverless.yml create mode 100644 examples/lambda-resolvers-vtl/resolvers/getUser.request.vtl create mode 100644 examples/lambda-resolvers-vtl/resolvers/getUser.response.vtl create mode 100644 examples/lambda-resolvers-vtl/schema.graphql create mode 100644 examples/lambda-resolvers-vtl/serverless.yml create mode 100644 examples/logging-xray/schema.graphql create mode 100644 examples/logging-xray/serverless.yml create mode 100644 examples/multi-auth/schema.graphql create mode 100644 examples/multi-auth/serverless.yml create mode 100644 examples/oidc-auth/schema.graphql create mode 100644 examples/oidc-auth/serverless.yml create mode 100644 examples/pipeline-resolvers/functions/getPosts.js create mode 100644 examples/pipeline-resolvers/functions/getUser.js create mode 100644 examples/pipeline-resolvers/schema.graphql create mode 100644 examples/pipeline-resolvers/serverless.yml create mode 100644 examples/schema-multiple-files/schemas/post.graphql create mode 100644 examples/schema-multiple-files/schemas/schema.graphql create mode 100644 examples/schema-multiple-files/schemas/user.graphql create mode 100644 examples/schema-multiple-files/serverless.yml create mode 100644 examples/substitutions/resolvers/getUser.request.vtl create mode 100644 examples/substitutions/resolvers/getUser.response.vtl create mode 100644 examples/substitutions/schema.graphql create mode 100644 examples/substitutions/serverless.yml create mode 100644 examples/tags/schema.graphql create mode 100644 examples/tags/serverless.yml create mode 100644 examples/visibility-private/schema.graphql create mode 100644 examples/visibility-private/serverless.yml create mode 100644 examples/waf/schema.graphql create mode 100644 examples/waf/serverless.yml create mode 100644 jest.e2e.config.ts create mode 100644 jest.e2e.setup.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99b80a83..227055c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,18 @@ jobs: echo "Error: lib directory is empty" exit 1 fi + + e2e: + name: CFN Synthesis Tests + runs-on: ubuntu-latest + needs: tests + steps: + - name: Checkout code + uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - name: Install dependencies + run: npm ci + - name: Run CFN synthesis tests + run: npm run test:e2e diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..e2f43bac --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,92 @@ +# CFN Synthesis Tests + +These tests verify that `serverless-appsync-plugin` generates the +expected CloudFormation when applied to a variety of real-world +configurations. They complement the unit tests in `src/__tests__/`: + +| Concern | Unit tests | CFN synthesis tests | +| --------------------------------------------- | ------------------- | ------------------- | +| Pure function logic | ✓ | | +| Schema validation | ✓ | | +| Type coercion | ✓ | | +| Plugin lifecycle on a real `serverless.yml` | | ✓ | +| Generated CloudFormation resources | partial (snapshots) | ✓ | +| Feature combinations | | ✓ | +| Example projects stay current with the plugin | | ✓ | + +## How it works + +Each test loads one of the example projects under [`../examples/`](../examples), +runs `serverless package` to produce a CloudFormation template, and +asserts on the generated resources. + +Critically, **these tests do not deploy anything to AWS** — they only +exercise the synthesis path. That makes them fast (~3s per fixture), +fork-safe (no AWS credentials required), and suitable to run on every +PR. + +## Running locally + +```bash +# Run all CFN synthesis tests +npm run test:e2e + +# Run everything (unit + synthesis) +npm run test:all + +# Run a single fixture +npx jest --config jest.e2e.config.ts basic-api-key +``` + +## Adding a new test + +1. Create a new example under `examples//`: + - `serverless.yml` — minimal configuration demonstrating the feature + - `schema.graphql` — GraphQL schema (or multiple `.graphql` files) + - Any handlers or resolver code the example needs +2. Add an entry to [`../examples/README.md`](../examples/README.md) +3. Create `e2e/.e2e.test.ts` using the helpers from `helpers/` +4. Run `npm run test:e2e` to verify + +The example should be **runnable**: a user should be able to `cd` into +it, run `serverless deploy` (with AWS credentials), and get a working +AppSync API. That keeps the examples honest as documentation. + +## Helpers reference + +See `helpers/synthesize.ts` and `helpers/assertions.ts` for the full +set of utilities. The common patterns: + +```typescript +import { synthesize } from './helpers/synthesize'; +import { + expectAuthenticationType, + expectDataSourceOfType, + expectResourceWithProperties, + findOneResourceByType, + getGraphQlApi, +} from './helpers/assertions'; + +describe('examples/my-feature', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/my-feature'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('does the thing', () => { + expectAuthenticationType(result.template, 'API_KEY'); + }); +}); +``` + +## CI + +A dedicated `e2e` job in `.github/workflows/ci.yml` runs the full +synthesis test suite on every PR and push to `master` or `alpha`. It +runs after the unit-test matrix completes successfully (no point +running E2E if unit tests already failed). diff --git a/e2e/api-keys-multiple.e2e.test.ts b/e2e/api-keys-multiple.e2e.test.ts new file mode 100644 index 00000000..5ffde5be --- /dev/null +++ b/e2e/api-keys-multiple.e2e.test.ts @@ -0,0 +1,45 @@ +import { synthesize } from './helpers/synthesize'; +import { + countResourcesByType, + findResourcesByType, +} from './helpers/assertions'; + +describe('examples/api-keys-multiple', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/api-keys-multiple'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates three distinct API key resources', () => { + expect(countResourcesByType(result.template, 'AWS::AppSync::ApiKey')).toBe( + 3, + ); + }); + + it('each API key has its own description matching the config', () => { + const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey'); + const descriptions = keys + .map((k) => k.resource.Properties?.Description as string) + .sort(); + expect(descriptions).toEqual([ + 'Internal testing', + 'Mobile app key', + 'Web app key', + ]); + }); + + it('each API key has an expiry set', () => { + const keys = findResourcesByType(result.template, 'AWS::AppSync::ApiKey'); + keys.forEach((k) => { + // The plugin computes an absolute Unix timestamp for Expires + const expires = k.resource.Properties?.Expires; + expect(expires).toBeDefined(); + expect(typeof expires).toBe('number'); + }); + }); +}); diff --git a/e2e/basic-api-key.e2e.test.ts b/e2e/basic-api-key.e2e.test.ts new file mode 100644 index 00000000..bbe2ad12 --- /dev/null +++ b/e2e/basic-api-key.e2e.test.ts @@ -0,0 +1,62 @@ +import { synthesize } from './helpers/synthesize'; +import { + countResourcesByType, + expectAuthenticationType, + expectDataSourceOfType, + expectResourceWithProperties, + findOneResourceByType, + getGraphQlApi, +} from './helpers/assertions'; + +describe('examples/basic-api-key', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/basic-api-key'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('produces a GraphQLApi with API_KEY authentication', () => { + const { resource } = getGraphQlApi(result.template); + expect(resource.Properties?.Name).toBe('basic-api-key'); + expectAuthenticationType(result.template, 'API_KEY'); + }); + + it('creates a default API key with the configured description', () => { + expectResourceWithProperties(result.template, 'AWS::AppSync::ApiKey', { + Description: 'Default API key', + }); + }); + + it('creates a GraphQLSchema bound to the API', () => { + findOneResourceByType(result.template, 'AWS::AppSync::GraphQLSchema'); + }); + + it('creates the DynamoDB data source with an IAM role', () => { + const ds = expectDataSourceOfType(result.template, 'AMAZON_DYNAMODB'); + expect(ds.resource.Properties?.Name).toBe('users'); + // Each AppSync data source needs an IAM role so AppSync can read/write + expect( + countResourcesByType(result.template, 'AWS::IAM::Role'), + ).toBeGreaterThan(0); + }); + + it('creates the Query.getUser resolver wired to the data source', () => { + expectResourceWithProperties(result.template, 'AWS::AppSync::Resolver', { + TypeName: 'Query', + FieldName: 'getUser', + Kind: 'UNIT', + }); + }); + + it('does not create any additional auth provider resources', () => { + // API_KEY only — no Cognito, no Lambda authorizer + const api = getGraphQlApi(result.template); + expect( + api.resource.Properties?.AdditionalAuthenticationProviders, + ).toBeUndefined(); + }); +}); diff --git a/e2e/caching.e2e.test.ts b/e2e/caching.e2e.test.ts new file mode 100644 index 00000000..cdf5b8d1 --- /dev/null +++ b/e2e/caching.e2e.test.ts @@ -0,0 +1,46 @@ +import { synthesize } from './helpers/synthesize'; +import { + findOneResourceByType, + findResourcesByType, +} from './helpers/assertions'; + +describe('examples/caching', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/caching'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates an ApiCache resource', () => { + const { resource } = findOneResourceByType( + result.template, + 'AWS::AppSync::ApiCache', + ); + const props = resource.Properties as Record; + expect(props.ApiCachingBehavior).toBe('PER_RESOLVER_CACHING'); + expect(props.Type).toBe('SMALL'); + expect(props.Ttl).toBe(600); + expect(props.AtRestEncryptionEnabled).toBe(true); + expect(props.TransitEncryptionEnabled).toBe(true); + }); + + it('configures caching keys on the resolver', () => { + const resolvers = findResourcesByType( + result.template, + 'AWS::AppSync::Resolver', + ); + expect(resolvers).toHaveLength(1); + const props = resolvers[0].resource.Properties as Record; + const cacheConfig = props.CachingConfig as Record; + expect(cacheConfig).toBeDefined(); + expect(cacheConfig.Ttl).toBe(60); + expect(cacheConfig.CachingKeys).toEqual([ + '$context.identity.username', + '$context.arguments.id', + ]); + }); +}); diff --git a/e2e/cognito-userpools.e2e.test.ts b/e2e/cognito-userpools.e2e.test.ts new file mode 100644 index 00000000..5999d599 --- /dev/null +++ b/e2e/cognito-userpools.e2e.test.ts @@ -0,0 +1,38 @@ +import { synthesize } from './helpers/synthesize'; +import { expectAuthenticationType, getGraphQlApi } from './helpers/assertions'; + +describe('examples/cognito-userpools', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/cognito-userpools'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('configures AMAZON_COGNITO_USER_POOLS authentication', () => { + expectAuthenticationType(result.template, 'AMAZON_COGNITO_USER_POOLS'); + }); + + it('passes user pool config to the GraphQLApi', () => { + const { resource } = getGraphQlApi(result.template); + const config = resource.Properties?.UserPoolConfig as Record< + string, + unknown + >; + expect(config).toBeDefined(); + expect(config.DefaultAction).toBe('ALLOW'); + expect(config.AwsRegion).toBe('us-east-1'); + expect(config.UserPoolId).toBeDefined(); + }); + + it('does not create an API key for non-API_KEY auth', () => { + const { template } = result; + const apiKeys = Object.values(template.Resources).filter( + (r) => r.Type === 'AWS::AppSync::ApiKey', + ); + expect(apiKeys).toHaveLength(0); + }); +}); diff --git a/e2e/custom-domain.e2e.test.ts b/e2e/custom-domain.e2e.test.ts new file mode 100644 index 00000000..9dd8a005 --- /dev/null +++ b/e2e/custom-domain.e2e.test.ts @@ -0,0 +1,40 @@ +import { synthesize } from './helpers/synthesize'; +import { findOneResourceByType } from './helpers/assertions'; + +describe('examples/custom-domain', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/custom-domain'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates an AppSync DomainName resource', () => { + const { resource } = findOneResourceByType( + result.template, + 'AWS::AppSync::DomainName', + ); + const props = resource.Properties as Record; + expect(props.DomainName).toBe('api.example.com'); + expect(props.CertificateArn).toBeDefined(); + }); + + it('creates a DomainNameApiAssociation', () => { + findOneResourceByType( + result.template, + 'AWS::AppSync::DomainNameApiAssociation', + ); + }); + + it('creates a Route 53 record for the custom domain', () => { + const { resource } = findOneResourceByType( + result.template, + 'AWS::Route53::RecordSet', + ); + const props = resource.Properties as Record; + expect(props.HostedZoneId).toBe('Z1234567890ABC'); + }); +}); diff --git a/e2e/datasource-eventbridge.e2e.test.ts b/e2e/datasource-eventbridge.e2e.test.ts new file mode 100644 index 00000000..028ab58d --- /dev/null +++ b/e2e/datasource-eventbridge.e2e.test.ts @@ -0,0 +1,29 @@ +import { synthesize } from './helpers/synthesize'; +import { expectDataSourceOfType } from './helpers/assertions'; + +describe('examples/datasource-eventbridge', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/datasource-eventbridge'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates an AMAZON_EVENTBRIDGE data source', () => { + const ds = expectDataSourceOfType(result.template, 'AMAZON_EVENTBRIDGE'); + expect(ds.resource.Properties?.Name).toBe('event_bus'); + }); + + it('configures the event bus ARN', () => { + const ds = expectDataSourceOfType(result.template, 'AMAZON_EVENTBRIDGE'); + const ebConfig = ds.resource.Properties?.EventBridgeConfig as Record< + string, + unknown + >; + expect(ebConfig).toBeDefined(); + expect(ebConfig.EventBusArn).toBeDefined(); + }); +}); diff --git a/e2e/datasource-http.e2e.test.ts b/e2e/datasource-http.e2e.test.ts new file mode 100644 index 00000000..9fef705b --- /dev/null +++ b/e2e/datasource-http.e2e.test.ts @@ -0,0 +1,70 @@ +import { synthesize } from './helpers/synthesize'; +import { + countResourcesByType, + findResourcesByType, +} from './helpers/assertions'; + +describe('examples/datasource-http', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/datasource-http'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates two HTTP data sources', () => { + const httpDataSources = findResourcesByType( + result.template, + 'AWS::AppSync::DataSource', + ).filter((ds) => ds.resource.Properties?.Type === 'HTTP'); + expect(httpDataSources).toHaveLength(2); + }); + + it('configures the unauthenticated HTTP endpoint', () => { + const dataSources = findResourcesByType( + result.template, + 'AWS::AppSync::DataSource', + ); + const weatherDs = dataSources.find( + (ds) => ds.resource.Properties?.Name === 'weather_api', + ); + if (!weatherDs) throw new Error('weather_api data source not found'); + const httpConfig = weatherDs.resource.Properties?.HttpConfig as Record< + string, + unknown + >; + expect(httpConfig.Endpoint).toBe('https://api.weather.example.com'); + expect(httpConfig.AuthorizationConfig).toBeUndefined(); + }); + + it('configures IAM signing for the signed HTTP endpoint', () => { + const dataSources = findResourcesByType( + result.template, + 'AWS::AppSync::DataSource', + ); + const signedDs = dataSources.find( + (ds) => ds.resource.Properties?.Name === 'signed_api', + ); + if (!signedDs) throw new Error('signed_api data source not found'); + const httpConfig = signedDs.resource.Properties?.HttpConfig as Record< + string, + unknown + >; + expect(httpConfig.AuthorizationConfig).toBeDefined(); + const auth = httpConfig.AuthorizationConfig as Record; + expect(auth.AuthorizationType).toBe('AWS_IAM'); + const iamConfig = auth.AwsIamConfig as Record; + expect(iamConfig.SigningRegion).toBe('us-east-1'); + expect(iamConfig.SigningServiceName).toBe('execute-api'); + }); + + it('creates a service role only for the IAM-signed data source', () => { + // Only the signed HTTP data source needs an IAM role + expect( + countResourcesByType(result.template, 'AWS::IAM::Role'), + ).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/e2e/datasource-none.e2e.test.ts b/e2e/datasource-none.e2e.test.ts new file mode 100644 index 00000000..a778e187 --- /dev/null +++ b/e2e/datasource-none.e2e.test.ts @@ -0,0 +1,25 @@ +import { synthesize } from './helpers/synthesize'; +import { expectDataSourceOfType } from './helpers/assertions'; + +describe('examples/datasource-none', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/datasource-none'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates a NONE data source', () => { + const ds = expectDataSourceOfType(result.template, 'NONE'); + expect(ds.resource.Properties?.Name).toBe('noop'); + }); + + it('does NOT create a service role for the NONE data source', () => { + // NONE data sources don't make AWS calls so they don't need a role. + const ds = expectDataSourceOfType(result.template, 'NONE'); + expect(ds.resource.Properties?.ServiceRoleArn).toBeUndefined(); + }); +}); diff --git a/e2e/datasource-opensearch.e2e.test.ts b/e2e/datasource-opensearch.e2e.test.ts new file mode 100644 index 00000000..8b4f718d --- /dev/null +++ b/e2e/datasource-opensearch.e2e.test.ts @@ -0,0 +1,31 @@ +import { synthesize } from './helpers/synthesize'; +import { expectDataSourceOfType } from './helpers/assertions'; + +describe('examples/datasource-opensearch', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/datasource-opensearch'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates an AMAZON_OPENSEARCH_SERVICE data source', () => { + expectDataSourceOfType(result.template, 'AMAZON_OPENSEARCH_SERVICE'); + }); + + it('configures the OpenSearch endpoint', () => { + const ds = expectDataSourceOfType( + result.template, + 'AMAZON_OPENSEARCH_SERVICE', + ); + const osConfig = ds.resource.Properties?.OpenSearchServiceConfig as Record< + string, + unknown + >; + expect(osConfig).toBeDefined(); + expect(osConfig.Endpoint).toBeDefined(); + }); +}); diff --git a/e2e/datasource-rds.e2e.test.ts b/e2e/datasource-rds.e2e.test.ts new file mode 100644 index 00000000..04eeece4 --- /dev/null +++ b/e2e/datasource-rds.e2e.test.ts @@ -0,0 +1,34 @@ +import { synthesize } from './helpers/synthesize'; +import { expectDataSourceOfType } from './helpers/assertions'; + +describe('examples/datasource-rds', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/datasource-rds'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates a RELATIONAL_DATABASE data source', () => { + expectDataSourceOfType(result.template, 'RELATIONAL_DATABASE'); + }); + + it('configures the RDS HTTP endpoint with cluster + secret ARNs', () => { + const ds = expectDataSourceOfType(result.template, 'RELATIONAL_DATABASE'); + const rdsConfig = ds.resource.Properties + ?.RelationalDatabaseConfig as Record; + expect(rdsConfig).toBeDefined(); + expect(rdsConfig.RelationalDatabaseSourceType).toBe('RDS_HTTP_ENDPOINT'); + + const httpEndpoint = rdsConfig.RdsHttpEndpointConfig as Record< + string, + unknown + >; + expect(httpEndpoint.DatabaseName).toBe('orders'); + expect(httpEndpoint.DbClusterIdentifier).toBeDefined(); + expect(httpEndpoint.AwsSecretStoreArn).toBeDefined(); + }); +}); diff --git a/e2e/environment-variables.e2e.test.ts b/e2e/environment-variables.e2e.test.ts new file mode 100644 index 00000000..43316d76 --- /dev/null +++ b/e2e/environment-variables.e2e.test.ts @@ -0,0 +1,26 @@ +import { synthesize } from './helpers/synthesize'; +import { getGraphQlApi } from './helpers/assertions'; + +describe('examples/environment-variables', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/environment-variables'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('passes environment variables to the GraphQLApi', () => { + const { resource } = getGraphQlApi(result.template); + const env = resource.Properties?.EnvironmentVariables as Record< + string, + string + >; + expect(env).toBeDefined(); + expect(env.LOG_LEVEL).toBe('info'); + expect(env.FEATURE_FLAG_NEW_AUTH).toBe('true'); + expect(env.EXTERNAL_API_URL).toBe('https://api.example.com'); + }); +}); diff --git a/e2e/helpers/assertions.ts b/e2e/helpers/assertions.ts new file mode 100644 index 00000000..79c8ef5e --- /dev/null +++ b/e2e/helpers/assertions.ts @@ -0,0 +1,159 @@ +import { CfnResource, CfnTemplate } from './synthesize'; + +/** + * Find all resources of a given CloudFormation type. + */ +export function findResourcesByType( + template: CfnTemplate, + type: string, +): Array<{ logicalId: string; resource: CfnResource }> { + return Object.entries(template.Resources) + .filter(([, r]) => r.Type === type) + .map(([logicalId, resource]) => ({ logicalId, resource })); +} + +/** + * Find exactly one resource of a given type and return it. Throws if zero + * or more than one match (forces tests to be explicit about cardinality). + */ +export function findOneResourceByType( + template: CfnTemplate, + type: string, +): { logicalId: string; resource: CfnResource } { + const matches = findResourcesByType(template, type); + if (matches.length === 0) { + throw new Error(`Expected exactly one ${type} resource, found none.`); + } + if (matches.length > 1) { + throw new Error( + `Expected exactly one ${type} resource, found ${matches.length}: ` + + matches.map((m) => m.logicalId).join(', '), + ); + } + return matches[0]; +} + +/** + * Count resources of a given CloudFormation type. + */ +export function countResourcesByType( + template: CfnTemplate, + type: string, +): number { + return findResourcesByType(template, type).length; +} + +/** + * Assert that a template contains at least one resource of the given type + * with the given property values. Property matching is partial — only + * properties listed in `match` need to be present, with deep equality. + */ +export function expectResourceWithProperties( + template: CfnTemplate, + type: string, + match: Record, +): { logicalId: string; resource: CfnResource } { + const matches = findResourcesByType(template, type); + const found = matches.find(({ resource }) => + matchesProperties(resource.Properties ?? {}, match), + ); + if (!found) { + const summary = matches.map((m) => ({ + logicalId: m.logicalId, + properties: m.resource.Properties, + })); + throw new Error( + `No ${type} resource matched expected properties.\n` + + `Expected (partial): ${JSON.stringify(match, null, 2)}\n` + + `Actual ${type} resources: ${JSON.stringify(summary, null, 2)}`, + ); + } + return found; +} + +/** + * Recursive partial-match: every key/value in `expected` must exist in + * `actual` with the same value. Extra keys in `actual` are ignored. + */ +function matchesProperties(actual: unknown, expected: unknown): boolean { + if (expected === null || typeof expected !== 'object') { + return actual === expected; + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual)) return false; + if (expected.length !== actual.length) return false; + return expected.every((v, i) => matchesProperties(actual[i], v)); + } + if (actual === null || typeof actual !== 'object') return false; + const a = actual as Record; + const e = expected as Record; + return Object.keys(e).every((k) => matchesProperties(a[k], e[k])); +} + +/** + * Convenience: assert that the GraphQL API has a specific authentication + * type configured. + */ +export function expectAuthenticationType( + template: CfnTemplate, + type: + | 'API_KEY' + | 'AWS_IAM' + | 'AMAZON_COGNITO_USER_POOLS' + | 'OPENID_CONNECT' + | 'AWS_LAMBDA', +): void { + const { resource } = findOneResourceByType( + template, + 'AWS::AppSync::GraphQLApi', + ); + const props = resource.Properties ?? {}; + const actual = props.AuthenticationType as string | undefined; + if (actual !== type) { + throw new Error( + `Expected AuthenticationType ${type}, got ${actual}.\n` + + `GraphQLApi properties: ${JSON.stringify(props, null, 2)}`, + ); + } +} + +/** + * Convenience: assert that a data source of a given AppSync type exists. + * Returns the matching resource for further inspection. + */ +export function expectDataSourceOfType( + template: CfnTemplate, + appsyncType: + | 'AMAZON_DYNAMODB' + | 'AWS_LAMBDA' + | 'AMAZON_OPENSEARCH_SERVICE' + | 'HTTP' + | 'RELATIONAL_DATABASE' + | 'AMAZON_EVENTBRIDGE' + | 'NONE', +): { logicalId: string; resource: CfnResource } { + return expectResourceWithProperties(template, 'AWS::AppSync::DataSource', { + Type: appsyncType, + }); +} + +/** + * Convenience: return all logical IDs of resources of a given type. + */ +export function logicalIdsByType( + template: CfnTemplate, + type: string, +): string[] { + return findResourcesByType(template, type).map((r) => r.logicalId); +} + +/** + * Get the AppSync GraphQLApi resource (assumes exactly one — true for all + * single-API examples). Use findResourcesByType for merged API fixtures. + */ +export function getGraphQlApi(template: CfnTemplate): { + logicalId: string; + resource: CfnResource; +} { + return findOneResourceByType(template, 'AWS::AppSync::GraphQLApi'); +} diff --git a/e2e/helpers/synthesize.ts b/e2e/helpers/synthesize.ts new file mode 100644 index 00000000..4a63810e --- /dev/null +++ b/e2e/helpers/synthesize.ts @@ -0,0 +1,122 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export type CfnResource = { + Type: string; + Properties?: Record; + DependsOn?: string | string[]; + Condition?: string; +}; + +export type CfnTemplate = { + AWSTemplateFormatVersion?: string; + Description?: string; + Resources: Record; + Outputs?: Record; + Parameters?: Record; + Conditions?: Record; + Mappings?: Record; +}; + +export type SynthesizeResult = { + template: CfnTemplate; + /** Absolute path of the .serverless directory used by this synth. */ + packageDir: string; + /** Cleanup function that removes the temporary package directory. */ + cleanup: () => void; +}; + +const REPO_ROOT = path.resolve(__dirname, '../..'); +const SERVERLESS_BIN = path.join( + REPO_ROOT, + 'node_modules', + '.bin', + 'serverless', +); + +/** + * Run `serverless package` against the example project at `exampleDir` and + * return the synthesized CloudFormation template. + * + * The plugin path is rewritten on the fly so the example uses the source + * code in this repository (not an installed version), which is what makes + * these tests meaningful for development. + * + * The package output is written to a unique temp directory per call so + * tests can run in parallel without colliding. + */ +export function synthesize(exampleDir: string): SynthesizeResult { + const absoluteExampleDir = path.isAbsolute(exampleDir) + ? exampleDir + : path.join(REPO_ROOT, exampleDir); + + if (!fs.existsSync(absoluteExampleDir)) { + throw new Error(`Example directory does not exist: ${absoluteExampleDir}`); + } + + if (!fs.existsSync(SERVERLESS_BIN)) { + throw new Error( + `Serverless Framework binary not found at ${SERVERLESS_BIN}. ` + + `Run \`npm ci\` at the repo root.`, + ); + } + + const packageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sls-appsync-e2e-')); + + try { + execFileSync(SERVERLESS_BIN, ['package', '--package', packageDir], { + cwd: absoluteExampleDir, + env: { + ...process.env, + // Suppress framework prompts and analytics + SLS_NOTIFICATIONS_MODE: 'off', + SLS_INTERACTIVE_SETUP_ENABLE: '0', + // Set a stable region so tests are deterministic + AWS_REGION: 'us-east-1', + AWS_DEFAULT_REGION: 'us-east-1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + // Surface stderr in test output so failures are diagnosable + const e = err as { stderr?: Buffer; stdout?: Buffer; message: string }; + const stderr = e.stderr?.toString() ?? ''; + const stdout = e.stdout?.toString() ?? ''; + fs.rmSync(packageDir, { recursive: true, force: true }); + throw new Error( + `serverless package failed for ${absoluteExampleDir}:\n` + + `STDOUT:\n${stdout}\n` + + `STDERR:\n${stderr}\n` + + `MESSAGE: ${e.message}`, + ); + } + + const templatePath = path.join( + packageDir, + 'cloudformation-template-update-stack.json', + ); + if (!fs.existsSync(templatePath)) { + fs.rmSync(packageDir, { recursive: true, force: true }); + throw new Error( + `CloudFormation template was not produced at ${templatePath}.`, + ); + } + + const template = JSON.parse( + fs.readFileSync(templatePath, 'utf8'), + ) as CfnTemplate; + + return { + template, + packageDir, + cleanup: () => { + try { + fs.rmSync(packageDir, { recursive: true, force: true }); + } catch { + // best-effort + } + }, + }; +} diff --git a/e2e/iam-auth.e2e.test.ts b/e2e/iam-auth.e2e.test.ts new file mode 100644 index 00000000..1ddf05ed --- /dev/null +++ b/e2e/iam-auth.e2e.test.ts @@ -0,0 +1,38 @@ +import { synthesize } from './helpers/synthesize'; +import { + expectAuthenticationType, + expectDataSourceOfType, +} from './helpers/assertions'; + +describe('examples/iam-auth', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/iam-auth'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('configures AWS_IAM authentication', () => { + expectAuthenticationType(result.template, 'AWS_IAM'); + }); + + it('creates the NONE data source', () => { + expectDataSourceOfType(result.template, 'NONE'); + }); + + it('does not create any IAM role for NONE data source', () => { + // NONE data sources don't need an IAM role + const { template } = result; + // The deployment bucket policy exists; we're checking AppSync-owned roles + // by counting roles whose names start with the GraphQL data source prefix. + const allRoles = Object.entries(template.Resources).filter( + ([, r]) => r.Type === 'AWS::IAM::Role', + ); + // There may be 0 roles for a NONE-only setup (no service role needed + // because we're not using logs/xray) + expect(allRoles.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/e2e/introspection-disabled.e2e.test.ts b/e2e/introspection-disabled.e2e.test.ts new file mode 100644 index 00000000..52e7ff80 --- /dev/null +++ b/e2e/introspection-disabled.e2e.test.ts @@ -0,0 +1,29 @@ +import { synthesize } from './helpers/synthesize'; +import { getGraphQlApi } from './helpers/assertions'; + +describe('examples/introspection-disabled', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/introspection-disabled'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('disables introspection on the GraphQLApi', () => { + const { resource } = getGraphQlApi(result.template); + expect(resource.Properties?.IntrospectionConfig).toBe('DISABLED'); + }); + + it('enforces a query depth limit', () => { + const { resource } = getGraphQlApi(result.template); + expect(resource.Properties?.QueryDepthLimit).toBe(10); + }); + + it('enforces a resolver count limit', () => { + const { resource } = getGraphQlApi(result.template); + expect(resource.Properties?.ResolverCountLimit).toBe(50); + }); +}); diff --git a/e2e/lambda-authorizer.e2e.test.ts b/e2e/lambda-authorizer.e2e.test.ts new file mode 100644 index 00000000..c94e3ba5 --- /dev/null +++ b/e2e/lambda-authorizer.e2e.test.ts @@ -0,0 +1,42 @@ +import { synthesize } from './helpers/synthesize'; +import { + expectAuthenticationType, + findOneResourceByType, + getGraphQlApi, +} from './helpers/assertions'; + +describe('examples/lambda-authorizer', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/lambda-authorizer'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('configures AWS_LAMBDA authentication', () => { + expectAuthenticationType(result.template, 'AWS_LAMBDA'); + }); + + it('passes the Lambda authorizer config to the GraphQLApi', () => { + const { resource } = getGraphQlApi(result.template); + const config = resource.Properties?.LambdaAuthorizerConfig as Record< + string, + unknown + >; + expect(config).toBeDefined(); + expect(config.AuthorizerResultTtlInSeconds).toBe(300); + expect(config.IdentityValidationExpression).toBe('^Bearer .*'); + expect(config.AuthorizerUri).toBeDefined(); + }); + + it('creates a Lambda function for the authorizer', () => { + findOneResourceByType(result.template, 'AWS::Lambda::Function'); + }); + + it('creates a Lambda invoke permission for AppSync', () => { + findOneResourceByType(result.template, 'AWS::Lambda::Permission'); + }); +}); diff --git a/e2e/lambda-resolvers-js.e2e.test.ts b/e2e/lambda-resolvers-js.e2e.test.ts new file mode 100644 index 00000000..63d3830c --- /dev/null +++ b/e2e/lambda-resolvers-js.e2e.test.ts @@ -0,0 +1,77 @@ +import { synthesize } from './helpers/synthesize'; +import { + countResourcesByType, + expectDataSourceOfType, + findResourcesByType, +} from './helpers/assertions'; + +describe('examples/lambda-resolvers-js', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/lambda-resolvers-js'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates both DynamoDB and Lambda data sources', () => { + expectDataSourceOfType(result.template, 'AMAZON_DYNAMODB'); + expectDataSourceOfType(result.template, 'AWS_LAMBDA'); + }); + + it('creates all three resolvers', () => { + expect( + countResourcesByType(result.template, 'AWS::AppSync::Resolver'), + ).toBe(3); + }); + + it('creates a JS resolver for Query.getUser', () => { + const resolvers = findResourcesByType( + result.template, + 'AWS::AppSync::Resolver', + ); + const getUser = resolvers.find( + (r) => + r.resource.Properties?.TypeName === 'Query' && + r.resource.Properties?.FieldName === 'getUser', + ); + if (!getUser) throw new Error('Query.getUser resolver not found'); + // JS resolver: has Runtime, Code property + const props = getUser.resource.Properties as Record; + expect(props.Runtime).toEqual({ + Name: 'APPSYNC_JS', + RuntimeVersion: '1.0.0', + }); + expect(props.Code).toBeDefined(); + }); + + it('creates a VTL-default resolver for Mutation.createUser (no code, no runtime)', () => { + const resolvers = findResourcesByType( + result.template, + 'AWS::AppSync::Resolver', + ); + const createUser = resolvers.find( + (r) => + r.resource.Properties?.TypeName === 'Mutation' && + r.resource.Properties?.FieldName === 'createUser', + ); + if (!createUser) throw new Error('Mutation.createUser resolver not found'); + const props = createUser.resource.Properties as Record; + expect(props.Runtime).toBeUndefined(); + }); + + it('creates IAM roles for each data source (DynamoDB + Lambda)', () => { + // 2 data source roles minimum + expect( + countResourcesByType(result.template, 'AWS::IAM::Role'), + ).toBeGreaterThanOrEqual(2); + }); + + it('creates a Lambda function for the createUser handler', () => { + expect( + countResourcesByType(result.template, 'AWS::Lambda::Function'), + ).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/e2e/lambda-resolvers-vtl.e2e.test.ts b/e2e/lambda-resolvers-vtl.e2e.test.ts new file mode 100644 index 00000000..70291548 --- /dev/null +++ b/e2e/lambda-resolvers-vtl.e2e.test.ts @@ -0,0 +1,35 @@ +import { synthesize } from './helpers/synthesize'; +import { findResourcesByType } from './helpers/assertions'; + +describe('examples/lambda-resolvers-vtl', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/lambda-resolvers-vtl'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates a resolver with VTL request and response mapping templates', () => { + const resolvers = findResourcesByType( + result.template, + 'AWS::AppSync::Resolver', + ); + expect(resolvers).toHaveLength(1); + const props = resolvers[0].resource.Properties as Record; + + // VTL resolvers have RequestMappingTemplate and ResponseMappingTemplate strings + expect(props.RequestMappingTemplate).toBeDefined(); + expect(props.ResponseMappingTemplate).toBeDefined(); + + // VTL request templates start with `{` and contain the version field + const reqTemplate = props.RequestMappingTemplate as string; + expect(reqTemplate).toContain('"operation": "GetItem"'); + + // No Code field for VTL-mode resolvers + expect(props.Code).toBeUndefined(); + expect(props.Runtime).toBeUndefined(); + }); +}); diff --git a/e2e/logging-xray.e2e.test.ts b/e2e/logging-xray.e2e.test.ts new file mode 100644 index 00000000..fa9b6c10 --- /dev/null +++ b/e2e/logging-xray.e2e.test.ts @@ -0,0 +1,46 @@ +import { synthesize } from './helpers/synthesize'; +import { + findOneResourceByType, + findResourcesByType, + getGraphQlApi, +} from './helpers/assertions'; + +describe('examples/logging-xray', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/logging-xray'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('enables X-Ray tracing on the GraphQLApi', () => { + const { resource } = getGraphQlApi(result.template); + expect(resource.Properties?.XrayEnabled).toBe(true); + }); + + it('configures field-level logging at level ALL', () => { + const { resource } = getGraphQlApi(result.template); + const logConfig = resource.Properties?.LogConfig as Record; + expect(logConfig).toBeDefined(); + expect(logConfig.FieldLogLevel).toBe('ALL'); + expect(logConfig.ExcludeVerboseContent).toBe(false); + expect(logConfig.CloudWatchLogsRoleArn).toBeDefined(); + }); + + it('creates a CloudWatch Logs group with the configured retention', () => { + const { resource } = findOneResourceByType( + result.template, + 'AWS::Logs::LogGroup', + ); + expect(resource.Properties?.RetentionInDays).toBe(14); + }); + + it('creates an IAM role that AppSync can use to write logs', () => { + // There's at least one IAM role created for AppSync to write to CloudWatch + const roles = findResourcesByType(result.template, 'AWS::IAM::Role'); + expect(roles.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/e2e/multi-auth.e2e.test.ts b/e2e/multi-auth.e2e.test.ts new file mode 100644 index 00000000..de9b66ff --- /dev/null +++ b/e2e/multi-auth.e2e.test.ts @@ -0,0 +1,40 @@ +import { synthesize } from './helpers/synthesize'; +import { expectAuthenticationType, getGraphQlApi } from './helpers/assertions'; + +describe('examples/multi-auth', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/multi-auth'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('uses API_KEY as primary auth', () => { + expectAuthenticationType(result.template, 'API_KEY'); + }); + + it('lists all three additional auth providers on the GraphQLApi', () => { + const { resource } = getGraphQlApi(result.template); + const additional = resource.Properties + ?.AdditionalAuthenticationProviders as Array<{ + AuthenticationType: string; + }>; + expect(additional).toBeDefined(); + expect(additional.map((p) => p.AuthenticationType).sort()).toEqual([ + 'AMAZON_COGNITO_USER_POOLS', + 'AWS_IAM', + 'OPENID_CONNECT', + ]); + }); + + it('still creates an API key (primary auth is API_KEY)', () => { + const { template } = result; + const apiKeys = Object.values(template.Resources).filter( + (r) => r.Type === 'AWS::AppSync::ApiKey', + ); + expect(apiKeys.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/e2e/oidc-auth.e2e.test.ts b/e2e/oidc-auth.e2e.test.ts new file mode 100644 index 00000000..5be1b3e7 --- /dev/null +++ b/e2e/oidc-auth.e2e.test.ts @@ -0,0 +1,31 @@ +import { synthesize } from './helpers/synthesize'; +import { expectAuthenticationType, getGraphQlApi } from './helpers/assertions'; + +describe('examples/oidc-auth', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/oidc-auth'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('configures OPENID_CONNECT authentication', () => { + expectAuthenticationType(result.template, 'OPENID_CONNECT'); + }); + + it('passes OpenID Connect config to the GraphQLApi', () => { + const { resource } = getGraphQlApi(result.template); + const config = resource.Properties?.OpenIDConnectConfig as Record< + string, + unknown + >; + expect(config).toBeDefined(); + expect(config.Issuer).toBe('https://example.auth0.com/'); + expect(config.ClientId).toBe('my-client-id'); + expect(config.IatTTL).toBe(3600); + expect(config.AuthTTL).toBe(3600); + }); +}); diff --git a/e2e/pipeline-resolvers.e2e.test.ts b/e2e/pipeline-resolvers.e2e.test.ts new file mode 100644 index 00000000..3c08230b --- /dev/null +++ b/e2e/pipeline-resolvers.e2e.test.ts @@ -0,0 +1,45 @@ +import { synthesize } from './helpers/synthesize'; +import { + countResourcesByType, + findResourcesByType, +} from './helpers/assertions'; + +describe('examples/pipeline-resolvers', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/pipeline-resolvers'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates two AppSync functions', () => { + expect( + countResourcesByType( + result.template, + 'AWS::AppSync::FunctionConfiguration', + ), + ).toBe(2); + }); + + it('creates a PIPELINE resolver wired to both functions', () => { + 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'); + expect(props.PipelineConfig).toBeDefined(); + const pipelineConfig = props.PipelineConfig as { Functions: unknown[] }; + expect(pipelineConfig.Functions).toHaveLength(2); + }); + + it('creates two data sources', () => { + expect( + countResourcesByType(result.template, 'AWS::AppSync::DataSource'), + ).toBe(2); + }); +}); diff --git a/e2e/schema-multiple-files.e2e.test.ts b/e2e/schema-multiple-files.e2e.test.ts new file mode 100644 index 00000000..ebe84ec4 --- /dev/null +++ b/e2e/schema-multiple-files.e2e.test.ts @@ -0,0 +1,36 @@ +import { synthesize } from './helpers/synthesize'; +import { + countResourcesByType, + findOneResourceByType, +} from './helpers/assertions'; + +describe('examples/schema-multiple-files', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/schema-multiple-files'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('concatenates all .graphql files into one schema definition', () => { + const { resource } = findOneResourceByType( + result.template, + 'AWS::AppSync::GraphQLSchema', + ); + const props = resource.Properties as Record; + const definition = props.Definition as string; + // All three files' top-level types should appear in the joined schema + expect(definition).toContain('type Query'); + expect(definition).toContain('type User'); + expect(definition).toContain('type Post'); + }); + + it('creates resolvers from both User and Post type definitions', () => { + expect( + countResourcesByType(result.template, 'AWS::AppSync::Resolver'), + ).toBe(2); + }); +}); diff --git a/e2e/substitutions.e2e.test.ts b/e2e/substitutions.e2e.test.ts new file mode 100644 index 00000000..1f7d5306 --- /dev/null +++ b/e2e/substitutions.e2e.test.ts @@ -0,0 +1,36 @@ +import { synthesize } from './helpers/synthesize'; +import { findResourcesByType } from './helpers/assertions'; + +describe('examples/substitutions', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/substitutions'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('renders substitution variables as Fn::Sub blocks inside the template', () => { + const resolvers = findResourcesByType( + result.template, + 'AWS::AppSync::Resolver', + ); + expect(resolvers).toHaveLength(1); + const props = resolvers[0].resource.Properties as Record; + + // The plugin produces a Fn::Join that interleaves the static template + // strings with per-variable Fn::Sub blocks. Verify both substitution + // variables appear inside the joined output, and that the TABLE_NAME + // ref to the UsersTable CloudFormation resource is preserved. + const reqTemplate = props.RequestMappingTemplate; + const serialized = JSON.stringify(reqTemplate); + + expect(serialized).toContain('Fn::Join'); + expect(serialized).toContain('Fn::Sub'); + expect(serialized).toContain('TABLE_NAME'); + expect(serialized).toContain('ENVIRONMENT'); + expect(serialized).toContain('UsersTable'); + }); +}); diff --git a/e2e/tags.e2e.test.ts b/e2e/tags.e2e.test.ts new file mode 100644 index 00000000..d98bf927 --- /dev/null +++ b/e2e/tags.e2e.test.ts @@ -0,0 +1,33 @@ +import { synthesize } from './helpers/synthesize'; +import { getGraphQlApi } from './helpers/assertions'; + +describe('examples/tags', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/tags'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('applies all configured tags to the GraphQLApi', () => { + const { resource } = getGraphQlApi(result.template); + const tags = resource.Properties?.Tags as Array<{ + Key: string; + Value: unknown; + }>; + expect(Array.isArray(tags)).toBe(true); + + const tagMap: Record = {}; + tags.forEach((t) => { + tagMap[t.Key] = t.Value; + }); + + expect(tagMap.owner).toBe('platform-team'); + expect(tagMap['cost-center']).toBe('1234'); + expect(tagMap.project).toBe('my-product'); + expect(tagMap.environment).toBeDefined(); + }); +}); diff --git a/e2e/visibility-private.e2e.test.ts b/e2e/visibility-private.e2e.test.ts new file mode 100644 index 00000000..156388ce --- /dev/null +++ b/e2e/visibility-private.e2e.test.ts @@ -0,0 +1,24 @@ +import { synthesize } from './helpers/synthesize'; +import { getGraphQlApi } from './helpers/assertions'; + +describe('examples/visibility-private', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/visibility-private'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('sets the GraphQLApi visibility to PRIVATE', () => { + const { resource } = getGraphQlApi(result.template); + expect(resource.Properties?.Visibility).toBe('PRIVATE'); + }); + + it('uses AWS_IAM authentication (required for PRIVATE APIs)', () => { + const { resource } = getGraphQlApi(result.template); + expect(resource.Properties?.AuthenticationType).toBe('AWS_IAM'); + }); +}); diff --git a/e2e/waf.e2e.test.ts b/e2e/waf.e2e.test.ts new file mode 100644 index 00000000..f8cd0189 --- /dev/null +++ b/e2e/waf.e2e.test.ts @@ -0,0 +1,56 @@ +import { synthesize } from './helpers/synthesize'; +import { findOneResourceByType } from './helpers/assertions'; + +describe('examples/waf', () => { + let result: ReturnType; + + beforeAll(() => { + result = synthesize('examples/waf'); + }); + + afterAll(() => { + result.cleanup(); + }); + + it('creates a WAFv2 WebACL', () => { + const { resource } = findOneResourceByType( + result.template, + 'AWS::WAFv2::WebACL', + ); + const props = resource.Properties as Record; + expect(props.Name).toBe('AppSyncWaf'); + expect(props.Scope).toBe('REGIONAL'); + expect(props.DefaultAction).toEqual({ Allow: {} }); + }); + + it('attaches the WebACL to the GraphQL API', () => { + const { resource } = findOneResourceByType( + result.template, + 'AWS::WAFv2::WebACLAssociation', + ); + const props = resource.Properties as Record; + expect(props.ResourceArn).toBeDefined(); + expect(props.WebACLArn).toBeDefined(); + }); + + it('builds rules from the shorthand syntax (throttle + disableIntrospection + custom)', () => { + const { resource } = findOneResourceByType( + result.template, + 'AWS::WAFv2::WebACL', + ); + const rules = (resource.Properties as Record) + .Rules as Array>; + expect(rules.length).toBeGreaterThanOrEqual(3); + // Find at least one rule that's clearly the disableIntrospection one + const introspectionRule = rules.find((r) => + JSON.stringify(r).toLowerCase().includes('introspect'), + ); + expect(introspectionRule).toBeDefined(); + // Find the throttle rule by RateBasedStatement + const throttleRule = rules.find((r) => { + const stmt = r.Statement as Record | undefined; + return stmt?.RateBasedStatement !== undefined; + }); + expect(throttleRule).toBeDefined(); + }); +}); diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..c51a39f9 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,64 @@ +# Examples + +Runnable, copy-pasteable example projects for `serverless-appsync-plugin`. + +Each subfolder is a complete Serverless Framework project that you can: + +1. **Read** to learn how to configure a specific feature +2. **Copy** as a starting point for your own project +3. **Deploy** with `serverless deploy` to see it work on real AWS + +These examples are also used as fixtures by the plugin's +[CFN synthesis test suite](../e2e/README.md), so they're guaranteed to +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 | + +## How to run an example + +Pick one, `cd` into it, then: + +```bash +npm install +serverless deploy +``` + +You'll need AWS credentials configured and `serverless` installed +globally or available via `npx`. + +## How they fit into the test suite + +Each example is exercised by a test in `e2e/` that runs +`serverless package` (CloudFormation synthesis without deploying) and +asserts on the generated CloudFormation template. This catches breakages +at compile time without requiring AWS credentials in PR CI. + +See [e2e/README.md](../e2e/README.md) for details. diff --git a/examples/api-keys-multiple/schema.graphql b/examples/api-keys-multiple/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/api-keys-multiple/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/api-keys-multiple/serverless.yml b/examples/api-keys-multiple/serverless.yml new file mode 100644 index 00000000..f7d65db7 --- /dev/null +++ b/examples/api-keys-multiple/serverless.yml @@ -0,0 +1,36 @@ +service: appsync-api-keys-multiple + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: api-keys-multiple + authentication: + type: API_KEY + + # Multiple keys let you give different clients separate credentials + # that you can revoke independently — e.g. mobile app key vs web app + # key vs internal-testing key, each with its own expiry. + apiKeys: + - name: mobile + description: Mobile app key + expiresAfter: 30d + - name: web + description: Web app key + expiresAfter: 365d + - name: internal + description: Internal testing + expiresAfter: 7d + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/basic-api-key/resolvers/getUser.js b/examples/basic-api-key/resolvers/getUser.js new file mode 100644 index 00000000..636adfae --- /dev/null +++ b/examples/basic-api-key/resolvers/getUser.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/basic-api-key/schema.graphql b/examples/basic-api-key/schema.graphql new file mode 100644 index 00000000..36ec011c --- /dev/null +++ b/examples/basic-api-key/schema.graphql @@ -0,0 +1,9 @@ +type User { + id: ID! + name: String! + email: String +} + +type Query { + getUser(id: ID!): User +} diff --git a/examples/basic-api-key/serverless.yml b/examples/basic-api-key/serverless.yml new file mode 100644 index 00000000..68658b7b --- /dev/null +++ b/examples/basic-api-key/serverless.yml @@ -0,0 +1,44 @@ +service: appsync-basic-api-key + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: basic-api-key + authentication: + type: API_KEY + apiKeys: + - name: default + description: Default API key + expiresAfter: 365d + + resolvers: + Query.getUser: + kind: UNIT + dataSource: users + code: ./resolvers/getUser.js + + dataSources: + users: + type: AMAZON_DYNAMODB + description: Users table + config: + tableName: !Ref UsersTable + +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 diff --git a/examples/caching/schema.graphql b/examples/caching/schema.graphql new file mode 100644 index 00000000..eef15ac5 --- /dev/null +++ b/examples/caching/schema.graphql @@ -0,0 +1,8 @@ +type User { + id: ID! + name: String! +} + +type Query { + getUser(id: ID!): User +} diff --git a/examples/caching/serverless.yml b/examples/caching/serverless.yml new file mode 100644 index 00000000..5da652de --- /dev/null +++ b/examples/caching/serverless.yml @@ -0,0 +1,52 @@ +service: appsync-caching + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: caching-enabled + authentication: + type: API_KEY + apiKeys: + - name: default + + caching: + behavior: PER_RESOLVER_CACHING + type: SMALL + ttl: 600 + atRestEncryption: true + transitEncryption: true + + resolvers: + Query.getUser: + kind: UNIT + dataSource: users + caching: + ttl: 60 + keys: + - '$context.identity.username' + - '$context.arguments.id' + + dataSources: + users: + type: AMAZON_DYNAMODB + config: + tableName: !Ref UsersTable + +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 diff --git a/examples/cognito-userpools/schema.graphql b/examples/cognito-userpools/schema.graphql new file mode 100644 index 00000000..d4f6f0a3 --- /dev/null +++ b/examples/cognito-userpools/schema.graphql @@ -0,0 +1,9 @@ +type User { + id: ID! + email: String! + name: String +} + +type Query { + me: User +} diff --git a/examples/cognito-userpools/serverless.yml b/examples/cognito-userpools/serverless.yml new file mode 100644 index 00000000..7cc11f2a --- /dev/null +++ b/examples/cognito-userpools/serverless.yml @@ -0,0 +1,51 @@ +service: appsync-cognito-userpools + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: cognito-userpools + authentication: + type: AMAZON_COGNITO_USER_POOLS + config: + userPoolId: !Ref CognitoUserPool + awsRegion: us-east-1 + defaultAction: ALLOW + + resolvers: + Query.me: + kind: UNIT + dataSource: users + + dataSources: + users: + type: AMAZON_DYNAMODB + config: + tableName: !Ref UsersTable + +resources: + Resources: + CognitoUserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: ${self:service}-${sls:stage}-pool + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + Properties: + UserPoolId: !Ref CognitoUserPool + ClientName: ${self:service}-${sls:stage}-client + 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 diff --git a/examples/custom-domain/schema.graphql b/examples/custom-domain/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/custom-domain/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/custom-domain/serverless.yml b/examples/custom-domain/serverless.yml new file mode 100644 index 00000000..5c54dc6f --- /dev/null +++ b/examples/custom-domain/serverless.yml @@ -0,0 +1,34 @@ +service: appsync-custom-domain + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: custom-domain + authentication: + type: API_KEY + apiKeys: + - name: default + + # Custom domain managed by CloudFormation. Requires a pre-existing + # ACM certificate and Route 53 hosted zone. + domain: + name: api.example.com + useCloudFormation: true + certificateArn: + Fn::Sub: 'arn:${AWS::Partition}:acm:us-east-1:${AWS::AccountId}:certificate/abc-123' + hostedZoneId: Z1234567890ABC + retain: false + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/datasource-eventbridge/schema.graphql b/examples/datasource-eventbridge/schema.graphql new file mode 100644 index 00000000..6d45c7fd --- /dev/null +++ b/examples/datasource-eventbridge/schema.graphql @@ -0,0 +1,11 @@ +type EventResult { + success: Boolean! +} + +type Mutation { + publishEvent(name: String!, detail: String): EventResult +} + +type Query { + _empty: String +} diff --git a/examples/datasource-eventbridge/serverless.yml b/examples/datasource-eventbridge/serverless.yml new file mode 100644 index 00000000..64d6c00e --- /dev/null +++ b/examples/datasource-eventbridge/serverless.yml @@ -0,0 +1,28 @@ +service: appsync-datasource-eventbridge + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: datasource-eventbridge + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Mutation.publishEvent: + kind: UNIT + dataSource: event_bus + + dataSources: + event_bus: + type: AMAZON_EVENTBRIDGE + description: Publishes events to the default EventBridge bus + config: + eventBusArn: + Fn::Sub: 'arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:event-bus/default' diff --git a/examples/datasource-http/schema.graphql b/examples/datasource-http/schema.graphql new file mode 100644 index 00000000..46290f9e --- /dev/null +++ b/examples/datasource-http/schema.graphql @@ -0,0 +1,8 @@ +type Weather { + temperature: Float + conditions: String +} + +type Query { + getWeather(city: String!): Weather +} diff --git a/examples/datasource-http/serverless.yml b/examples/datasource-http/serverless.yml new file mode 100644 index 00000000..bde554e1 --- /dev/null +++ b/examples/datasource-http/serverless.yml @@ -0,0 +1,44 @@ +service: appsync-datasource-http + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: datasource-http + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Query.getWeather: + kind: UNIT + dataSource: weather_api + + dataSources: + weather_api: + type: HTTP + description: External weather API + config: + endpoint: https://api.weather.example.com + signed_api: + type: HTTP + description: AWS API Gateway with IAM signing + config: + endpoint: + Fn::Sub: 'https://${AWS::Region}.execute-api.${AWS::Region}.amazonaws.com' + authorizationConfig: + authorizationType: AWS_IAM + awsIamConfig: + signingRegion: us-east-1 + signingServiceName: execute-api + iamRoleStatements: + - Effect: Allow + Action: + - execute-api:Invoke + Resource: + - '*' diff --git a/examples/datasource-none/resolvers/publishMessage.js b/examples/datasource-none/resolvers/publishMessage.js new file mode 100644 index 00000000..1c444e04 --- /dev/null +++ b/examples/datasource-none/resolvers/publishMessage.js @@ -0,0 +1,12 @@ +import { util } from '@aws-appsync/utils'; + +export function request() { + return { payload: null }; +} + +export function response(ctx) { + return { + id: util.autoId(), + text: ctx.args.text, + }; +} diff --git a/examples/datasource-none/schema.graphql b/examples/datasource-none/schema.graphql new file mode 100644 index 00000000..003bd7ac --- /dev/null +++ b/examples/datasource-none/schema.graphql @@ -0,0 +1,17 @@ +type Message { + id: ID! + text: String! +} + +type Mutation { + publishMessage(text: String!): Message +} + +type Subscription { + onMessagePublished: Message + @aws_subscribe(mutations: ["publishMessage"]) +} + +type Query { + _empty: String +} diff --git a/examples/datasource-none/serverless.yml b/examples/datasource-none/serverless.yml new file mode 100644 index 00000000..e1f72ce6 --- /dev/null +++ b/examples/datasource-none/serverless.yml @@ -0,0 +1,30 @@ +service: appsync-datasource-none + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +# NONE data sources are useful for local resolvers (e.g. data transformations, +# subscription publishing, or stitching results from other resolvers in a +# PIPELINE) where no AWS service call is needed. + +appSync: + name: datasource-none + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Mutation.publishMessage: + kind: UNIT + dataSource: noop + code: ./resolvers/publishMessage.js + + dataSources: + noop: + type: NONE + description: Pass-through data source for subscription publishing diff --git a/examples/datasource-opensearch/schema.graphql b/examples/datasource-opensearch/schema.graphql new file mode 100644 index 00000000..af7264db --- /dev/null +++ b/examples/datasource-opensearch/schema.graphql @@ -0,0 +1,8 @@ +type SearchResult { + id: ID! + title: String! +} + +type Query { + search(query: String!): [SearchResult!]! +} diff --git a/examples/datasource-opensearch/serverless.yml b/examples/datasource-opensearch/serverless.yml new file mode 100644 index 00000000..85fd9fe8 --- /dev/null +++ b/examples/datasource-opensearch/serverless.yml @@ -0,0 +1,43 @@ +service: appsync-datasource-opensearch + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: datasource-opensearch + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Query.search: + kind: UNIT + dataSource: search + + dataSources: + search: + type: AMAZON_OPENSEARCH_SERVICE + description: Search index + config: + endpoint: !GetAtt SearchDomain.DomainEndpoint + domain: SearchDomain + +resources: + Resources: + SearchDomain: + Type: AWS::OpenSearchService::Domain + Properties: + DomainName: ${self:service}-${sls:stage} + EngineVersion: OpenSearch_2.11 + ClusterConfig: + InstanceType: t3.small.search + InstanceCount: 1 + EBSOptions: + EBSEnabled: true + VolumeType: gp3 + VolumeSize: 10 diff --git a/examples/datasource-rds/schema.graphql b/examples/datasource-rds/schema.graphql new file mode 100644 index 00000000..cab9be87 --- /dev/null +++ b/examples/datasource-rds/schema.graphql @@ -0,0 +1,9 @@ +type Order { + id: ID! + customerId: ID! + total: Float! +} + +type Query { + listOrders: [Order!]! +} diff --git a/examples/datasource-rds/serverless.yml b/examples/datasource-rds/serverless.yml new file mode 100644 index 00000000..ae300545 --- /dev/null +++ b/examples/datasource-rds/serverless.yml @@ -0,0 +1,31 @@ +service: appsync-datasource-rds + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: datasource-rds + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Query.listOrders: + kind: UNIT + dataSource: orders_db + + dataSources: + orders_db: + type: RELATIONAL_DATABASE + description: Aurora Serverless v1 (Data API) + config: + databaseName: orders + dbClusterIdentifier: + Fn::Sub: 'arn:${AWS::Partition}:rds:${AWS::Region}:${AWS::AccountId}:cluster:ordersdb' + awsSecretStoreArn: + Fn::Sub: 'arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:OrdersDbCredentials' diff --git a/examples/environment-variables/resolvers/getUser.js b/examples/environment-variables/resolvers/getUser.js new file mode 100644 index 00000000..1a7bc896 --- /dev/null +++ b/examples/environment-variables/resolvers/getUser.js @@ -0,0 +1,9 @@ +export function request(ctx) { + // ctx.env.LOG_LEVEL would be 'info' at runtime + console.log('Log level:', ctx.env?.LOG_LEVEL); + return { payload: null }; +} + +export function response(ctx) { + return { id: ctx.args.id, name: 'Mock User' }; +} diff --git a/examples/environment-variables/schema.graphql b/examples/environment-variables/schema.graphql new file mode 100644 index 00000000..bcedf588 --- /dev/null +++ b/examples/environment-variables/schema.graphql @@ -0,0 +1,8 @@ +type User { + id: ID! + name: String +} + +type Query { + getUser(id: ID!): User +} diff --git a/examples/environment-variables/serverless.yml b/examples/environment-variables/serverless.yml new file mode 100644 index 00000000..0bf7bd9c --- /dev/null +++ b/examples/environment-variables/serverless.yml @@ -0,0 +1,33 @@ +service: appsync-environment-variables + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: environment-variables + authentication: + type: API_KEY + apiKeys: + - name: default + + # Environment variables are exposed to JS resolvers at runtime via + # `ctx.env.`. Useful for parameterizing resolver behavior per + # stage without committing values to resolver code. + environment: + LOG_LEVEL: info + FEATURE_FLAG_NEW_AUTH: 'true' + EXTERNAL_API_URL: https://api.example.com + + resolvers: + Query.getUser: + kind: UNIT + dataSource: none_ds + code: ./resolvers/getUser.js + + dataSources: + none_ds: + type: NONE diff --git a/examples/iam-auth/schema.graphql b/examples/iam-auth/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/iam-auth/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/iam-auth/serverless.yml b/examples/iam-auth/serverless.yml new file mode 100644 index 00000000..c359584e --- /dev/null +++ b/examples/iam-auth/serverless.yml @@ -0,0 +1,22 @@ +service: appsync-iam-auth + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: iam-auth + authentication: + type: AWS_IAM + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/introspection-disabled/schema.graphql b/examples/introspection-disabled/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/introspection-disabled/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/introspection-disabled/serverless.yml b/examples/introspection-disabled/serverless.yml new file mode 100644 index 00000000..39ad4ada --- /dev/null +++ b/examples/introspection-disabled/serverless.yml @@ -0,0 +1,31 @@ +service: appsync-introspection-disabled + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: introspection-disabled + authentication: + type: API_KEY + apiKeys: + - name: default + + # Hardening for production APIs: disable schema introspection so + # the schema isn't queryable by anonymous clients, and cap query + # depth + resolver count to prevent resource-exhaustion DoS. + introspection: false + queryDepthLimit: 10 + resolverCountLimit: 50 + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/lambda-authorizer/handler.js b/examples/lambda-authorizer/handler.js new file mode 100644 index 00000000..e884ab2a --- /dev/null +++ b/examples/lambda-authorizer/handler.js @@ -0,0 +1,8 @@ +exports.authorize = async (event) => { + return { + isAuthorized: event.authorizationToken?.startsWith('Bearer '), + resolverContext: {}, + deniedFields: [], + ttlOverride: 0, + }; +}; diff --git a/examples/lambda-authorizer/schema.graphql b/examples/lambda-authorizer/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/lambda-authorizer/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/lambda-authorizer/serverless.yml b/examples/lambda-authorizer/serverless.yml new file mode 100644 index 00000000..7deb374f --- /dev/null +++ b/examples/lambda-authorizer/serverless.yml @@ -0,0 +1,30 @@ +service: appsync-lambda-authorizer + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +functions: + authorizer: + handler: handler.authorize + +appSync: + name: lambda-authorizer + authentication: + type: AWS_LAMBDA + config: + authorizerResultTtlInSeconds: 300 + functionName: authorizer + identityValidationExpression: '^Bearer .*' + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/lambda-resolvers-js/functions/createUser.js b/examples/lambda-resolvers-js/functions/createUser.js new file mode 100644 index 00000000..c84de3b2 --- /dev/null +++ b/examples/lambda-resolvers-js/functions/createUser.js @@ -0,0 +1,7 @@ +exports.handler = async (event) => { + return { + id: 'mock-id', + name: event.arguments.name, + email: event.arguments.email, + }; +}; diff --git a/examples/lambda-resolvers-js/resolvers/getUser.js b/examples/lambda-resolvers-js/resolvers/getUser.js new file mode 100644 index 00000000..636adfae --- /dev/null +++ b/examples/lambda-resolvers-js/resolvers/getUser.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/lambda-resolvers-js/resolvers/listUsers.js b/examples/lambda-resolvers-js/resolvers/listUsers.js new file mode 100644 index 00000000..3bc17177 --- /dev/null +++ b/examples/lambda-resolvers-js/resolvers/listUsers.js @@ -0,0 +1,7 @@ +export function request() { + return { operation: 'Scan' }; +} + +export function response(ctx) { + return ctx.result.items; +} diff --git a/examples/lambda-resolvers-js/schema.graphql b/examples/lambda-resolvers-js/schema.graphql new file mode 100644 index 00000000..8a7612f9 --- /dev/null +++ b/examples/lambda-resolvers-js/schema.graphql @@ -0,0 +1,14 @@ +type User { + id: ID! + name: String! + email: String +} + +type Query { + getUser(id: ID!): User + listUsers: [User!]! +} + +type Mutation { + createUser(name: String!, email: String): User +} diff --git a/examples/lambda-resolvers-js/serverless.yml b/examples/lambda-resolvers-js/serverless.yml new file mode 100644 index 00000000..53ffc656 --- /dev/null +++ b/examples/lambda-resolvers-js/serverless.yml @@ -0,0 +1,57 @@ +service: appsync-lambda-resolvers-js + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +functions: + createUser: + handler: functions/createUser.handler + +appSync: + name: lambda-resolvers-js + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Query.getUser: + kind: UNIT + dataSource: users_table + code: ./resolvers/getUser.js + Mutation.createUser: + kind: UNIT + dataSource: createUser_lambda + Query.listUsers: + kind: UNIT + dataSource: users_table + code: ./resolvers/listUsers.js + + dataSources: + users_table: + type: AMAZON_DYNAMODB + config: + tableName: !Ref UsersTable + createUser_lambda: + type: AWS_LAMBDA + config: + function: + handler: functions/createUser.handler + +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 diff --git a/examples/lambda-resolvers-vtl/resolvers/getUser.request.vtl b/examples/lambda-resolvers-vtl/resolvers/getUser.request.vtl new file mode 100644 index 00000000..9c604eb1 --- /dev/null +++ b/examples/lambda-resolvers-vtl/resolvers/getUser.request.vtl @@ -0,0 +1,7 @@ +{ + "version": "2018-05-29", + "operation": "GetItem", + "key": { + "id": $util.dynamodb.toDynamoDBJson($ctx.args.id) + } +} diff --git a/examples/lambda-resolvers-vtl/resolvers/getUser.response.vtl b/examples/lambda-resolvers-vtl/resolvers/getUser.response.vtl new file mode 100644 index 00000000..db241d37 --- /dev/null +++ b/examples/lambda-resolvers-vtl/resolvers/getUser.response.vtl @@ -0,0 +1 @@ +$util.toJson($ctx.result) diff --git a/examples/lambda-resolvers-vtl/schema.graphql b/examples/lambda-resolvers-vtl/schema.graphql new file mode 100644 index 00000000..bcedf588 --- /dev/null +++ b/examples/lambda-resolvers-vtl/schema.graphql @@ -0,0 +1,8 @@ +type User { + id: ID! + name: String +} + +type Query { + getUser(id: ID!): User +} diff --git a/examples/lambda-resolvers-vtl/serverless.yml b/examples/lambda-resolvers-vtl/serverless.yml new file mode 100644 index 00000000..b1a397ff --- /dev/null +++ b/examples/lambda-resolvers-vtl/serverless.yml @@ -0,0 +1,42 @@ +service: appsync-lambda-resolvers-vtl + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: lambda-resolvers-vtl + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Query.getUser: + kind: UNIT + dataSource: users + request: ./resolvers/getUser.request.vtl + response: ./resolvers/getUser.response.vtl + + dataSources: + users: + type: AMAZON_DYNAMODB + config: + tableName: !Ref UsersTable + +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 diff --git a/examples/logging-xray/schema.graphql b/examples/logging-xray/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/logging-xray/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/logging-xray/serverless.yml b/examples/logging-xray/serverless.yml new file mode 100644 index 00000000..72e3ee7a --- /dev/null +++ b/examples/logging-xray/serverless.yml @@ -0,0 +1,33 @@ +service: appsync-logging-xray + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: logging-xray + authentication: + type: API_KEY + apiKeys: + - name: default + + # Field-level logging via CloudWatch Logs + logging: + level: ALL + retentionInDays: 14 + excludeVerboseContent: false + + # AWS X-Ray tracing for resolver-level performance insight + xrayEnabled: true + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/multi-auth/schema.graphql b/examples/multi-auth/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/multi-auth/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/multi-auth/serverless.yml b/examples/multi-auth/serverless.yml new file mode 100644 index 00000000..5e0b0611 --- /dev/null +++ b/examples/multi-auth/serverless.yml @@ -0,0 +1,41 @@ +service: appsync-multi-auth + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: multi-auth + authentication: + type: API_KEY + apiKeys: + - name: default + description: Default API key + additionalAuthentications: + - type: AMAZON_COGNITO_USER_POOLS + config: + userPoolId: !Ref CognitoUserPool + awsRegion: us-east-1 + - type: AWS_IAM + - type: OPENID_CONNECT + config: + issuer: https://example.auth0.com/ + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE + +resources: + Resources: + CognitoUserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: ${self:service}-${sls:stage}-pool diff --git a/examples/oidc-auth/schema.graphql b/examples/oidc-auth/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/oidc-auth/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/oidc-auth/serverless.yml b/examples/oidc-auth/serverless.yml new file mode 100644 index 00000000..ededcd5e --- /dev/null +++ b/examples/oidc-auth/serverless.yml @@ -0,0 +1,27 @@ +service: appsync-oidc-auth + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: oidc-auth + authentication: + type: OPENID_CONNECT + config: + issuer: https://example.auth0.com/ + clientId: my-client-id + iatTTL: 3600 + authTTL: 3600 + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/pipeline-resolvers/functions/getPosts.js b/examples/pipeline-resolvers/functions/getPosts.js new file mode 100644 index 00000000..ebca2c9f --- /dev/null +++ b/examples/pipeline-resolvers/functions/getPosts.js @@ -0,0 +1,17 @@ +import { util } from '@aws-appsync/utils'; + +export function request(ctx) { + return { + operation: 'Query', + query: { + expression: 'userId = :userId', + expressionValues: util.dynamodb.toMapValues({ + ':userId': ctx.prev.result.id, + }), + }, + }; +} + +export function response(ctx) { + return { ...ctx.prev.result, posts: ctx.result.items }; +} diff --git a/examples/pipeline-resolvers/functions/getUser.js b/examples/pipeline-resolvers/functions/getUser.js new file mode 100644 index 00000000..636adfae --- /dev/null +++ b/examples/pipeline-resolvers/functions/getUser.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-resolvers/schema.graphql b/examples/pipeline-resolvers/schema.graphql new file mode 100644 index 00000000..632f163a --- /dev/null +++ b/examples/pipeline-resolvers/schema.graphql @@ -0,0 +1,15 @@ +type User { + id: ID! + name: String! + posts: [Post!] +} + +type Post { + id: ID! + title: String! + body: String +} + +type Query { + getUserWithPosts(id: ID!): User +} diff --git a/examples/pipeline-resolvers/serverless.yml b/examples/pipeline-resolvers/serverless.yml new file mode 100644 index 00000000..eb33e0a3 --- /dev/null +++ b/examples/pipeline-resolvers/serverless.yml @@ -0,0 +1,65 @@ +service: appsync-pipeline-resolvers + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: pipeline-resolvers + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Query.getUserWithPosts: + kind: PIPELINE + functions: + - getUser + - getPosts + + pipelineFunctions: + getUser: + dataSource: users + code: ./functions/getUser.js + getPosts: + dataSource: posts + code: ./functions/getPosts.js + + dataSources: + users: + type: AMAZON_DYNAMODB + config: + tableName: !Ref UsersTable + posts: + type: AMAZON_DYNAMODB + config: + tableName: !Ref PostsTable + +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 + 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 diff --git a/examples/schema-multiple-files/schemas/post.graphql b/examples/schema-multiple-files/schemas/post.graphql new file mode 100644 index 00000000..de7ffad7 --- /dev/null +++ b/examples/schema-multiple-files/schemas/post.graphql @@ -0,0 +1,6 @@ +type Post { + id: ID! + title: String! + body: String + authorId: ID! +} diff --git a/examples/schema-multiple-files/schemas/schema.graphql b/examples/schema-multiple-files/schemas/schema.graphql new file mode 100644 index 00000000..5340b6c9 --- /dev/null +++ b/examples/schema-multiple-files/schemas/schema.graphql @@ -0,0 +1,4 @@ +type Query { + getUser(id: ID!): User + getPost(id: ID!): Post +} diff --git a/examples/schema-multiple-files/schemas/user.graphql b/examples/schema-multiple-files/schemas/user.graphql new file mode 100644 index 00000000..d6c9abea --- /dev/null +++ b/examples/schema-multiple-files/schemas/user.graphql @@ -0,0 +1,5 @@ +type User { + id: ID! + name: String! + email: String +} diff --git a/examples/schema-multiple-files/serverless.yml b/examples/schema-multiple-files/serverless.yml new file mode 100644 index 00000000..0e28300f --- /dev/null +++ b/examples/schema-multiple-files/serverless.yml @@ -0,0 +1,34 @@ +service: appsync-schema-multiple-files + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +# Schema can be split across multiple .graphql files using a glob pattern. +# The plugin concatenates them at synthesis time, preserving each file's +# definitions. This is the recommended pattern for any schema bigger than +# ~100 lines. + +appSync: + name: schema-multiple-files + schema: + - 'schemas/*.graphql' + authentication: + type: API_KEY + apiKeys: + - name: default + + resolvers: + Query.getUser: + kind: UNIT + dataSource: none_ds + Query.getPost: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/substitutions/resolvers/getUser.request.vtl b/examples/substitutions/resolvers/getUser.request.vtl new file mode 100644 index 00000000..3922b27b --- /dev/null +++ b/examples/substitutions/resolvers/getUser.request.vtl @@ -0,0 +1,9 @@ +## Environment: ${ENVIRONMENT} +{ + "version": "2018-05-29", + "operation": "GetItem", + "key": { + "id": $util.dynamodb.toDynamoDBJson($ctx.args.id) + }, + "tableName": "${TABLE_NAME}" +} diff --git a/examples/substitutions/resolvers/getUser.response.vtl b/examples/substitutions/resolvers/getUser.response.vtl new file mode 100644 index 00000000..db241d37 --- /dev/null +++ b/examples/substitutions/resolvers/getUser.response.vtl @@ -0,0 +1 @@ +$util.toJson($ctx.result) diff --git a/examples/substitutions/schema.graphql b/examples/substitutions/schema.graphql new file mode 100644 index 00000000..eef15ac5 --- /dev/null +++ b/examples/substitutions/schema.graphql @@ -0,0 +1,8 @@ +type User { + id: ID! + name: String! +} + +type Query { + getUser(id: ID!): User +} diff --git a/examples/substitutions/serverless.yml b/examples/substitutions/serverless.yml new file mode 100644 index 00000000..6b9d095b --- /dev/null +++ b/examples/substitutions/serverless.yml @@ -0,0 +1,50 @@ +service: appsync-substitutions + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: substitutions + authentication: + type: API_KEY + apiKeys: + - name: default + + # Substitutions are replaced inside VTL templates at synthesis time. + # Useful for parameterizing templates with stage-specific values + # without committing them to template files. + substitutions: + TABLE_NAME: + Ref: UsersTable + ENVIRONMENT: ${sls:stage} + + resolvers: + Query.getUser: + kind: UNIT + dataSource: users + request: ./resolvers/getUser.request.vtl + response: ./resolvers/getUser.response.vtl + + dataSources: + users: + type: AMAZON_DYNAMODB + config: + tableName: !Ref UsersTable + +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 diff --git a/examples/tags/schema.graphql b/examples/tags/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/tags/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/tags/serverless.yml b/examples/tags/serverless.yml new file mode 100644 index 00000000..c6c4f3b6 --- /dev/null +++ b/examples/tags/serverless.yml @@ -0,0 +1,33 @@ +service: appsync-tags + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: tags + authentication: + type: API_KEY + apiKeys: + - name: default + + # Tags propagate to the AppSync API and (where supported) to associated + # resources like the WAF ACL. Useful for cost allocation, ownership, + # and compliance metadata. + tags: + owner: platform-team + environment: ${sls:stage} + cost-center: '1234' + project: my-product + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/visibility-private/schema.graphql b/examples/visibility-private/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/visibility-private/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/visibility-private/serverless.yml b/examples/visibility-private/serverless.yml new file mode 100644 index 00000000..3881f2a7 --- /dev/null +++ b/examples/visibility-private/serverless.yml @@ -0,0 +1,27 @@ +service: appsync-visibility-private + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: visibility-private + authentication: + type: AWS_IAM + + # PRIVATE visibility makes the API reachable only from VPC endpoints, + # not from the public internet. Used for internal APIs that need to + # be accessible from inside a VPC but never directly from the internet. + visibility: PRIVATE + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/examples/waf/schema.graphql b/examples/waf/schema.graphql new file mode 100644 index 00000000..6ae991f6 --- /dev/null +++ b/examples/waf/schema.graphql @@ -0,0 +1,3 @@ +type Query { + hello: String +} diff --git a/examples/waf/serverless.yml b/examples/waf/serverless.yml new file mode 100644 index 00000000..617a7ae8 --- /dev/null +++ b/examples/waf/serverless.yml @@ -0,0 +1,45 @@ +service: appsync-waf + +provider: + name: aws + runtime: nodejs20.x + +plugins: + - serverless-appsync-plugin + +appSync: + name: waf-protected + authentication: + type: API_KEY + apiKeys: + - name: default + + waf: + enabled: true + name: AppSyncWaf + defaultAction: Allow + description: WAF rules for the GraphQL API + rules: + - throttle: 200 + - disableIntrospection + - name: BlockBadBots + action: Block + statement: + ByteMatchStatement: + FieldToMatch: + SingleHeader: + Name: user-agent + PositionalConstraint: CONTAINS + SearchString: BadBot + TextTransformations: + - Priority: 0 + Type: LOWERCASE + + resolvers: + Query.hello: + kind: UNIT + dataSource: none_ds + + dataSources: + none_ds: + type: NONE diff --git a/jest.e2e.config.ts b/jest.e2e.config.ts new file mode 100644 index 00000000..41555969 --- /dev/null +++ b/jest.e2e.config.ts @@ -0,0 +1,15 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/e2e/**/*.e2e.test.ts'], + testTimeout: 60_000, + globalSetup: './jest.e2e.setup.ts', + // E2E tests synthesize CloudFormation by spawning the Serverless Framework + // CLI. Keep concurrency modest so we don't overwhelm CI runners. + maxWorkers: 2, +}; + +export default config; diff --git a/jest.e2e.setup.ts b/jest.e2e.setup.ts new file mode 100644 index 00000000..a06be222 --- /dev/null +++ b/jest.e2e.setup.ts @@ -0,0 +1,48 @@ +/** + * Jest globalSetup for E2E tests. + * + * Creates a `node_modules/serverless-appsync-plugin` symlink under + * `examples/` so that every example project resolves the plugin from + * the current source tree (via Node's module resolution walking up + * the directory tree), without needing a per-example `npm install`. + * + * This is invoked once before any e2e test runs. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +export default async function globalSetup(): Promise { + const repoRoot = path.resolve(__dirname); + const examplesNodeModules = path.join(repoRoot, 'examples', 'node_modules'); + const pluginSymlinkPath = path.join( + examplesNodeModules, + 'serverless-appsync-plugin', + ); + + fs.mkdirSync(examplesNodeModules, { recursive: true }); + + // Remove any pre-existing entry (whether file, symlink, or directory) + // so we always get a fresh symlink pointing at the current repo root. + if ( + fs.existsSync(pluginSymlinkPath) || + fs.lstatSync(pluginSymlinkPath, { throwIfNoEntry: false }) + ) { + try { + fs.rmSync(pluginSymlinkPath, { recursive: true, force: true }); + } catch { + // ignore + } + } + + // Use a relative symlink so it works in any clone location. + fs.symlinkSync('../..', pluginSymlinkPath, 'dir'); + + // Make sure `lib/` is built — synthesis needs the compiled plugin. + const libPath = path.join(repoRoot, 'lib', 'index.js'); + if (!fs.existsSync(libPath)) { + throw new Error( + `Plugin build artifact not found at ${libPath}. ` + + `Run \`npm run build\` before \`npm run test:e2e\`, or use \`npm run test:e2e\` directly which builds first.`, + ); + } +} diff --git a/package.json b/package.json index ff037d59..6a8c6472 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "scripts": { "test": "jest src/__tests__/.*\\.test\\.ts", "test:watch": "jest src/__tests__/.*\\.test\\.ts --watch", + "test:e2e": "npm run build && jest --config jest.e2e.config.ts", + "test:all": "npm run test && npm run test:e2e", "build": "tsc", "lint": "eslint . && tsc --noEmit", "lint:fix": "eslint . --fix", From 7e6225c3e2a00bd073da35567869a09c34e46064 Mon Sep 17 00:00:00 2001 From: sid88in Date: Mon, 25 May 2026 22:35:33 +0000 Subject: [PATCH 2/2] address review feedback from @AlexHladin - Bump actions/checkout and actions/setup-node to v6 (latest stable) in both the tests and e2e jobs of .github/workflows/ci.yml - Bump all example runtimes from nodejs20.x to nodejs22.x. Node 20 reached EOL on April 30 2026; Lambda console removed it then and function updates will be blocked from Sep 30 2026. Examples should reflect the current recommended runtime so users copying them get a supported runtime by default. - Export AuthenticationType union from src/types/common.ts (derived from the existing Auth discriminated union via Auth['type']) and use it in e2e/helpers/assertions.ts instead of duplicating the literal string union. Keeps the e2e helper and the source of truth in sync automatically if a new auth type is ever added. Re: 'why Node 22 for the e2e job and not the full 20/22/24/26 matrix?' The CFN output produced by serverless package is identical across Node versions (no Node-specific code paths in synthesis). The existing unit-test matrix already verifies plugin logic across all four Node versions; running the synthesis tests across the same matrix would 4x CI minutes for zero added signal. 22 specifically matches what the Release workflow uses, keeping CI consistent. --- .github/workflows/ci.yml | 8 ++++---- e2e/helpers/assertions.ts | 8 ++------ examples/api-keys-multiple/serverless.yml | 2 +- examples/basic-api-key/serverless.yml | 2 +- examples/caching/serverless.yml | 2 +- examples/cognito-userpools/serverless.yml | 2 +- examples/custom-domain/serverless.yml | 2 +- examples/datasource-eventbridge/serverless.yml | 2 +- examples/datasource-http/serverless.yml | 2 +- examples/datasource-none/serverless.yml | 2 +- examples/datasource-opensearch/serverless.yml | 2 +- examples/datasource-rds/serverless.yml | 2 +- examples/environment-variables/serverless.yml | 2 +- examples/iam-auth/serverless.yml | 2 +- examples/introspection-disabled/serverless.yml | 2 +- examples/lambda-authorizer/serverless.yml | 2 +- examples/lambda-resolvers-js/serverless.yml | 2 +- examples/lambda-resolvers-vtl/serverless.yml | 2 +- examples/logging-xray/serverless.yml | 2 +- examples/multi-auth/serverless.yml | 2 +- examples/oidc-auth/serverless.yml | 2 +- examples/pipeline-resolvers/serverless.yml | 2 +- examples/schema-multiple-files/serverless.yml | 2 +- examples/substitutions/serverless.yml | 2 +- examples/tags/serverless.yml | 2 +- examples/visibility-private/serverless.yml | 2 +- examples/waf/serverless.yml | 2 +- src/types/common.ts | 2 ++ 28 files changed, 33 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 227055c1..2cfdb70d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,11 @@ jobs: matrix: node: [20, 22, 24, 26] steps: - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install dependencies run: npm ci - name: Lint @@ -49,8 +49,8 @@ jobs: needs: tests steps: - name: Checkout code - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: 22 - name: Install dependencies diff --git a/e2e/helpers/assertions.ts b/e2e/helpers/assertions.ts index 79c8ef5e..8647abdb 100644 --- a/e2e/helpers/assertions.ts +++ b/e2e/helpers/assertions.ts @@ -1,4 +1,5 @@ import { CfnResource, CfnTemplate } from './synthesize'; +import type { AuthenticationType } from '../../src/types/common'; /** * Find all resources of a given CloudFormation type. @@ -96,12 +97,7 @@ function matchesProperties(actual: unknown, expected: unknown): boolean { */ export function expectAuthenticationType( template: CfnTemplate, - type: - | 'API_KEY' - | 'AWS_IAM' - | 'AMAZON_COGNITO_USER_POOLS' - | 'OPENID_CONNECT' - | 'AWS_LAMBDA', + type: AuthenticationType, ): void { const { resource } = findOneResourceByType( template, diff --git a/examples/api-keys-multiple/serverless.yml b/examples/api-keys-multiple/serverless.yml index f7d65db7..2af9c1a6 100644 --- a/examples/api-keys-multiple/serverless.yml +++ b/examples/api-keys-multiple/serverless.yml @@ -2,7 +2,7 @@ service: appsync-api-keys-multiple provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/basic-api-key/serverless.yml b/examples/basic-api-key/serverless.yml index 68658b7b..1041355f 100644 --- a/examples/basic-api-key/serverless.yml +++ b/examples/basic-api-key/serverless.yml @@ -2,7 +2,7 @@ service: appsync-basic-api-key provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/caching/serverless.yml b/examples/caching/serverless.yml index 5da652de..7fb62caf 100644 --- a/examples/caching/serverless.yml +++ b/examples/caching/serverless.yml @@ -2,7 +2,7 @@ service: appsync-caching provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/cognito-userpools/serverless.yml b/examples/cognito-userpools/serverless.yml index 7cc11f2a..260e1e1f 100644 --- a/examples/cognito-userpools/serverless.yml +++ b/examples/cognito-userpools/serverless.yml @@ -2,7 +2,7 @@ service: appsync-cognito-userpools provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/custom-domain/serverless.yml b/examples/custom-domain/serverless.yml index 5c54dc6f..4df4eec9 100644 --- a/examples/custom-domain/serverless.yml +++ b/examples/custom-domain/serverless.yml @@ -2,7 +2,7 @@ service: appsync-custom-domain provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/datasource-eventbridge/serverless.yml b/examples/datasource-eventbridge/serverless.yml index 64d6c00e..fc9c3bb5 100644 --- a/examples/datasource-eventbridge/serverless.yml +++ b/examples/datasource-eventbridge/serverless.yml @@ -2,7 +2,7 @@ service: appsync-datasource-eventbridge provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/datasource-http/serverless.yml b/examples/datasource-http/serverless.yml index bde554e1..e9b3eced 100644 --- a/examples/datasource-http/serverless.yml +++ b/examples/datasource-http/serverless.yml @@ -2,7 +2,7 @@ service: appsync-datasource-http provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/datasource-none/serverless.yml b/examples/datasource-none/serverless.yml index e1f72ce6..4e2a6314 100644 --- a/examples/datasource-none/serverless.yml +++ b/examples/datasource-none/serverless.yml @@ -2,7 +2,7 @@ service: appsync-datasource-none provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/datasource-opensearch/serverless.yml b/examples/datasource-opensearch/serverless.yml index 85fd9fe8..bdfa5644 100644 --- a/examples/datasource-opensearch/serverless.yml +++ b/examples/datasource-opensearch/serverless.yml @@ -2,7 +2,7 @@ service: appsync-datasource-opensearch provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/datasource-rds/serverless.yml b/examples/datasource-rds/serverless.yml index ae300545..f7fee526 100644 --- a/examples/datasource-rds/serverless.yml +++ b/examples/datasource-rds/serverless.yml @@ -2,7 +2,7 @@ service: appsync-datasource-rds provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/environment-variables/serverless.yml b/examples/environment-variables/serverless.yml index 0bf7bd9c..d58755ac 100644 --- a/examples/environment-variables/serverless.yml +++ b/examples/environment-variables/serverless.yml @@ -2,7 +2,7 @@ service: appsync-environment-variables provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/iam-auth/serverless.yml b/examples/iam-auth/serverless.yml index c359584e..7d8e567d 100644 --- a/examples/iam-auth/serverless.yml +++ b/examples/iam-auth/serverless.yml @@ -2,7 +2,7 @@ service: appsync-iam-auth provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/introspection-disabled/serverless.yml b/examples/introspection-disabled/serverless.yml index 39ad4ada..7350f686 100644 --- a/examples/introspection-disabled/serverless.yml +++ b/examples/introspection-disabled/serverless.yml @@ -2,7 +2,7 @@ service: appsync-introspection-disabled provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/lambda-authorizer/serverless.yml b/examples/lambda-authorizer/serverless.yml index 7deb374f..f580ccc5 100644 --- a/examples/lambda-authorizer/serverless.yml +++ b/examples/lambda-authorizer/serverless.yml @@ -2,7 +2,7 @@ service: appsync-lambda-authorizer provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/lambda-resolvers-js/serverless.yml b/examples/lambda-resolvers-js/serverless.yml index 53ffc656..58aecffc 100644 --- a/examples/lambda-resolvers-js/serverless.yml +++ b/examples/lambda-resolvers-js/serverless.yml @@ -2,7 +2,7 @@ service: appsync-lambda-resolvers-js provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/lambda-resolvers-vtl/serverless.yml b/examples/lambda-resolvers-vtl/serverless.yml index b1a397ff..245b8dd6 100644 --- a/examples/lambda-resolvers-vtl/serverless.yml +++ b/examples/lambda-resolvers-vtl/serverless.yml @@ -2,7 +2,7 @@ service: appsync-lambda-resolvers-vtl provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/logging-xray/serverless.yml b/examples/logging-xray/serverless.yml index 72e3ee7a..1c8e017e 100644 --- a/examples/logging-xray/serverless.yml +++ b/examples/logging-xray/serverless.yml @@ -2,7 +2,7 @@ service: appsync-logging-xray provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/multi-auth/serverless.yml b/examples/multi-auth/serverless.yml index 5e0b0611..784d52ae 100644 --- a/examples/multi-auth/serverless.yml +++ b/examples/multi-auth/serverless.yml @@ -2,7 +2,7 @@ service: appsync-multi-auth provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/oidc-auth/serverless.yml b/examples/oidc-auth/serverless.yml index ededcd5e..1b420116 100644 --- a/examples/oidc-auth/serverless.yml +++ b/examples/oidc-auth/serverless.yml @@ -2,7 +2,7 @@ service: appsync-oidc-auth provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/pipeline-resolvers/serverless.yml b/examples/pipeline-resolvers/serverless.yml index eb33e0a3..152016ec 100644 --- a/examples/pipeline-resolvers/serverless.yml +++ b/examples/pipeline-resolvers/serverless.yml @@ -2,7 +2,7 @@ service: appsync-pipeline-resolvers provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/schema-multiple-files/serverless.yml b/examples/schema-multiple-files/serverless.yml index 0e28300f..5c34bc10 100644 --- a/examples/schema-multiple-files/serverless.yml +++ b/examples/schema-multiple-files/serverless.yml @@ -2,7 +2,7 @@ service: appsync-schema-multiple-files provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/substitutions/serverless.yml b/examples/substitutions/serverless.yml index 6b9d095b..17899473 100644 --- a/examples/substitutions/serverless.yml +++ b/examples/substitutions/serverless.yml @@ -2,7 +2,7 @@ service: appsync-substitutions provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/tags/serverless.yml b/examples/tags/serverless.yml index c6c4f3b6..63b131e2 100644 --- a/examples/tags/serverless.yml +++ b/examples/tags/serverless.yml @@ -2,7 +2,7 @@ service: appsync-tags provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/visibility-private/serverless.yml b/examples/visibility-private/serverless.yml index 3881f2a7..e905e893 100644 --- a/examples/visibility-private/serverless.yml +++ b/examples/visibility-private/serverless.yml @@ -2,7 +2,7 @@ service: appsync-visibility-private provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/examples/waf/serverless.yml b/examples/waf/serverless.yml index 617a7ae8..caa049a4 100644 --- a/examples/waf/serverless.yml +++ b/examples/waf/serverless.yml @@ -2,7 +2,7 @@ service: appsync-waf provider: name: aws - runtime: nodejs20.x + runtime: nodejs22.x plugins: - serverless-appsync-plugin diff --git a/src/types/common.ts b/src/types/common.ts index bb360aa6..b5abedbf 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -112,6 +112,8 @@ export type ApiKeyAuth = { export type Auth = CognitoAuth | LambdaAuth | OidcAuth | ApiKeyAuth | IamAuth; +export type AuthenticationType = Auth['type']; + export type DomainConfig = { enabled?: boolean; useCloudFormation?: boolean;