From 697b66b89cd72d24fbb0309f7dd0866db96d333b Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Thu, 8 Jan 2026 18:39:06 +0000 Subject: [PATCH] feat(appsync): integrate plugin functionality (#13217) --- docs/sf/menu.json | 14 + .../providers/aws/guide/appsync/API-keys.md | 50 + docs/sf/providers/aws/guide/appsync/WAF.md | 235 +++ .../aws/guide/appsync/authentication.md | 119 ++ .../sf/providers/aws/guide/appsync/caching.md | 47 + .../providers/aws/guide/appsync/commands.md | 155 ++ .../aws/guide/appsync/custom-domain.md | 107 ++ .../aws/guide/appsync/dataSources.md | 179 ++ .../aws/guide/appsync/general-config.md | 222 +++ docs/sf/providers/aws/guide/appsync/index.md | 103 ++ .../aws/guide/appsync/pipeline-functions.md | 63 + .../providers/aws/guide/appsync/resolvers.md | 230 +++ .../providers/aws/guide/appsync/syncConfig.md | 39 + package-lock.json | 120 +- package.json | 2 +- .../serverless/lib/classes/plugin-manager.js | 27 +- .../serverless/lib/cli/render-help/general.js | 1 + .../plugins/aws/appsync/get-appsync-config.js | 143 ++ .../plugins/aws/appsync/get-stack-value.js | 28 + .../lib/plugins/aws/appsync/index.js | 971 ++++++++++ .../lib/plugins/aws/appsync/resources/Api.js | 554 ++++++ .../aws/appsync/resources/DataSource.js | 411 +++++ .../aws/appsync/resources/JsResolver.js | 117 ++ .../aws/appsync/resources/MappingTemplate.js | 77 + .../plugins/aws/appsync/resources/Naming.js | 103 ++ .../aws/appsync/resources/PipelineFunction.js | 97 + .../plugins/aws/appsync/resources/Resolver.js | 157 ++ .../plugins/aws/appsync/resources/Schema.js | 96 + .../aws/appsync/resources/SyncConfig.js | 37 + .../lib/plugins/aws/appsync/resources/Waf.js | 316 ++++ .../lib/plugins/aws/appsync/utils.js | 113 ++ .../lib/plugins/aws/appsync/validation.js | 904 ++++++++++ packages/serverless/package.json | 5 + .../appsync/__snapshots__/api.test.js.snap | 747 ++++++++ .../__snapshots__/dataSources.test.js.snap | 1595 +++++++++++++++++ .../getAppSyncConfig.test.js.snap | 257 +++ .../__snapshots__/js-resolvers.test.js.snap | 66 + .../mapping-templates.test.js.snap | 62 + .../__snapshots__/resolvers.test.js.snap | 456 +++++ .../appsync/__snapshots__/schema.test.js.snap | 136 ++ .../appsync/__snapshots__/waf.test.js.snap | 607 +++++++ .../unit/lib/plugins/aws/appsync/api.test.js | 427 +++++ .../lib/plugins/aws/appsync/basicConfig.js | 8 + .../plugins/aws/appsync/dataSources.test.js | 466 +++++ .../fixtures/schemas/multiple/post.graphql | 22 + .../fixtures/schemas/multiple/schema.graphql | 3 + .../fixtures/schemas/multiple/user.graphql | 19 + .../fixtures/schemas/single/schema.graphql | 20 + .../aws/appsync/getAppSyncConfig.test.js | 308 ++++ .../unit/lib/plugins/aws/appsync/given.js | 103 ++ .../lib/plugins/aws/appsync/index.test.js | 81 + .../plugins/aws/appsync/js-resolvers.test.js | 78 + .../aws/appsync/mapping-templates.test.js | 76 + .../lib/plugins/aws/appsync/resolvers.test.js | 531 ++++++ .../lib/plugins/aws/appsync/schema.test.js | 59 + .../lib/plugins/aws/appsync/utils.test.js | 72 + .../__snapshots__/apiKeys.test.js.snap | 9 + .../__snapshots__/auth.test.js.snap | 44 + .../__snapshots__/base.test.js.snap | 70 + .../__snapshots__/datasources.test.js.snap | 131 ++ .../pipelineFunctions.test.js.snap | 25 + .../__snapshots__/resolvers.test.js.snap | 44 + .../aws/appsync/validation/apiKeys.test.js | 127 ++ .../aws/appsync/validation/auth.test.js | 262 +++ .../aws/appsync/validation/base.test.js | 463 +++++ .../appsync/validation/datasources.test.js | 881 +++++++++ .../validation/pipelineFunctions.test.js | 130 ++ .../aws/appsync/validation/resolvers.test.js | 228 +++ .../unit/lib/plugins/aws/appsync/waf.test.js | 246 +++ 69 files changed, 14689 insertions(+), 12 deletions(-) create mode 100644 docs/sf/providers/aws/guide/appsync/API-keys.md create mode 100644 docs/sf/providers/aws/guide/appsync/WAF.md create mode 100644 docs/sf/providers/aws/guide/appsync/authentication.md create mode 100644 docs/sf/providers/aws/guide/appsync/caching.md create mode 100644 docs/sf/providers/aws/guide/appsync/commands.md create mode 100644 docs/sf/providers/aws/guide/appsync/custom-domain.md create mode 100644 docs/sf/providers/aws/guide/appsync/dataSources.md create mode 100644 docs/sf/providers/aws/guide/appsync/general-config.md create mode 100644 docs/sf/providers/aws/guide/appsync/index.md create mode 100644 docs/sf/providers/aws/guide/appsync/pipeline-functions.md create mode 100644 docs/sf/providers/aws/guide/appsync/resolvers.md create mode 100644 docs/sf/providers/aws/guide/appsync/syncConfig.md create mode 100644 packages/serverless/lib/plugins/aws/appsync/get-appsync-config.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/get-stack-value.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/index.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/Api.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/DataSource.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/JsResolver.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/MappingTemplate.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/Naming.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/PipelineFunction.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/Resolver.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/Schema.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/SyncConfig.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/resources/Waf.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/utils.js create mode 100644 packages/serverless/lib/plugins/aws/appsync/validation.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/api.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/dataSources.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/getAppSyncConfig.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/js-resolvers.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/mapping-templates.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/resolvers.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/schema.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/waf.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/api.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/basicConfig.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/dataSources.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/post.graphql create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/schema.graphql create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/user.graphql create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/single/schema.graphql create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/getAppSyncConfig.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/given.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/index.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/js-resolvers.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/mapping-templates.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/resolvers.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/schema.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/utils.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/apiKeys.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/auth.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/base.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/datasources.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/pipelineFunctions.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/resolvers.test.js.snap create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/apiKeys.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/auth.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/base.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/datasources.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/pipelineFunctions.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/validation/resolvers.test.js create mode 100644 packages/serverless/test/unit/lib/plugins/aws/appsync/waf.test.js diff --git a/docs/sf/menu.json b/docs/sf/menu.json index 13270f261..5befc3b47 100644 --- a/docs/sf/menu.json +++ b/docs/sf/menu.json @@ -74,6 +74,20 @@ "State": "guides/state", "Python support": "providers/aws/guide/python", "API Gateway Proxy": "providers/aws/guide/api-gateway-aws-proxy", + "AppSync": { + "Overview": "providers/aws/guide/appsync", + "General Configuration": "providers/aws/guide/appsync/general-config", + "Authentication": "providers/aws/guide/appsync/authentication", + "API Keys": "providers/aws/guide/appsync/API-keys", + "Data Sources": "providers/aws/guide/appsync/dataSources", + "Resolvers": "providers/aws/guide/appsync/resolvers", + "Pipeline Functions": "providers/aws/guide/appsync/pipeline-functions", + "Caching": "providers/aws/guide/appsync/caching", + "Delta Sync": "providers/aws/guide/appsync/syncConfig", + "Custom Domain": "providers/aws/guide/appsync/custom-domain", + "WAF": "providers/aws/guide/appsync/WAF", + "CLI Commands": "providers/aws/guide/appsync/commands" + }, "Deploying SAM/CFN Templates": "guides/sam", "Workflow Tips": "guides/workflow", "Plugins": { diff --git a/docs/sf/providers/aws/guide/appsync/API-keys.md b/docs/sf/providers/aws/guide/appsync/API-keys.md new file mode 100644 index 000000000..34c336748 --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/API-keys.md @@ -0,0 +1,50 @@ + + +# API Keys + +When you use `API_KEY` as an [authentication method](authentication.md), you can control how API keys are created under `appSync.apiKeys`. It takes an array of API key definitions or strings. + +## Quick start + +```yaml +appSync: + apiKeys: + - john + - name: jane + description: Jane's API key. + expiresAfter: 1M +``` + +## Configuration + +It can either be string, which translates into the API key's name with default values for the other attributes, or use a custom configuration. + +- `name`: A unique name for this API key. Required. +- `description`: An optional description for this API key. +- `expiresAfter`: A time after which this API key will expire. [See below](#expiry) for more details about expiry. Defaults to `365d`. +- `expiresAt`: A date-time at which this API key will expire. [See below](#expiry) for more details about expiry. +- `wafRules`: an array of [WAF rules](WAF.md) that will apply to this API key only. + +## Expiry + +You can control expiry of the API keys with the `expiresAfter` or `expiresAt` attribute. + +`expiresAfter` behaves as a sliding-window expiry date which extends after each deployment. It can be a number of hours until expiry or a more human-friendly string. e.g. `24h`, `30d`, `3M`, `1y`. + +`expiresAt` is an exact ISO datetime at which this API will expire. It will not be renewed unless you change this value. e.g. `2022-02-13T10:00:00`. + +`expiresAfter` takes precedence over `expiresAt`. If neither are passed, it defaults to a `expiresAfter` of `365d` + +**Note**: The minimum lifetime of an API key in AppSync is 1 day and maximum is 1 year (365 days). Expiry datetimes are always rounded down to the nearest hour. (e.g. `2022-02-13T10:45:00` becomes `2022-02-13T10:00:00`). diff --git a/docs/sf/providers/aws/guide/appsync/WAF.md b/docs/sf/providers/aws/guide/appsync/WAF.md new file mode 100644 index 000000000..1f820886d --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/WAF.md @@ -0,0 +1,235 @@ + + +# Web Application Firewall (WAF) + +AppSync [supports WAF](https://aws.amazon.com/blogs/mobile/appsync-waf/). WAF is an Application Firewall that helps you protect your API against common web exploits. + +The AppSync integration comes with some handy pre-defined rules that you can enable in just a few lines of code. + +You can configure WAF rules under the `appSync.waf` attribute. + +## Quick start + +You can define a collection of rules for your web ACL and associate it: + +```yaml +appSync: + name: my-api + waf: + enabled: true + defaultAction: 'Allow' + rules: + - throttle + - disableIntrospection +``` + +Or directly associate an existing web ACL: + +```yaml +appSync: + name: my-api + waf: + enabled: true + arn: 'arn:aws:wafv2:us-east-1:123456789012:regional/webacl/my-Waf/d7b694d2-4f7d-4dd6-a9a9-843dd1931330' +``` + +## Configuration + +- `enabled`: Boolean. Enable or disable WAF. Defaults to `true` when `appSync.waf` is defined. +- `arn`: Optional. The WAF's ARN to associate with your AppSync resource. +- `name`: Optional. The name of this WAF instance. Defaults to the name of your API. +- `defaultAction`: Optional. The default action if a request does not match a rule. `Allow` or `Block`. Defaults to `Allow`. +- `description`: Optional. A description for this WAF instance. +- `visibilityConfig`: Optional. A [visibility config](https://docs.aws.amazon.com/waf/latest/APIReference/API_VisibilityConfig.html) for this WAF + - `name`: Metric name + - `cloudWatchMetricsEnabled`: A boolean indicating whether the associated resource sends metrics to Amazon CloudWatch + - `sampledRequestsEnabled`: A boolean indicating whether AWS WAF should store a sampling of the web requests that match the rule +- `rules`: Required. An array of [rules](#rules). Optional when `arn` is present + +## Rules + +### Configuration + +Common configuration to all rules: + +- `name`: The name of the rule +- `action`: How this rule should handle the incoming request when matching the rule. `Allow` or `Deny`. Defaults to `Allow`. +- `priority`: The priority of this rule. See [Rules Priority](#rules-priority) +- `visibilityConfig`: The [visibility config](https://docs.aws.amazon.com/waf/latest/APIReference/API_VisibilityConfig.html) for this rule. + +### Throttling + +Throttling will disallow requests coming from the same ip address when a limit is reached within a 5-minutes period. It corresponds to a rules with a [RateBasedStatement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-wafv2-webacl-ratebasedstatement.html). + +Examples: + +```yml +waf: + enabled: true + rules: + - throttle # limit to 100 requests per 5 minutes period + - throttle: 200 # limit to 200 requests per 5 minutes period + - throttle: + limit: 200 + priority: 10 + aggregateKeyType: FORWARDED_IP + forwardedIPConfig: + headerName: 'X-Forwarded-For' + fallbackBehavior: 'MATCH' +``` + +#### Configuration + +See the [CloudFormation documentation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-wafv2-webacl-ratebasedstatement.html) + +- `aggregateKeyType`: `IP` or `FORWARDED_IP` +- `limit`: The limit of requests in a 5-minutes window for the same IP address. +- `forwardedIPConfig`: [forwardedIPConfig](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-wafv2-webacl-forwardedipconfiguration.html) +- `scopeDownStatement`: [WebACL Statement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-wafv2-webacl-statement.html) + +### Disable Introspection + +Sometimes, you want to disable introspection to disallow untrusted consumers to discover the structure of your API. + +```yml +waf: + enabled: true + rules: + - disableIntrospection # disables introspection for everyone + - disableIntrospection: # using custom configuration + name: Disable introspection + priority: 200 +``` + +### Custom rules + +You can also specify custom rules. For more info on how to define a rule, see the [Cfn documentation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-wafv2-webacl-rule.html) + +Example: + +```yml +waf: + enabled: true + defaultAction: Block + rules: + # Only allow US users + - action: Allow + name: UsOnly + statement: + GeoMatchStatement: + CountryCodes: + - US +``` + +```yml +waf: + enabled: true + defaultAction: Block + rules: + # using ManagedRuleGroup + - name: "AWSManagedRulesCommonRuleSet" + priority: 20 + overrideAction: + None: {} + statement: + ManagedRuleGroupStatement: + VendorName: "AWS" + Name: "AWSManagedRulesCommonRuleSet" +``` + +### Per API Key rules + +In some cases, you might want to enable a rule for a given API key only. You can specify `wafRules` under the `appSync.apiKeys` attribute. The rules will apply only to that API key. + +```yml +apiKeys: + - name: MyApiKey + expiresAfter: 365d + wafRules: + - throttle # throttles this API key + - disableIntrospection # disables introspection for this API key +``` + +Adding a rule to an API key without any _statement_ will add a _match-all_ rule for that key (all requests will match that rule). This is useful for example to exclude API keys from global rules. In that case, you need to make sure to attribute a higher priority to that rule. + +Example: + +- Block all requests by default, except in the US. +- The `WorldWideApiKey` API key should be excluded from that rule. + +```yml +appSync: + waf: + enabled: true + defaultAction: Block # Block all by default + rules: + # allow US requests + - action: Allow + name: UsOnly + priority: 5 + statement: + geoMatchStatement: + countryCodes: + - US + apiKeys: + - name: Key1 # no rule is set, the global rule applies (US only) + - name: Key1 # no rule is set, the global rule applies (US only) + - name: WorldWideApiKey + wafRules: + - name: WorldWideApiKeyRule + action: Allow + priority: 1 # Since priority is higher than 5, all requests will be Allowed +``` + +### Rules priority + +The priorities don't need to be consecutive, but they must all be different. + +Setting a priority to the rules is not required, but recommended. If you don't set priority, it will be automatically attributed and sequentially incremented according to the following rules: + +First the global rules (under `appSync.waf.rules`), in the order that they are defined, then the API key rules, in order of the API keys and their rules. + +Auto-generated priorities start at 100. This gives you som room (0-99) to add other rules that should get a higher priority, if you need to. + +For more info about how rules are executed, pease refer to [the documentation](https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-processing.html) + +Example: + +```yml +appSync: + waf: + enabled: true + rules: + - name: Rule1 + # (no-set) Priority = 100 + - name: Rule2 + priority: 5 # Priority = 5 + - name: Rule3 + # (no-set) Priority = 101 + apiKeys: + - name: Key1 + wafRules: + - name: Rule4 + # (no-set) Priority = 102 + - name: Rule5 + # (no-set) Priority = 103 + - name: Key + wafRules: + - name: Rule6 + priority: 1 # Priority = 1 + - name: Rule7 + # (no-set) Priority = 104 +``` diff --git a/docs/sf/providers/aws/guide/appsync/authentication.md b/docs/sf/providers/aws/guide/appsync/authentication.md new file mode 100644 index 000000000..143bb3c13 --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/authentication.md @@ -0,0 +1,119 @@ + + +# Authentication + +[Authentication](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html) definitions are found under the `appSync.authentication` (for the default authentication method) and `appSync.additionalAuthentications` (as an array) for additional ones + +## Quick start + +```yaml +appSync: + authentication: + type: 'API_KEY' + additionalAuthentications: + - type: 'AMAZON_COGNITO_USER_POOLS' + config: + userPoolId: '123456789' +``` + +## Configuration + +- `type`: The type of authentication. Can be `API_KEY`, `AWS_IAM`, `AMAZON_COGNITO_USER_POOLS`, `AWS_LAMBDA` or `OPENID_CONNECT` +- `config`: The configuration for the provided `type` (See below). + +### API Keys + +Enables the API Key based authentication. See the [API Keys section](API-keys.md) to see how to configure them. + +```yaml +appSync: + authentication: + type: 'API_KEY' +``` + +`config` is not required for this type. + +### IAM + +Allows IAM users and roles to access the API. + +```yaml +appSync: + authentication: + type: 'AWS_IAM' +``` + +`config` is not required for this type. + +### Cognito + +Allows authentication using a Cognito user pool. + +```yaml +appSync: + authentication: + type: 'AMAZON_COGNITO_USER_POOLS' + config: + userPoolId: '123456789' +``` + +- `userPoolId`: The user pool id to use. +- `awsRegion`: The region where the user pool is located. Defaults to the stack's region. +- `appIdClientRegex`: An optional regular expression for validating the incoming Amazon Cognito user pool app client ID. +- `defaultAction`: `ALLOW` or `DENY`. The action that you want your GraphQL API to take when a request that uses Amazon Cognito user pool authentication doesn't match the Amazon Cognito user pool configuration. When specifying Amazon Cognito user pools as the default authentication, you must set this value to `ALLOW` if specifying additionalAuthentications. Default: `ALLOW`. This field is only available for the default `authorization` configuration. + +### OIDC + +Allows users to authenticate against the API using a third-party OIDC auth provider. + +```yaml +appSync: + authentication: + type: 'OPENID_CONNECT' + config: + issuer: 'https://auth.example.com' + clientId: '5fbc318d-5920-48a8-92ea-20d62d16cc60' +``` + +- `issuer`: The issuer of this OIDC config. +- `clientId`: Optional. The client identifier of the Relying party at the OpenID identity provider. This identifier is typically obtained when the Relying party is registered with the OpenID identity provider. You can specify a regular expression so that AWS AppSync can validate against multiple client identifiers at a time. +- `iatTTL`: Optional. The number of milliseconds that a token is valid after it's issued to a user. +- `authTTL`: Optional. The number of milliseconds that a token is valid after being authenticated. + +### Lambda + +Allows custom authentication through Lambda. + +```yaml +appSync: + authentication: + type: 'AWS_LAMBDA' + config: + authorizerResultTtlInSeconds: 300 + function: + timeout: 30 + handler: 'functions/auth.handler' +``` + +- `identityValidationExpression`: Optional. A regular expression for validation of tokens before the Lambda function is called. +- `authorizerResultTtlInSeconds`: Optional. The number of seconds a response should be cached for. The default is 5 minutes (300 seconds). +- `function`: A Lambda function definition as you would define it under the `functions` section of your `serverless.yml` file. +- `functionName`: The name of the function as defined under the `functions` section of the `serverless.yml` file +- `functionAlias`: A specific function alias to use. +- `functionArn`: The function ARN to use. diff --git a/docs/sf/providers/aws/guide/appsync/caching.md b/docs/sf/providers/aws/guide/appsync/caching.md new file mode 100644 index 000000000..027150590 --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/caching.md @@ -0,0 +1,47 @@ + + +# Caching + +AppSync supports [server-side data caching](https://docs.aws.amazon.com/appsync/latest/devguide/enabling-caching.html). You can find the caching configuration under the `appSync.caching` attribute. + +## Quick start + +```yaml +appSync: + name: my-api + caching: + behavior: 'PER_RESOLVER_CACHING' + type: 'SMALL' + ttl: 3600 + atRestEncryption: false + transitEncryption: false +``` + +## Configuration + +- `behavior`: `FULL_REQUEST_CACHING` or `PER_RESOLVER_CACHING` +- `type`: The type of the Redis instance. `SMALL`, `MEDIUM`, `LARGE`, `XLARGE`, `LARGE_2X`, `LARGE_4X`, `LARGE_8X`, `LARGE_12X`. Defaults to `SMALL` +- `ttl`: The default TTL of the cache in seconds. Defaults to `3600`. Maximum is `3600` +- `enabled`: Boolean. Whether caching is enabled. Defaults to `true` when the `caching` definition is present. +- `atRestEncryption`: Boolean. Whether to encrypt the data at rest. Defaults to `false` +- `transitEncryption`: Boolean. Whether to encrypt the data in transit. Defaults to `false` + +## Per resolver caching + +See [Resolver caching](resolvers.md#caching) + +## Flushing the cache + +You can use the [flush-cache command](commands.md#flush-cache) to easily flush the cache. diff --git a/docs/sf/providers/aws/guide/appsync/commands.md b/docs/sf/providers/aws/guide/appsync/commands.md new file mode 100644 index 000000000..caa1673e2 --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/commands.md @@ -0,0 +1,155 @@ + + +# Commands + +The AppSync integration provides some useful commands to explore and manage your API. + +## `validate-schema` + +This commands allows you to validate your GraphQL schema. + +```bash +sls appsync validate-schema +``` + +## `get-introspection` + +Allows you to extract the introspection of the schema as a JSON or SDL. + +**Options** + +- `--format` or `-f`: the format in which to extract the schema. `JSON` or `SDL`. Defaults to `JSON` +- `--output` or `-o`: a file where to output the schema. If not specified, prints to stdout + +```bash +sls appsync get-introspection +``` + +## `flush-cache` + +If your API uses the server-side [Caching](caching.md), this command flushes the cache. + +```bash +sls appsync flush-cache +``` + +## `console` + +Opens a new browser tab to the AWS console page of this API. + +```bash +sls appsync console +``` + +## `cloudwatch` + +Opens a new browser tab to the CloudWatch logs page of this API. + +```bash +sls appsync cloudwatch +``` + +## `logs` + +Outputs the logs of the AppSync API to stdout. + +**Options** + +- `--startTime`: Starting time. You can use human-friendly relative times. e.g. `30m`, `1h`, etc. Default: `10m` (10 minutes ago) +- `--tail` or `-t`: Keep streaming new logs. +- `--interval` or `-i`: Tail polling interval in milliseconds. Default: `1000`. +- `--filter` or `-f`: A filter pattern to apply to the logs stream. + +```bash +sls appsync logs --filter '86771d0c-c0f3-4f54-b048-793a233e3ed9' +``` + +## `domain` + +Manage the domain for this AppSync API. + +## Create the domain + +Before associating a domain to an API, you must first create it. You can do so using the following command. + +**Options** + +- `--quiet` or `-q`: Don't return an error if the operation fails +- `--stage`: The stage to use + +```bash +sls appsync domain create +``` + +## Delete the domain + +Deletes a domain from AppSync. + +**Options** + +- `--quiet` or `-q`: Don't return an error if the operation fails +- `--yes` or `-y`: Automatic yes to prompts +- `--stage`: The stage to use + +```bash +sls appsync domain delete +``` + +If an API is associated to it, you will need to [disassociate](#disassociate-the-api-from-the-domain) it first. + +## Create a route53 record + +If you use Route53 for your hosted zone, you can also create the required CNAME record for your custom domain. + +- `--quiet` or `-q`: Don't return an error if the operation fails +- `--stage`: The stage to use + +```bash +sls appsync domain create-record +``` + +## Delete the route53 record + +- `--quiet` or `-q`: Don't return an error if the operation fails +- `--yes` or `-y`: Automatic yes to prompts +- `--stage`: The stage to use + +```bash +sls appsync domain delete-record +``` + +## Associate the API to the domain + +Associate the API in this stack to the domain. + +- `--quiet` or `-q`: Don't return an error if the operation fails +- `--stage`: The stage to use + +```bash +sls appsync domain assoc --stage dev +``` + +You can associate an API to a domain that already has another API attached to it. The old API will be replaced by the new one. + +## Disassociate the API from the domain + +- `--quiet` or `-q`: Don't return an error if the operation fails +- `--yes` or `-y`: Automatic yes to prompts +- `--stage`: The stage to use + +```bash +sls appsync domain disassoc --stage dev +``` diff --git a/docs/sf/providers/aws/guide/appsync/custom-domain.md b/docs/sf/providers/aws/guide/appsync/custom-domain.md new file mode 100644 index 000000000..391993a83 --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/custom-domain.md @@ -0,0 +1,107 @@ + + +# Custom Domains + +AppSync supports associating your API to a [custom domain](https://aws.amazon.com/blogs/mobile/introducing-custom-domain-names-for-aws-appsync-apis/). + +The configuration for custom domain can be found under the `appSync.domain` attribute. + +## Quick start + +```yaml +appSync: + name: my-api + domain: + name: api.example.com + hostedZoneId: Z111111QQQQQQQ +``` + +## Configuration + +- `name`: Required. The fully qualified domain name to associate this API to. +- `certificateArn`: Optional. A valid certificate ARN for the domain name. See [Certificate](#certificate). +- `useCloudFormation`: Boolean. Optional. Whether to use CloudFormation or CLI commands to manage the domain. See [Using CloudFormation or CLI commands](#using-cloudformation-vs-the-cli-commands). Defaults to `true`. +- `retain`: Boolean, optional. Whether to retain the domain and domain association when they are removed from CloudFormation. Defaults to `false`. See [Ejecting from CloudFormation](#ejecting-from-cloudformation) +- `hostedZoneId`: Boolean, conditional. The Route53 hosted zone id where to create the certificate validation and/or AppSync Alias records. Required if `useCloudFormation` is `true` and `certificateArn` is not provided. +- `hostedZoneName`: The hosted zone name where to create the route53 Alias record. If `certificateArn` is provided, it takes precedence over `hostedZoneName`. +- `route53`: Boolean. Wether to create the Route53 Alias record for this domain. Set to `false` if you don't use Route53. Defaults to `true`. + +## Certificate + +If `useCloudFormation` is `true` and a valid `certificateArn` is not provided, a certificate will be created for the provided domain `name` using CloudFormation. You must provide the `hostedZoneId` +where the DNS validation records for the certificate will be created. + +⚠️ Any change that requires a change of certificate attached to the domain requires a replacement of the AppSync domain resource. CloudFormation will usually fail with the following error when that happens: + +```bash +CloudFormation cannot update a stack when a custom-named resource requires replacing. Rename api.example.com and update the stack again. +``` + +If `useCloudFormation` is `false`, when creating the domain with the `domain create` command, the Framework will try to find an existing certificate that +matches the given domain. If no valid certificate is found, an error will be thrown. No certificate will be auto-generated. + +## Using CloudFormation vs the CLI commands + +There are two ways to manage your custom domain: + +- using CloudFormation (default) +- using the CLI [commands](commands.md#domain) + +If `useCloudFormation` is set to `true`, the domain, domain association, and optionally, the domain certificate will be automatically created and managed by CloudFormation. However, in some cases you might not want that. + +For example, if you want to use blue/green deployments, you might need to associate APIs from different stacks to the same domain. In that case, the only way to do it is to use the CLI. + +For more information about managing domains with the CLI, see the [Commands](commands.md#domain) section. + +## Ejecting from CloudFormation + +If you started to manage your domain through CloudFormation and want to eject from it, follow the following steps: + +1. Set `retain` to `true` + +To avoid breaking your API if it is already on production, you first need to tell CloudFormation to retain the domain and any association with an existing API. For that, you can set the `retain` attribute to `true`. **You will then need to re-deploy to make sure that CloudFormation takes the change into account.** + +2. Set `useCloudFormation` to `false` + +You can now set `useCloudFormation` to `false` and **deploy one more time**. The domain and domain association resources will be removed from the CloudFormation template, but the resources will be retained (see point 1.) + +3. Manage your domain using the CLI + +You can now manage your domain using the CLI [commands](commands.md#domain) + +## Domain names per stage + +You can use different domains by stage easily thanks to [Serverless Framework Stage Parameters](https://www.serverless.com/framework/docs/guides/parameters) + +```yaml +params: + prod: + domain: api.example.com + domainCert: arn:aws:acm:us-east-1:123456789012:certificate/7e14a3b2-f7a5-4da5-8150-4a03ede7158c + + staging: + domain: qa.example.com + domainCert: arn:aws:acm:us-east-1:123456789012:certificate/61d7d798-d656-4630-9ff9-d77a7d616dbe + + default: + domain: ${sls:stage}.example.com + domainCert: arn:aws:acm:us-east-1:123456789012:certificate/44211071-e102-4bf4-b7b0-06d0b78cd667 + +appSync: + name: my-api + domain: + name: ${param:domain} + certificateArn: ${param:domainCert} +``` diff --git a/docs/sf/providers/aws/guide/appsync/dataSources.md b/docs/sf/providers/aws/guide/appsync/dataSources.md new file mode 100644 index 000000000..8f5ca1855 --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/dataSources.md @@ -0,0 +1,179 @@ + + +# DataSources + +All the DataSources in your AppSync API can be found in serverless.yml under the `appSync.dataSources` property. DataSources are defined as key-value objects, the key being the name of the DataSource. + +## DynamoDB + +### Quick start + +```yaml +appSync: + dataSources: + myTableDs: + type: AMAZON_DYNAMODB + description: 'My table' + config: + tableName: my-table +``` + +### config + +- `tableName`: the name of the DynamoDB table +- `region`: the region of the table. Defaults to the stack's region +- `useCallerCredentials`: Set to `true` to use AWS Identity and Access Management with this data source +- `serviceRoleArn`: The service role ARN for this DataSource. If not provided, a new one will be created. +- `iamRoleStatements`: Statements to use for the generated IAM Role. If not provided, default statements will be used. +- `versioned`: Set to `true` to use Conflict Detection and Resolution with this data source. +- `deltaSyncConfig`: + - `deltaSyncTableName`: The Delta Sync table name. + - `baseTableTTL`: The number of minutes that an Item is stored in the data source. Defaults to `43200` + - `deltaSyncTableTTL`: The number of minutes that a Delta Sync log entry is stored in the Delta Sync table. Defaults to `1440` + +## AWS Lambda + +### Quick start + +```yaml +appSync: + dataSources: + myFunction: + type: 'AWS_LAMBDA' + config: + function: + timeout: 30 + handler: 'functions/myFunction.handler' +``` + +### config + +- `serviceRoleArn`: The service role ARN for this DataSource. If not provided, a new one will be created. +- `iamRoleStatements`: Statements to use for the generated IAM Role. If not provided, default statements will be used. +- `function`: A Lambda function definition as you would define it under the `functions` section of your `serverless.yml` file. +- `functionName`: The name of the function as defined under the `functions` section of the `serverless.yml` file +- `functionAlias`: A specific function alias to use +- `functionArn`: The function ARN to use for this DataSource. + +## OpenSearch (ElasticSearch) + +### Quick start + +```yaml +appSync: + dataSources: + search: + type: 'AMAZON_OPENSEARCH_SERVICE' + config: + endpoint: https://abcdefgh.us-east-1.es.amazonaws.com +``` + +### config + +- `endpoint`: The endpoint url to the OpenSearch domain +- `region`: The region of the OpenSearch domain. Defaults to the stack's region. +- `serviceRoleArn`: The service role ARN for this DataSource. If not provided, a new one will be created. +- `iamRoleStatements`: Statements to use for the generated IAM Role. If not provided, default statements will be used. + +## HTTP + +### Quick start + +```yaml +appSync: + dataSources: + api: + type: 'HTTP' + config: + endpoint: https://api.example.com +``` + +### config + +- `endpoint`: The url of the HTTP endpoint. +- `serviceRoleArn`: The service role ARN for this DataSource. If not provided, a new one will be created. +- `iamRoleStatements`: Statements to use for the generated IAM Role. If not provided, default statements will be used. +- `authorizationConfig`: + - `authorizationType`: The authorization type that the HTTP endpoint requires. + - `AWS_IAM`: The authorization type is Signature Version 4 (SigV4). + - `awsIamConfig`: + - `signingRegion`: The signing Region for AWS Identity and Access Management authorization. Defaults to the region of the stack. + - `signingServiceName`: The signing service name for AWS Identity and Access Management authorization. + +## Relational Database + +### Quick start + +```yaml +appSync: + dataSources: + myDatabase: + type: 'RELATIONAL_DATABASE' + config: + databaseName: myDatabase + dbClusterIdentifier: Ref: RDSCluster + awsSecretStoreArn: Ref: RDSClusterSecret + serviceRoleArn: !GetAtt RelationalDbServiceRole.Arn +``` + +### config + +- `databaseName`: The name of the database +- `region`: The region of the RDS HTTP endpoint. Defaults to the region of the stack. +- `awsSecretStoreArn`: The ARN for database credentials stored in AWS Secrets Manager. +- `dbClusterIdentifier`: Amazon RDS cluster Amazon Resource Name (ARN). +- `schema`: Logical schema name. +- `serviceRoleArn`: The service role ARN for this DataSource. If not provided, a new one will be created. +- `iamRoleStatements`: Statements to use for the generated IAM Role. If not provided, default statements will be used. + +## EventBridge + +```yaml +appSync: + dataSources: + myEventBus: + type: 'AMAZON_EVENTBRIDGE' + config: + eventBusArn: !GetAtt MyEventBus.Arn +``` + +### config + +- `eventBusArn`: The ARN of the event bus + +## NONE + +```yaml +appSync: + dataSources: + api: + type: 'NONE' +``` + +# Organize your data sources + +You can define your data sources into several files for organizational reasons. You can pass each file into the `dataSources` attribute as an array. + +```yaml +dataSources: + - ${file(appsync/dataSources/users.yml)} + - ${file(appsync/dataSources/posts.yml)} +``` diff --git a/docs/sf/providers/aws/guide/appsync/general-config.md b/docs/sf/providers/aws/guide/appsync/general-config.md new file mode 100644 index 000000000..92d2cfa3a --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/general-config.md @@ -0,0 +1,222 @@ + + +# General configuration + +## Quick start + +```yaml +service: my-app + +provider: + name: aws + +appSync: + name: my-api + + authentication: + type: API_KEY + + apiKeys: + - name: myKey + expiresAfter: 1M + + dataSources: + my-table: + type: AMAZON_DYNAMODB + description: 'My table' + config: + tableName: my-table + + resolvers: + Query.user: + dataSource: my-table +``` + +## Configuration + +- `name`: The name of this AppSync API +- `schema`: The filename of the schema file. Defaults to `schema.graphql`. [Read more](#Schema) +- `authentication`: See [Authentication](authentication.md) +- `additionalAuthentications`: See [Authentication](authentication.md) +- `apiKeys`: See [API Keys](API-keys.md) +- `domain`: See [Custom domains](custom-domain.md) +- `dataSources`: See [DataSources](dataSources.md) +- `resolvers`: See [Resolvers](resolvers.md) +- `pipelineFunctions`: See [Pipeline functions](pipeline-functions.md) +- `environment`: A list of environment variables for the API. See [Official Documentation](https://docs.aws.amazon.com/appsync/latest/devguide/environment-variables.html) +- `caching`: See [Cacing](caching.md) +- `waf`: See [Web Application Firefall](WAF.md) +- `logging`: See [Logging](#Logging) +- `xrayEnabled`: Boolean. Enable or disable X-Ray tracing. +- `visibility`: Optional. `GLOBAL` or `PRIVATE`. **Changing this value requires the replacement of the API.** +- `introspection`: Boolean. Whether to enable introspection or not. Defaults to `true`. +- `queryDepthLimit`: Optional. The maximum amount of nested level allowed per query. Must be between 1 and 75. If not specified: unlimited. +- `resolverCountLimit`: Optional. The maximum number of resolvers a query can process. Must be between 1 and 1000. If not specified: unlimited. +- `tags`: A key-value pair for tagging this AppSync API +- `esbuild`: Custom esbuild options, or `false` See [Esbuild](#Esbuild) + +## Schema + +There are different ways to define your schema. By default the schema is found in the `schema.graphql` file. The path of the file is relative to the service directory (where your `serverless.yml` file is). + +```yaml +appSync: + name: my-api + schema: 'mySchema.graphql' +``` + +### Multiple files + +You can specify more than one file as (an array). This is useful if you want to organize your schema into several files. + +```yaml +appSync: + name: my-api + schema: + - 'schemas/user.graphql' + - 'schemas/posts.graphql' +``` + +You can also specify glob expressions to avoid specifying each individual file. + +```yaml +appSync: + name: my-api + schema: 'schemas/*.graphql' # include all graphql files in the `schemas` directory +``` + +### Schema stitching + +All the schema files will be merged together before the schema is sent to AppSync. If types are present (extended) in several files, you will need to use [Object extension](https://spec.graphql.org/October2021/#sec-Object-Extensions) + +```graphql +# base.graphql + +# You must create the types before you can extend them. +type Query +type Mutation +``` + +```graphql +# users.graphql + +extend type Query { + getUser(id: ID!): User! +} + +extend type Mutation { + createUser(user: UserInput!): User! +} + +type User { + id: ID! + name: String! +} +``` + +```graphql +# posts.graphql + +extend type Query { + getPost(id: ID!): Post! +} + +extend type Mutation { + createPost(post: PostInput!): Post! +} + +type Post { + id: ID! + title: String + author: User! +} +``` + +This will result into the following schema: + +```graphql +type Query { + getUser(id: ID!): User! + getPost(id: ID!): Post! +} + +type Mutation { + createUser(user: UserInput!): User! + createPost(post: PostInput!): Post! +} + +type User { + id: ID! + name: String! +} + +type Post { + id: ID! + title: String + author: User! +} +``` + +### Limitations and compatibility + +AppSync is currently using an older version of the [Graphql Specs](https://spec.graphql.org/). +The Framework intends to use modern schemas for future-proofing. Incompatibilities will either be dropped or attempted to be fixed. + +**Descriptions** + +[Descriptions](https://spec.graphql.org/October2021/#sec-Descriptions) with three double quotes (`"""`) are not supported by AppSync and will be removed. + +Old-style descriptions (using `#`) are supported by AppSync but will be removed by the [stitching procedure](#schema-stitching) which does not support them\*. Comments are also not supported on [enums](https://spec.graphql.org/October2021/#sec-Enums) by AppSync. + +\* If you want to retain `#` comments, the workaround is to skip schema stitching by putting your whole schema into one single file. + +**Multiple interfaces** + +Types can implement multiple [interfaces](https://spec.graphql.org/October2021/#sec-Interfaces) using an ampersand `&` in GraphQL, but AppSync uses the old comma (`,`) separator. `&` is the only separator supported, but it will automatically be replaced with a `,`. + +## Logging + +```yaml +appSync: + name: my-api + logging: + level: ERROR + retentionInDays: 14 +``` + +- `level`: `ERROR`, `NONE`, `INFO`, `DEBUG` or `ALL` +- `enabled`: Boolean, Optional. Defaults to `true` when `logging` is present. +- `excludeVerboseContent`: Boolean, Optional. Exclude or not verbose content (headers, response headers, context, and evaluated mapping templates), regardless of field logging level. Defaults to `false`. +- `retentionInDays`: Optional. Number of days to retain the logs. Defaults to [`provider.logRetentionInDays`](https://www.serverless.com/framework/docs/providers/aws/guide/serverless.yml#general-function-settings). +- `roleArn`: Optional. The role ARN to use for AppSync to write into CloudWatch. If not specified, a new role is created by default. + +## Esbuild + +By default, the Framework uses esbuild in order to bundle Javascript resolvers. TypeScript files are also transpiled into compatible JavaScript. This option allows you to pass custom options that must be passed to the esbuild command. + +⚠️ Use these options carefully. Some options are not compatible with AWS AppSync. For more details about using esbuild with AppSync, see the [official guidelines](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-reference-overview-js.html#additional-utilities) + +Set this option to `false` to disable esbuild completely. You code will be sent as-is to AppSync. + +Example: + +Override the target and disable sourcemap. + +```yml +appSync: + esbuild: + target: 'es2020', + sourcemap: false +``` diff --git a/docs/sf/providers/aws/guide/appsync/index.md b/docs/sf/providers/aws/guide/appsync/index.md new file mode 100644 index 000000000..e9706252b --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/index.md @@ -0,0 +1,103 @@ + + +# AppSync + +Built-in support for AWS AppSync in the Serverless Framework. Deploy GraphQL APIs with resolvers, data sources, authentication, and more. + +Huge thanks to the community contributors of the original [`serverless-appsync-plugin`](https://github.com/sid88in/serverless-appsync-plugin) - these capabilities now ship in the Framework by default. + +## Migration from the community plugin + +Migrating from the community plugin? This feature is included by default in the Framework. There is nothing to install. + +Remove it from the `plugins` section of `serverless.yml` and from your dependencies. Keep your existing `appSync` configuration; the built-in integration continues to honor it. + +## Quick start + +```yaml +service: my-app + +provider: + name: aws + +appSync: + name: my-api + + authentication: + type: API_KEY + + apiKeys: + - name: myKey + expiresAfter: 1M + + dataSources: + my-table: + type: AMAZON_DYNAMODB + description: 'My table' + config: + tableName: my-table + + resolvers: + Query.user: + dataSource: my-table +``` + +## Configuration + +- [General Configuration](general-config.md) +- [DataSources](dataSources.md) +- [Resolvers](resolvers.md) +- [Pipeline Functions](pipeline-functions.md) +- [Authentication](authentication.md) +- [API Keys](API-keys.md) +- [Custom Domain](custom-domain.md) +- [Caching](caching.md) +- [Delta Sync](syncConfig.md) +- [Web Application Firewall (WAF)](WAF.md) + +## CLI + +This integration adds CLI commands. See [CLI Commands](commands.md). + +## Variables + +Access AppSync values in your configuration: + +- `${appsync:id}`: The id of the AppSync API +- `${appsync:url}`: The URL of the AppSync API +- `${appsync:arn}`: The ARN of the AppSync API +- `${appsync:apiKey.[NAME]}`: An API key + +Example: + +```yaml +provider: + environment: + APPSYNC_ID: ${appsync:id} + APPSYNC_ARN: ${appsync:arn} + APPSYNC_URL: ${appsync:url} + APPSYNC_API_KEY: ${appsync:apiKey.myKey} + +appSync: + name: my-api + + authentication: + type: API_KEY + + apiKeys: + - name: myKey +``` diff --git a/docs/sf/providers/aws/guide/appsync/pipeline-functions.md b/docs/sf/providers/aws/guide/appsync/pipeline-functions.md new file mode 100644 index 000000000..89b7d4a17 --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/pipeline-functions.md @@ -0,0 +1,63 @@ + + +# Pipeline functions + +When you use `PIPELINE` [resolvers](resolvers.md), you will also need to define the used pipeline functions. You can do so under the `appSync.pipelineFunctions` attribute. + +It's a key-value pair object whose key is the name of the function and the value is its configuration. + +## Quick start + +```yaml +appSync: + pipelineFunctions: + myFunction: + dataSource: myDataSource + code: myFunction.js +``` + +## Configuration + +- `dataSource`: The name of the dataSource to use. +- `description`: An optional description for this pipeline function. +- `code`: The path to the JS resolver handler file, relative to `serverless.yml`. +- `request`: The path to the VTL request mapping template file, relative to `serverless.yml`. +- `response`: The path to the VTL response mapping template file, relative to `serverless.yml`. +- `maxBatchSize`: The maximum [batch size](https://aws.amazon.com/blogs/mobile/introducing-configurable-batching-size-for-aws-appsync-lambda-resolvers/) to use (only available for AWS Lambda DataSources) +- `substitutions`: See [Variable Substitutions](substitutions.md) +- `sync`: [See SyncConfig](syncConfig.md) + +## JavaScript vs VTL vs Direct Lambda + +When `code` is specified, the JavaScript runtime is used. When `request` and/or `response` are specified, the VTL runtime is used. + +To use [direct lambda](https://docs.aws.amazon.com/appsync/latest/devguide/direct-lambda-reference.html), don't specify anything (only works with Lambda function data sources). + +## Inline DataSources + +Just like with `UNIT` resolvers, you can [define the dataSource inline](resolvers.md#inline-datasources) in pipeline functions. + +```yaml +appSync: + pipelineFunctions: + myFunction: + dataSource: + type: 'AWS_LAMBDA' + config: + function: + timeout: 30 + handler: 'functions/myFunction.handler' +``` diff --git a/docs/sf/providers/aws/guide/appsync/resolvers.md b/docs/sf/providers/aws/guide/appsync/resolvers.md new file mode 100644 index 000000000..c26b95fa9 --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/resolvers.md @@ -0,0 +1,230 @@ + + +# Resolvers + +All the Resolvers in your AppSync API can be found in serverless.yml under the `appSync.resolvers` property. + +Resolvers are defined using key-value pairs where the key can either be an arbitrary name for the resolver or the `type` and `field` in the schema it is attached to separated with a dot (`.`), and the value is the configuration of the resolver. + +## Quick start + +```yaml +appSync: + resolvers: + Query.user: + dataSource: myDataSource + + getPosts: + type: Query + field: getPosts + dataSource: myDataSource +``` + +## Configuration + +- `type`: The Type in the schema this resolver is attached to. Optional if specified in the configuration key. +- `field`: The Field in the schema this resolver is attached to. Optional if specified in the configuration key. +- `kind`: The kind of resolver. Can be `UNIT` or `PIPELINE` ([see below](#PIPELINE-resolvers)). Defaults to `PIPELINE` +- `dataSource`: The name of the [dataSource](dataSources.md) this resolver uses. +- `functions`: For pipeline resolvers ([see below](#PIPELINE-resolvers)) the array of functions to run in sequence +- `maxBatchSize`: The maximum [batch size](https://aws.amazon.com/blogs/mobile/introducing-configurable-batching-size-for-aws-appsync-lambda-resolvers/) to use (only available for AWS Lambda DataSources) +- `code`: The path of the JavaScript resolver handler file, relative to `serverless.yml`. If not specified, a [minimalistic default](#javascript-vs-vtl) is used. +- `request`: The path to the VTL request mapping template file, relative to `serverless.yml`. +- `response`: The path to the VTL response mapping template file, relative to `serverless.yml`. +- `substitutions`: See [Variable Substitutions](substitutions.md). Deprecated: Use [environment variables](./general-config.md) instead. +- `caching`: [See below](#Caching) +- `sync`: [See SyncConfig](syncConfig.md) + +## JavaScript, VTL, or Direct Lambda + +When `code` is specified, the JavaScript runtime is used. + +When `request` and/or `response` are specified, the VTL runtime is used. + +For [direct lambda](https://docs.aws.amazon.com/appsync/latest/devguide/direct-lambda-reference.html), set `kind` to `UNIT` and don't specify `request`, `response` or `code`. This only works with Lambda function data sources. + +If nothing is specified, by default, the resolver is a PIPELINE JavaScript resolver, and the following minimalistic code is used for the `before` and `after` handlers. + +```js +export function request() { + return {}; +} + +export function response(ctx) { + return ctx.prev.result; +} +``` + +Example of a UNIT JavaScript resolver. + +```yaml +appSync: + resolvers: + Query.user: + kind: UNIT + dataSource: myDataSource + code: getUser.js +``` + +## Bundling + +AppSync requires resolvers to be bundled in one single file. By default, the Framework bundles your code with [esbuild](https://esbuild.github.io/), using the given path as the entry point. + +This means that you can import external libraries and utilities. e.g. + +```js +import { Context, util } from '@aws-appsync/utils'; +import { generateUpdateExpressions, updateItem } from '../lib/helpers'; + +export function request(ctx) { + const { id, ...post } = ctx.args.post; + + const item = updateItem(post); + + return { + operation: 'UpdateItem', + key: { + id: util.dynamodb.toDynamoDB(id), + }, + update: generateUpdateExpressions(item), + condition: { + expression: 'attribute_exists(#id)', + expressionNames: { + '#id': 'id', + }, + }, + }; +} + +export function response(ctx: Context) { + return ctx.result; +} +``` + +For more information, also see the [esbuild option](./general-config.md#Esbuild). + +## TypeScript support + +You can write JS resolver in TypeScript. Resolver files with the `.ts` extension are automatically transpiled and bundled using esbuild. + +```yaml +resolvers: + Query.user: + kind: UNIT + dataSource: 'users' + code: 'getUser.ts' +``` + +```ts +// getUser.ts +import { Context, util } from '@aws-appsync/utils'; + +export function request(ctx: Context) { + const { + args: { id }, + } = ctx; + return { + operation: 'GetItem', + key: util.dynamodb.toMapValues({ id }), + }; +} + +export function response(ctx: Context) { + return ctx.result; +} +``` + +For more information, also see the [esbuild option](./general-config.md#Esbuild). + +## PIPELINE resolvers + +When `kind` is `PIPELINE`, you can specify the [pipeline function](pipeline-functions.md) names to use: + +```yaml +appSync: + pipelineFunctions: + function1: + dataSource: myDataSource + function2: + dataSource: myDataSource + + resolvers: + Query.user: + functions: + - function1 + - function2 +``` + +## Inline DataSources + +If a [DataSource](dataSources.md) is only used in one single resolver, you can also define it inline in the resolver configuration. This is often the case for Lambda resolvers. + +You can even also define the Lambda function definition inline under the dataSource definition. This helps keep everything in one single place! + +```yaml +appSync: + resolvers: + Query.user: + kind: UNIT + dataSource: + type: 'AWS_LAMBDA' + config: + function: + timeout: 30 + handler: 'functions/getUser.handler' +``` + +## Inline function definitions + +If a [Pipeline function](pipeline-functions.md) is only used in a single resolver, you can also define it inline in the resolver configuration. + +```yaml +appSync: + resolvers: + Query.user: + functions: + - dataSource: 'users' + code: 'getUser.js' +``` + +## Caching + +```yaml +Query.user: + dataSource: myDataSource + caching: + ttl: 60 + keys: + - '$context.arguments.id' +``` + +You can either pass `true` which will use the global TTL (See the [global caching configuration](caching.md)) and no `keys`. + +You can also customize each resolver using the following config: + +- `ttl`: The TTL of the cache for this resolver in seconds +- `keys`: An array of keys to use for the cache. + +# Organize your resolvers + +You can define your data sources into several files for organizational reasons. You can pass each file into the `dataSources` attribute as an array. + +```yaml +resolvers: + - ${file(appsync/resolvers/users.yml)} + - ${file(appsync/resolvers/posts.yml)} +``` diff --git a/docs/sf/providers/aws/guide/appsync/syncConfig.md b/docs/sf/providers/aws/guide/appsync/syncConfig.md new file mode 100644 index 000000000..67eec3932 --- /dev/null +++ b/docs/sf/providers/aws/guide/appsync/syncConfig.md @@ -0,0 +1,39 @@ + + +# Sync Config + +[Delta Sync](https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-delta-sync.html) configuration for [resolvers](resolvers.md) and [pipeline functions](pipeline-functions.md). + +## Quick start + +```yaml +Query.user: + dataSource: my-table + sync: + conflictDetection: 'VERSION' + conflictHandler: 'LAMBDA' + function: + timeout: 30 + handler: 'functions/userSync.handler' +``` + +- `conflictDetection`: `VERSION` or `NONE`. Defaults to `VERSION` +- `conflictHandler`: When `conflictDetection` is `VERSION`, configures how conflict resolution happens. `OPTIMISTIC_CONCURRENCY`, `AUTOMERGE` or `LAMBDA`. Defaults to `OPTIMISTIC_CONCURRENCY` +- `function`: When `conflictHandler` is `LAMBDA`, a Lambda function definition as you would define it under the `functions` section of your `serverless.yml` file. +- `functionName`: When `conflictHandler` is `LAMBDA`, the name of the function as defined under the `functions` section of the `serverless.yml` file +- `functionAlias`: When `conflictHandler` is `LAMBDA`, a specific function alias to use. +- `functionArn`: When `conflictHandler` is `LAMBDA`, the function ARN to use. diff --git a/package-lock.json b/package-lock.json index d7de99ad9..529cdf05f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -800,6 +800,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.958.0.tgz", "integrity": "sha512-R3G5cxf3fsL0CEcTbY1VkSwU1FJtImrhA5I9Eepd8nEO6isZ6C99qVKZtDG9eG7qVNK6zTzUigXac/GFrn6hYA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1317,6 +1318,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.958.0.tgz", "integrity": "sha512-ol8Sw37AToBWb6PjRuT/Wu40SrrZSA0N4F7U3yTkjUNX0lirfO1VFLZ0hZtZplVJv8GNPITbiczxQ8VjxESXxg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -2532,6 +2534,7 @@ "version": "7.26.10", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -3449,11 +3452,45 @@ "version": "8.57.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@graphql-tools/merge": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.4.2.tgz", + "integrity": "sha512-XbrHAaj8yDuINph+sAfuq3QCZ/tKblrTLOpirK0+CAgNlZUCHs0Fa+xtMUURgwCVThLle1AF7svJCxFizygLsw==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^9.2.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-9.2.1.tgz", + "integrity": "sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -3562,7 +3599,6 @@ "version": "0.11.14", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", "debug": "^4.3.1", @@ -3865,6 +3901,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -4494,6 +4531,7 @@ "node_modules/@octokit/core": { "version": "6.1.4", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.1.2", @@ -5976,6 +6014,7 @@ "version": "8.14.1", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6064,7 +6103,6 @@ }, "node_modules/ansi-escapes": { "version": "4.3.2", - "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -6078,7 +6116,6 @@ }, "node_modules/ansi-escapes/node_modules/type-fest": { "version": "0.21.3", - "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -7124,6 +7161,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -7189,7 +7227,6 @@ "version": "3.3.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" }, @@ -7201,7 +7238,6 @@ "version": "5.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "semver": "^7.0.0" } @@ -8857,6 +8893,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9177,6 +9214,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9847,6 +9885,16 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "license": "MIT", @@ -9877,7 +9925,6 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10222,7 +10269,6 @@ "version": "3.2.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "builtin-modules": "^3.3.0" }, @@ -10714,6 +10760,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11925,6 +11972,15 @@ "es5-ext": "~0.10.2" } }, + "node_modules/luxon": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", + "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "dev": true, @@ -14261,7 +14317,6 @@ }, "node_modules/supports-color": { "version": "7.2.0", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -14270,6 +14325,19 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "dev": true, @@ -14502,6 +14570,22 @@ "node": ">=6" } }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "dev": true, @@ -15283,6 +15367,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -15516,6 +15601,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -15744,12 +15830,14 @@ "@aws-sdk/client-sts": "3.958.0", "@aws-sdk/credential-providers": "3.958.0", "@aws-sdk/lib-storage": "3.958.0", + "@graphql-tools/merge": "^8.3.12", "@iarna/toml": "^2.2.5", "@serverlessinc/sf-core": "*", "@smithy/node-http-handler": "^4.4.5", "@smithy/util-retry": "^4.2.6", "adm-zip": "^0.5.16", "ajv": "8.17.1", + "ajv-errors": "^3.0.0", "ajv-formats": "2.1.1", "appdirectory": "^0.1.0", "archiver": "^7.0.1", @@ -15768,6 +15856,7 @@ "get-stdin": "^9.0.0", "glob": "^10.5.0", "globby": "^11.1.0", + "graphql": "^16.6.0", "https-proxy-agent": "^7.0.6", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", @@ -15777,6 +15866,7 @@ "jszip": "^3.10.1", "lodash": "^4.17.21", "lodash.uniqby": "^4.7.0", + "luxon": "^2.5.0", "memoizee": "^0.4.17", "micromatch": "^4.0.8", "object-hash": "^3.0.0", @@ -15792,6 +15882,7 @@ "sha256-file": "^1.0.0", "shell-quote": "^1.8.3", "strip-ansi": "^7.1.2", + "terminal-link": "^2.1.1", "timers-ext": "^0.1.8", "toml": "^3.0.0", "tsx": "^4.21.0", @@ -15841,6 +15932,7 @@ "packages/serverless/node_modules/ajv": { "version": "8.17.1", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15852,6 +15944,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "packages/serverless/node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, "packages/serverless/node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -16166,6 +16267,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", diff --git a/package.json b/package.json index 40ae5250d..4d049f444 100644 --- a/package.json +++ b/package.json @@ -32,4 +32,4 @@ "overrides": { "help-me": "^5.0.0" } -} +} \ No newline at end of file diff --git a/packages/serverless/lib/classes/plugin-manager.js b/packages/serverless/lib/classes/plugin-manager.js index eedd54772..603def7ed 100644 --- a/packages/serverless/lib/classes/plugin-manager.js +++ b/packages/serverless/lib/classes/plugin-manager.js @@ -1,6 +1,6 @@ import path from 'path' import _ from 'lodash' -import { log, getPluginWriters } from '@serverless/util' +import { getPluginWriters, log } from '@serverless/util' import { require as tsxRequire } from 'tsx/cjs/api' import ServerlessError from '../serverless-error.js' import renderCommandHelp from '../cli/render-help/command.js' @@ -66,6 +66,7 @@ import pluginAwsAlerts from '../plugins/aws/alerts/index.js' import pluginAwsDomains from '../plugins/aws/domains/index.js' import pluginAxiom from '../plugins/observability/axiom/index.js' import pluginPythonRequirements from '../plugins/python/index.js' +import pluginAwsAppsync from '../plugins/aws/appsync/index.js' import { createRequire } from 'module' const internalPlugins = [ @@ -128,6 +129,7 @@ const internalPlugins = [ pluginAwsAlerts, pluginAwsDomains, pluginPythonRequirements, + pluginAwsAppsync, ] // Describe core-bundled plugins so we can coordinate loading with any legacy entries in `plugins:` @@ -161,6 +163,18 @@ const bundledPluginDefinitions = [ }) : true, }, + { + module: pluginAwsAppsync, + externalNames: ['serverless-appsync-plugin'], + allowCommunityOverride: true, + shouldLoad: (serverless, context = {}) => + pluginAwsAppsync.shouldLoad + ? pluginAwsAppsync.shouldLoad({ + serverless, + ...context, + }) + : true, + }, ] const findBundledPluginByModule = (module) => @@ -402,7 +416,16 @@ class PluginManager { return true }) - .forEach((Plugin) => this.addPlugin(Plugin)) + .forEach((Plugin) => { + const definition = findBundledPluginByModule(Plugin) + if (definition) { + isRegisteringExternalPlugins = true + this.addPlugin(Plugin) + isRegisteringExternalPlugins = false + } else { + this.addPlugin(Plugin) + } + }) // Load External Plugins isRegisteringExternalPlugins = true diff --git a/packages/serverless/lib/cli/render-help/general.js b/packages/serverless/lib/cli/render-help/general.js index 7f90d020c..1891a0b10 100644 --- a/packages/serverless/lib/cli/render-help/general.js +++ b/packages/serverless/lib/cli/render-help/general.js @@ -66,6 +66,7 @@ ${style.aside('Options')}`) if (loadedPlugins.size) { if (extensionCommandsSchema.size) { for (const [plugin, pluginCommandsSchema] of extensionCommandsSchema) { + writeText() writeText(null, style.aside(plugin.constructor.name)) for (const [commandName, commandSchema] of pluginCommandsSchema) { writeText(generateCommandUsage(commandName, commandSchema)) diff --git a/packages/serverless/lib/plugins/aws/appsync/get-appsync-config.js b/packages/serverless/lib/plugins/aws/appsync/get-appsync-config.js new file mode 100644 index 000000000..108ac8431 --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/get-appsync-config.js @@ -0,0 +1,143 @@ +import _ from 'lodash' +const { forEach, merge } = _ + +const flattenMaps = (input) => { + if (Array.isArray(input)) { + return merge({}, ...input) + } else { + return merge({}, input) + } +} + +export const isUnitResolver = (resolver) => { + return resolver.kind === 'UNIT' +} + +export const isPipelineResolver = (resolver) => { + return !resolver.kind || resolver.kind === 'PIPELINE' +} + +const toResourceName = (name) => { + return name.replace(/[^a-z_]/i, '_') +} + +export const getAppSyncConfig = (config) => { + const schema = Array.isArray(config.schema) + ? config.schema + : [config.schema || 'schema.graphql'] + + const dataSources = {} + const resolvers = {} + const pipelineFunctions = {} + + forEach(flattenMaps(config.dataSources), (ds, name) => { + dataSources[name] = { + ...ds, + name, + } + }) + + forEach(flattenMaps(config.resolvers), (resolver, typeAndField) => { + const [type, field] = typeAndField.split('.') + + if (typeof resolver === 'string') { + resolvers[typeAndField] = { + dataSource: resolver, + kind: 'UNIT', + type, + field, + } + return + } + + if (isUnitResolver(resolver) && typeof resolver.dataSource === 'object') { + const name = typeAndField.replace(/[^a-z_]/i, '_') + dataSources[name] = { + ...resolver.dataSource, + name, + } + } + + resolvers[typeAndField] = { + ...resolver, + type: resolver.type || type, + field: resolver.field || field, + ...(isUnitResolver(resolver) + ? { + kind: 'UNIT', + dataSource: + typeof resolver.dataSource === 'object' + ? typeAndField.replace(/[^a-z_]/i, '_') + : resolver.dataSource, + } + : { + kind: 'PIPELINE', + functions: resolver.functions.map((f, index) => { + if (typeof f === 'string') { + return f + } + + const name = `${toResourceName(typeAndField)}_${index}` + pipelineFunctions[name] = { + ...f, + name, + dataSource: + typeof f.dataSource === 'string' ? f.dataSource : name, + } + if (typeof f.dataSource === 'object') { + dataSources[name] = { + ...f.dataSource, + name, + } + } + return name + }), + }), + } + }) + + forEach(flattenMaps(config.pipelineFunctions), (func, name) => { + if (typeof func.dataSource === 'object') { + dataSources[name] = { + ...func.dataSource, + name, + } + } + + pipelineFunctions[name] = { + ...func, + dataSource: typeof func.dataSource === 'string' ? func.dataSource : name, + name, + } + }) + + const additionalAuthentications = config.additionalAuthentications || [] + + let apiKeys + if ( + config.authentication.type === 'API_KEY' || + additionalAuthentications.some((auth) => auth.type === 'API_KEY') + ) { + const inputKeys = config.apiKeys || [] + + apiKeys = inputKeys.reduce((acc, key) => { + if (typeof key === 'string') { + acc[key] = { name: key } + } else { + acc[key.name] = key + } + + return acc + }, {}) + } + + return { + ...config, + additionalAuthentications, + apiKeys, + schema, + dataSources, + resolvers, + pipelineFunctions, + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/get-stack-value.js b/packages/serverless/lib/plugins/aws/appsync/get-stack-value.js new file mode 100644 index 000000000..60ff14bfc --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/get-stack-value.js @@ -0,0 +1,28 @@ +function getServerlessStackName(provider) { + return provider.naming.getStackName() +} + +function getValue(provider, value, name) { + if (typeof value === 'string') { + return Promise.resolve(value) + } else if (value && typeof value.Ref === 'string') { + return provider + .request('CloudFormation', 'listStackResources', { + StackName: getServerlessStackName(provider), + }) + .then((result) => { + const resource = result.StackResourceSummaries.find( + (r) => r.LogicalResourceId === value.Ref, + ) + if (!resource) { + throw new Error(`${name}: Ref "${value.Ref} not found`) + } + + return resource.PhysicalResourceId + }) + } + + return Promise.reject(new Error(`${value} is not a valid ${name}`)) +} + +export { getServerlessStackName, getValue } diff --git a/packages/serverless/lib/plugins/aws/appsync/index.js b/packages/serverless/lib/plugins/aws/appsync/index.js new file mode 100644 index 000000000..33d1760e7 --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/index.js @@ -0,0 +1,971 @@ +import _ from 'lodash' +const { forEach, last, merge } = _ +import { getAppSyncConfig } from './get-appsync-config.js' +import { GraphQLError } from 'graphql' +import { DateTime } from 'luxon' +import path from 'path' +import open from 'open' +import fs from 'fs' +import terminalLink from 'terminal-link' +import { AppSyncValidationError, validateConfig } from './validation.js' +import { + confirmAction, + getHostedZoneName, + getWildCardDomainName, + parseDateTimeOrDuration, + wait, +} from './utils.js' +import { Api } from './resources/Api.js' +import { Naming } from './resources/Naming.js' + +const CONSOLE_BASE_URL = 'https://console.aws.amazon.com' + +class ServerlessAppsyncPlugin { + static shouldLoad({ serverless }) { + const appSyncConfig = serverless?.configurationInput?.appSync + if (!appSyncConfig) { + return false + } + + return true + } + + constructor(serverless, options, utils) { + this.gatheredData = { + apis: [], + apiKeys: [], + } + this.serverless = serverless + this.options = options + this.provider = this.serverless.getProvider('aws') + this.utils = utils + + // The Serverless Framework now uses the same AJV version, but we keep + // custom validation in `validateConfig()` for better AppSync-specific + // error messages. For SF, just validate that `appSync` is an object. + this.serverless.configSchemaHandler.defineTopLevelProperty('appSync', { + type: 'object', + }) + + this.configurationVariablesSources = { + appsync: { + resolve: this.resolveVariable.bind(this), + }, + } + + this.commands = { + appsync: { + usage: 'Manage the AppSync API', + commands: { + 'validate-schema': { + usage: 'Validate the graphql schema', + lifecycleEvents: ['run'], + }, + 'get-introspection': { + usage: "Get the API's introspection schema", + lifecycleEvents: ['run'], + options: { + format: { + usage: + 'Specify the output format (JSON or SDL). Default: `JSON`', + shortcut: 'f', + required: false, + type: 'string', + }, + output: { + usage: 'Output to a file. If not specified, writes to stdout', + shortcut: 'o', + required: false, + type: 'string', + }, + }, + }, + 'flush-cache': { + usage: 'Flushes the cache of the API.', + lifecycleEvents: ['run'], + }, + console: { + usage: 'Open the AppSync AWS console', + lifecycleEvents: ['run'], + }, + cloudwatch: { + usage: 'Open the CloudWatch AWS console', + lifecycleEvents: ['run'], + }, + logs: { + usage: 'Output the logs of the AppSync API to stdout', + lifecycleEvents: ['run'], + options: { + startTime: { + usage: 'Starting time. Default: 10m (10 minutes ago)', + required: false, + type: 'string', + }, + tail: { + usage: 'Tail the log output', + shortcut: 't', + required: false, + type: 'boolean', + }, + interval: { + usage: 'Tail polling interval in milliseconds. Default: `1000`', + shortcut: 'i', + required: false, + type: 'string', + }, + filter: { + usage: 'A filter pattern to apply to the logs', + shortcut: 'f', + required: false, + type: 'string', + }, + }, + }, + domain: { + usage: 'Manage the domain for this AppSync API', + commands: { + create: { + usage: 'Create the domain in AppSync', + lifecycleEvents: ['run'], + options: { + quiet: { + usage: "Don't return an error if the domain already exists", + shortcut: 'q', + required: false, + type: 'boolean', + }, + yes: { + usage: 'Automatic yes to prompts', + shortcut: 'y', + required: false, + type: 'boolean', + }, + }, + }, + delete: { + usage: 'Delete the domain from AppSync', + lifecycleEvents: ['run'], + options: { + quiet: { + usage: "Don't return an error if the domain does not exist", + shortcut: 'q', + required: false, + type: 'boolean', + }, + yes: { + usage: 'Automatic yes to prompts', + shortcut: 'y', + required: false, + type: 'boolean', + }, + }, + }, + 'create-record': { + usage: 'Create the Alias record for this domain in Route53', + lifecycleEvents: ['run'], + options: { + quiet: { + usage: "Don't return an error if the record already exists", + shortcut: 'q', + required: false, + type: 'boolean', + }, + yes: { + usage: 'Automatic yes to prompts', + shortcut: 'y', + required: false, + type: 'boolean', + }, + }, + }, + 'delete-record': { + usage: 'Deletes the Alias record for this domain from Route53', + lifecycleEvents: ['run'], + options: { + quiet: { + usage: "Don't return an error if the record does not exist", + shortcut: 'q', + required: false, + type: 'boolean', + }, + yes: { + usage: 'Automatic yes to prompts', + shortcut: 'y', + required: false, + type: 'boolean', + }, + }, + }, + assoc: { + usage: 'Associate this AppSync API with the domain', + lifecycleEvents: ['run'], + options: { + yes: { + usage: 'Automatic yes to prompts', + shortcut: 'y', + required: false, + type: 'boolean', + }, + }, + }, + disassoc: { + usage: 'Disassociate the AppSync API associated to the domain', + lifecycleEvents: ['run'], + options: { + yes: { + usage: 'Automatic yes to prompts', + shortcut: 'y', + required: false, + type: 'boolean', + }, + force: { + usage: + 'Force the disassociation of *any* API from this domain', + shortcut: 'f', + required: false, + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, + } + + this.hooks = { + 'after:aws:info:gatherData': () => this.gatherData(), + 'after:aws:info:displayServiceInfo': () => { + this.displayEndpoints() + this.displayApiKeys() + }, + // Commands + 'appsync:validate-schema:run': () => { + this.loadConfig() + this.validateSchemas() + this.utils.log.success('AppSync schema valid') + }, + 'appsync:get-introspection:run': () => this.getIntrospection(), + 'appsync:flush-cache:run': () => this.flushCache(), + 'appsync:console:run': () => this.openConsole(), + 'appsync:cloudwatch:run': () => this.openCloudWatch(), + 'appsync:logs:run': async () => this.initShowLogs(), + 'before:appsync:domain:create:run': async () => this.initDomainCommand(), + 'appsync:domain:create:run': async () => this.createDomain(), + 'before:appsync:domain:delete:run': async () => this.initDomainCommand(), + 'appsync:domain:delete:run': async () => this.deleteDomain(), + 'before:appsync:domain:assoc:run': async () => this.initDomainCommand(), + 'appsync:domain:assoc:run': async () => this.assocDomain(), + 'before:appsync:domain:disassoc:run': async () => + this.initDomainCommand(), + 'appsync:domain:disassoc:run': async () => this.disassocDomain(), + 'before:appsync:domain:create-record:run': async () => + this.initDomainCommand(), + 'appsync:domain:create-record:run': async () => this.createRecord(), + 'before:appsync:domain:delete-record:run': async () => + this.initDomainCommand(), + 'appsync:domain:delete-record:run': async () => this.deleteRecord(), + // Removed promotional finalize hook + } + + // These hooks need the config to be loaded and + // processed in order to add embedded functions + // to the service. (eg: function defined in resolvers) + ;[ + 'before:logs:logs', + 'before:deploy:function:initialize', + 'before:package:initialize', + 'before:aws:info:gatherData', + ].forEach((hook) => { + this.hooks[hook] = () => { + this.loadConfig() + this.buildAndAppendResources() + } + }) + } + + async getApiIdFromStack() { + this.loadConfig() + + if (!this.naming) { + throw new this.serverless.classes.Error( + 'Could not find the naming service. This should not happen.', + ) + } + + const logicalIdGraphQLApi = this.naming.getApiLogicalId() + + const { StackResources } = await this.provider.request( + 'CloudFormation', + 'describeStackResources', + { + StackName: this.provider.naming.getStackName(), + LogicalResourceId: logicalIdGraphQLApi, + }, + ) + + const apiId = last(StackResources?.[0]?.PhysicalResourceId?.split('/')) + + if (!apiId) { + throw new this.serverless.classes.Error( + 'AppSync Api not found in stack. Did you forget to deploy?', + ) + } + + return apiId + } + + async gatherData() { + const apiId = await this.getApiIdFromStack() + + const { graphqlApi } = await this.provider.request( + 'AppSync', + 'getGraphqlApi', + { + apiId, + }, + ) + + forEach(graphqlApi?.uris, (value, type) => { + this.gatheredData.apis.push({ + id: apiId, + type: type.toLowerCase(), + uri: value, + }) + }) + + const { apiKeys } = await this.provider.request('AppSync', 'listApiKeys', { + apiId: apiId, + }) + + apiKeys?.forEach((apiKey) => { + this.gatheredData.apiKeys.push({ + value: apiKey.id || 'unknown key', + description: apiKey.description, + }) + }) + } + + async getIntrospection() { + const apiId = await this.getApiIdFromStack() + + const { schema } = await this.provider.request( + 'AppSync', + 'getIntrospectionSchema', + { + apiId, + format: (this.options.format || 'JSON').toUpperCase(), + }, + ) + + if (!schema) { + throw new this.serverless.classes.Error('Schema not found') + } + + if (this.options.output) { + try { + const filePath = path.resolve(this.options.output) + fs.writeFileSync(filePath, schema.toString()) + this.utils.log.success(`Introspection schema exported to ${filePath}`) + } catch (error) { + this.utils.log.error(`Could not save to file: ${error.message}`) + } + return + } + + this.utils.writeText(schema.toString()) + } + + async flushCache() { + const apiId = await this.getApiIdFromStack() + await this.provider.request('AppSync', 'flushApiCache', { apiId }) + this.utils.log.success('Cache flushed successfully') + } + + async openConsole() { + const apiId = await this.getApiIdFromStack() + const { region } = this.serverless.service.provider + const url = `${CONSOLE_BASE_URL}/appsync/home?region=${region}#/${apiId}/v1/home` + open(url) + } + + async openCloudWatch() { + const apiId = await this.getApiIdFromStack() + const { region } = this.serverless.service.provider + const url = `${CONSOLE_BASE_URL}/cloudwatch/home?region=${region}#logsV2:log-groups/log-group/$252Faws$252Fappsync$252Fapis$252F${apiId}` + open(url) + } + + async initShowLogs() { + const apiId = await this.getApiIdFromStack() + await this.showLogs(`/aws/appsync/apis/${apiId}`) + } + + async showLogs(logGroupName, nextToken) { + let startTime + if (this.options.startTime) { + startTime = parseDateTimeOrDuration(this.options.startTime) + } else { + startTime = DateTime.now().minus({ minutes: 10 }) + } + + const { events, nextToken: newNextToken } = await this.provider.request( + 'CloudWatchLogs', + 'filterLogEvents', + { + logGroupName, + startTime: startTime.toMillis(), + nextToken, + filterPattern: this.options.filter, + }, + ) + + events?.forEach((event) => { + const { timestamp, message } = event + this.utils.writeText( + `${DateTime.fromMillis(timestamp || 0).toISO()}\t${message}`, + ) + }) + + const lastTs = last(events)?.timestamp + this.options.startTime = lastTs + ? DateTime.fromMillis(lastTs + 1).toISO() + : this.options.startTime + + if (this.options.tail) { + const interval = this.options.interval + ? parseInt(this.options.interval, 10) + : 1000 + await wait(interval) + await this.showLogs(logGroupName, newNextToken) + } + } + + async initDomainCommand() { + this.loadConfig() + const domain = this.getDomain() + + if (domain.useCloudFormation !== false) { + this.utils.log.warning( + 'You are using the CloudFormation integration for domain configuration.\n' + + 'To avoid CloudFormation drifts, you should not use it in combination with this command.\n' + + 'Set the `domain.useCloudFormation` attribute to false to use the CLI integration.\n' + + 'If you have already deployed using CloudFormation and would like to switch to using the CLI, you can ' + + terminalLink( + 'eject from CloudFormation', + 'https://github.com/sid88in/serverless-appsync-plugin/blob/master/doc/custom-domain.md#ejecting-from-cloudformation', + ) + + ' first.', + ) + + if (!this.options.yes && !(await confirmAction())) { + process.exit(0) + } + } + } + + getDomain() { + if (!this.api) { + throw new this.serverless.classes.Error('AppSync configuration not found') + } + + const { domain } = this.api.config + if (!domain) { + throw new this.serverless.classes.Error('Domain configuration not found') + } + + return domain + } + + async getDomainCertificateArn() { + const { CertificateSummaryList } = await this.provider.request( + 'ACM', + 'listCertificates', + // only fully issued certificates + { CertificateStatuses: ['ISSUED'] }, + // certificates must always be in us-east-1 + { region: 'us-east-1' }, + ) + + const domain = this.getDomain() + + // try to find an exact match certificate + // fallback on wildcard + const matches = [domain.name, getWildCardDomainName(domain.name)] + for (const match of matches) { + const cert = CertificateSummaryList?.find( + ({ DomainName }) => DomainName === match, + ) + if (cert) { + this.utils.log.info( + `Found matching certificate for ${match}: ${cert.CertificateArn}`, + ) + return cert.CertificateArn + } + } + } + + async createDomain() { + try { + const domain = this.getDomain() + const certificateArn = + domain.certificateArn || (await this.getDomainCertificateArn()) + + if (!certificateArn) { + throw new this.serverless.classes.Error( + `No certificate found for domain ${domain.name}.`, + ) + } + + await this.provider.request('AppSync', 'createDomainName', { + domainName: domain.name, + certificateArn, + }) + this.utils.log.success(`Domain '${domain.name}' created successfully`) + } catch (error) { + if ( + error instanceof this.serverless.classes.Error && + this.options.quiet + ) { + this.utils.log.error(error.message) + } else { + throw error + } + } + } + + async deleteDomain() { + try { + const domain = this.getDomain() + this.utils.log.warning(`The domain '${domain.name} will be deleted.`) + if (!this.options.yes && !(await confirmAction())) { + return + } + await this.provider.request('AppSync', 'deleteDomainName', { + domainName: domain.name, + }) + this.utils.log.success(`Domain '${domain.name}' deleted successfully`) + } catch (error) { + if ( + error instanceof this.serverless.classes.Error && + this.options.quiet + ) { + this.utils.log.error(error.message) + } else { + throw error + } + } + } + + async getApiAssocStatus(name) { + try { + const result = await this.provider.request( + 'AppSync', + 'getApiAssociation', + { + domainName: name, + }, + ) + return result.apiAssociation + } catch (error) { + if ( + error instanceof this.serverless.classes.Error && + error.providerErrorCodeExtension === 'NOT_FOUND_EXCEPTION' + ) { + return { associationStatus: 'NOT_FOUND' } + } + throw error + } + } + + async showApiAssocStatus({ name, message, desiredStatus }) { + const progressInstance = this.utils.progress.create({ message }) + let status + do { + status = + (await this.getApiAssocStatus(name))?.associationStatus || 'UNKNOWN' + if (status !== desiredStatus) { + await wait(1000) + } + } while (status !== desiredStatus) + + progressInstance.remove() + } + + async assocDomain() { + const domain = this.getDomain() + const apiId = await this.getApiIdFromStack() + const assoc = await this.getApiAssocStatus(domain.name) + + if (assoc?.associationStatus !== 'NOT_FOUND' && assoc?.apiId !== apiId) { + this.utils.log.warning( + `The domain ${domain.name} is currently associated to another API (${assoc?.apiId})`, + ) + if (!this.options.yes && !(await confirmAction())) { + return + } + } else if (assoc?.apiId === apiId) { + this.utils.log.success('The domain is already associated to this API') + return + } + + await this.provider.request('AppSync', 'associateApi', { + domainName: domain.name, + apiId, + }) + + const message = `Associating API with domain '${domain.name}'` + await this.showApiAssocStatus({ + name: domain.name, + message, + desiredStatus: 'SUCCESS', + }) + this.utils.log.success( + `API successfully associated to domain '${domain.name}'`, + ) + } + + async disassocDomain() { + const domain = this.getDomain() + const apiId = await this.getApiIdFromStack() + const assoc = await this.getApiAssocStatus(domain.name) + + if (assoc?.associationStatus === 'NOT_FOUND') { + this.utils.log.warning( + `The domain ${domain.name} is currently not associated to any API`, + ) + return + } + + if (assoc?.apiId !== apiId && !this.options.force) { + throw new this.serverless.classes.Error( + `The domain ${domain.name} is currently associated to another API (${assoc?.apiId})\n` + + `Try running this command from that API's stack or stage, or use the --force / -f flag`, + ) + } + this.utils.log.warning( + `The domain ${domain.name} will be disassociated from API '${apiId}'`, + ) + + if (!this.options.yes && !(await confirmAction())) { + return + } + + await this.provider.request('AppSync', 'disassociateApi', { + domainName: domain.name, + }) + + const message = `Disassociating API from domain '${domain.name}'` + await this.showApiAssocStatus({ + name: domain.name, + message, + desiredStatus: 'NOT_FOUND', + }) + + this.utils.log.success( + `API successfully disassociated from domain '${domain.name}'`, + ) + } + + async getHostedZoneId() { + const domain = this.getDomain() + if (domain.hostedZoneId) { + return domain.hostedZoneId + } else { + const { HostedZones } = await this.provider.request( + 'Route53', + 'listHostedZonesByName', + {}, + ) + const hostedZoneName = + domain.hostedZoneName || getHostedZoneName(domain.name) + const foundHostedZone = HostedZones.find( + (zone) => zone.Name === hostedZoneName, + )?.Id + if (!foundHostedZone) { + throw new this.serverless.classes.Error( + `No hosted zone found for domain ${domain.name}`, + ) + } + return foundHostedZone.replace('/hostedzone/', '') + } + } + + async getAppSyncDomainName() { + const domain = this.getDomain() + const { domainNameConfig } = await this.provider.request( + 'AppSync', + 'getDomainName', + { + domainName: domain.name, + }, + ) + + const { hostedZoneId, appsyncDomainName: dnsName } = domainNameConfig || {} + if (!hostedZoneId || !dnsName) { + throw new this.serverless.classes.Error( + `Domain ${domain.name} not found\nDid you forget to run 'sls appsync domain create'?`, + ) + } + + return { hostedZoneId, dnsName } + } + + async createRecord() { + const progressInstance = this.utils.progress.create({ + message: 'Creating route53 record', + }) + + const domain = this.getDomain() + const appsyncDomainName = await this.getAppSyncDomainName() + const hostedZoneId = await this.getHostedZoneId() + const changeId = await this.changeRoute53Record( + 'CREATE', + hostedZoneId, + appsyncDomainName, + ) + if (changeId) { + await this.checkRoute53RecordStatus(changeId) + progressInstance.remove() + this.utils.log.info( + `Alias record for '${domain.name}' was created in Hosted Zone '${hostedZoneId}'`, + ) + this.utils.log.success('Route53 record created successfuly') + } + } + + async deleteRecord() { + const domain = this.getDomain() + const appsyncDomainName = await this.getAppSyncDomainName() + const hostedZoneId = await this.getHostedZoneId() + + this.utils.log.warning( + `Alias record for '${domain.name}' will be deleted from Hosted Zone '${hostedZoneId}'`, + ) + if (!this.options.yes && !(await confirmAction())) { + return + } + + const progressInstance = this.utils.progress.create({ + message: 'Deleting route53 record', + }) + + const changeId = await this.changeRoute53Record( + 'DELETE', + hostedZoneId, + appsyncDomainName, + ) + if (changeId) { + await this.checkRoute53RecordStatus(changeId) + progressInstance.remove() + this.utils.log.info( + `Alias record for '${domain.name}' was deleted from Hosted Zone '${hostedZoneId}'`, + ) + this.utils.log.success('Route53 record deleted successfuly') + } + } + + async checkRoute53RecordStatus(changeId) { + let result + do { + result = await this.provider.request('Route53', 'getChange', { + Id: changeId, + }) + if (result.ChangeInfo.Status !== 'INSYNC') { + await wait(1000) + } + } while (result.ChangeInfo.Status !== 'INSYNC') + } + + async changeRoute53Record(action, hostedZoneId, domainNamConfig) { + const domain = this.getDomain() + + try { + const { ChangeInfo } = await this.provider.request( + 'Route53', + 'changeResourceRecordSets', + { + HostedZoneId: hostedZoneId, + ChangeBatch: { + Changes: [ + { + Action: action, + ResourceRecordSet: { + Name: domain.name, + Type: 'A', + AliasTarget: { + HostedZoneId: domainNamConfig.hostedZoneId, + DNSName: domainNamConfig.dnsName, + EvaluateTargetHealth: false, + }, + }, + }, + ], + }, + }, + ) + + return ChangeInfo.Id + } catch (error) { + if ( + error instanceof this.serverless.classes.Error && + this.options.quiet + ) { + this.utils.log.error(error.message) + } else { + throw error + } + } + } + + displayEndpoints() { + const endpoints = this.gatheredData.apis.map( + ({ type, uri }) => `${type}: ${uri}`, + ) + + if (endpoints.length === 0) { + return + } + + const { name } = this.api?.config?.domain || {} + if (name) { + endpoints.push(`graphql: https://${name}/graphql`) + endpoints.push(`realtime: wss://${name}/graphql/realtime`) + } + + this.serverless.addServiceOutputSection( + 'appsync endpoints', + endpoints.sort(), + ) + } + + displayApiKeys() { + const { conceal } = this.options + const apiKeys = this.gatheredData.apiKeys.map( + ({ description, value }) => `${value} (${description})`, + ) + + if (apiKeys.length === 0) { + return + } + + if (!conceal) { + this.serverless.addServiceOutputSection('appsync api keys', apiKeys) + } + } + + loadConfig() { + this.utils.log.info('Loading AppSync config') + + const { appSync } = this.serverless.configurationInput + + try { + validateConfig(appSync) + } catch (error) { + if (error instanceof AppSyncValidationError) { + this.handleConfigValidationError(error) + } else { + throw error + } + } + const config = getAppSyncConfig(appSync) + this.naming = new Naming(appSync.name) + this.api = new Api(config, this) + } + + validateSchemas() { + try { + this.utils.log.info('Validating AppSync schema') + if (!this.api) { + throw new this.serverless.classes.Error( + 'Could not load the API. This should not happen.', + ) + } + this.api.compileSchema() + } catch (error) { + this.utils.log.info('Error') + if (error instanceof GraphQLError) { + this.handleError(error.message) + } + + throw error + } + } + + buildAndAppendResources() { + if (!this.api) { + throw new this.serverless.classes.Error( + 'Could not load the API. This should not happen.', + ) + } + + const resources = this.api.compile() + + merge(this.serverless.service, { + functions: this.api.functions, + resources: { Resources: resources }, + }) + + this.serverless.service.setFunctionNames( + this.serverless.processedInput.options, + ) + } + + resolveVariable({ address }) { + this.loadConfig() + + if (!this.naming) { + throw new this.serverless.classes.Error( + 'Could not find the naming service. This should not happen.', + ) + } + + if (address === 'id') { + return { + value: { + 'Fn::GetAtt': [this.naming.getApiLogicalId(), 'ApiId'], + }, + } + } else if (address === 'arn') { + return { + value: { + 'Fn::GetAtt': [this.naming.getApiLogicalId(), 'Arn'], + }, + } + } else if (address === 'url') { + return { + value: { + 'Fn::GetAtt': [this.naming.getApiLogicalId(), 'GraphQLUrl'], + }, + } + } else if (address.startsWith('apiKey.')) { + const [, name] = address.split('.') + return { + value: { + 'Fn::GetAtt': [this.naming.getApiKeyLogicalId(name), 'ApiKey'], + }, + } + } else { + throw new this.serverless.classes.Error(`Unknown address '${address}'`) + } + } + + handleConfigValidationError(error) { + const errors = error.validationErrors.map( + (error) => ` at appSync${error.path}: ${error.message}`, + ) + const message = `Invalid AppSync Configuration:\n${errors.join('\n')}` + this.handleError(message) + } + + handleError(message) { + const { configValidationMode } = this.serverless.service + if (configValidationMode === 'error') { + throw new this.serverless.classes.Error(message) + } else if (configValidationMode === 'warn') { + this.utils.log.warning(message) + } + } +} + +export default ServerlessAppsyncPlugin diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/Api.js b/packages/serverless/lib/plugins/aws/appsync/resources/Api.js new file mode 100644 index 000000000..daf6f1a9f --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/Api.js @@ -0,0 +1,554 @@ +import _ from 'lodash' +const { forEach, isEmpty, merge, set } = _ +import { getHostedZoneName, parseDuration } from '../utils.js' +import { DateTime, Duration } from 'luxon' +import { Naming } from './Naming.js' +import { DataSource } from './DataSource.js' +import { Resolver } from './Resolver.js' +import { PipelineFunction } from './PipelineFunction.js' +import { Schema } from './Schema.js' +import { Waf } from './Waf.js' + +export class Api { + constructor(config, plugin) { + this.config = config + this.plugin = plugin + this.naming = new Naming(this.config.name) + this.functions = {} + } + + compile() { + const resources = {} + + merge(resources, this.compileEndpoint()) + merge(resources, this.compileSchema()) + merge(resources, this.compileCustomDomain()) + merge(resources, this.compileCloudWatchLogGroup()) + merge(resources, this.compileLambdaAuthorizerPermission()) + merge(resources, this.compileWafRules()) + merge(resources, this.compileCachingResources()) + + forEach(this.config.apiKeys, (key) => { + merge(resources, this.compileApiKey(key)) + }) + + forEach(this.config.dataSources, (ds) => { + merge(resources, this.compileDataSource(ds)) + }) + + forEach(this.config.pipelineFunctions, (func) => { + merge(resources, this.compilePipelineFunctionResource(func)) + }) + + forEach(this.config.resolvers, (resolver) => { + merge(resources, this.compileResolver(resolver)) + }) + + return resources + } + + compileEndpoint() { + const logicalId = this.naming.getApiLogicalId() + + const endpointResource = { + Type: 'AWS::AppSync::GraphQLApi', + Properties: { + Name: this.config.name, + XrayEnabled: this.config.xrayEnabled || false, + Tags: this.getTagsConfig(), + EnvironmentVariables: this.config.environment, + }, + } + + merge( + endpointResource.Properties, + this.compileAuthenticationProvider(this.config.authentication), + ) + + if (this.config.additionalAuthentications.length > 0) { + merge(endpointResource.Properties, { + AdditionalAuthenticationProviders: + this.config.additionalAuthentications?.map((provider) => + this.compileAuthenticationProvider(provider, true), + ), + }) + } + + if (this.config.logging && this.config.logging.enabled !== false) { + const logicalIdCloudWatchLogsRole = this.naming.getLogGroupRoleLogicalId() + set(endpointResource, 'Properties.LogConfig', { + CloudWatchLogsRoleArn: this.config.logging.roleArn || { + 'Fn::GetAtt': [logicalIdCloudWatchLogsRole, 'Arn'], + }, + FieldLogLevel: this.config.logging.level, + ExcludeVerboseContent: this.config.logging.excludeVerboseContent, + }) + } + + if (this.config.visibility) { + merge(endpointResource.Properties, { + Visibility: this.config.visibility, + }) + } + + if (this.config.introspection !== undefined) { + merge(endpointResource.Properties, { + IntrospectionConfig: this.config.introspection ? 'ENABLED' : 'DISABLED', + }) + } + + if (this.config.queryDepthLimit !== undefined) { + merge(endpointResource.Properties, { + QueryDepthLimit: this.config.queryDepthLimit, + }) + } + + if (this.config.resolverCountLimit !== undefined) { + merge(endpointResource.Properties, { + ResolverCountLimit: this.config.resolverCountLimit, + }) + } + + const resources = { + [logicalId]: endpointResource, + } + + return resources + } + + compileCloudWatchLogGroup() { + if (!this.config.logging || this.config.logging.enabled === false) { + return {} + } + + const logGroupLogicalId = this.naming.getLogGroupLogicalId() + const roleLogicalId = this.naming.getLogGroupRoleLogicalId() + const policyLogicalId = this.naming.getLogGroupPolicyLogicalId() + const apiLogicalId = this.naming.getApiLogicalId() + + if (this.config.logging.roleArn) { + return { + [logGroupLogicalId]: { + Type: 'AWS::Logs::LogGroup', + Properties: { + LogGroupName: { + 'Fn::Join': [ + '/', + [ + '/aws/appsync/apis', + { 'Fn::GetAtt': [apiLogicalId, 'ApiId'] }, + ], + ], + }, + RetentionInDays: + this.config.logging.retentionInDays || + this.plugin.serverless.service.provider.logRetentionInDays, + }, + }, + } + } + + return { + [logGroupLogicalId]: { + Type: 'AWS::Logs::LogGroup', + Properties: { + LogGroupName: { + 'Fn::Join': [ + '/', + ['/aws/appsync/apis', { 'Fn::GetAtt': [apiLogicalId, 'ApiId'] }], + ], + }, + RetentionInDays: + this.config.logging.retentionInDays || + this.plugin.serverless.service.provider.logRetentionInDays, + }, + }, + [policyLogicalId]: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: `${policyLogicalId}`, + Roles: [{ Ref: roleLogicalId }], + PolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: [ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents', + ], + Resource: [ + { + 'Fn::GetAtt': [logGroupLogicalId, 'Arn'], + }, + ], + }, + ], + }, + }, + }, + [roleLogicalId]: { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { + Service: ['appsync.amazonaws.com'], + }, + Action: ['sts:AssumeRole'], + }, + ], + }, + }, + }, + } + } + + compileSchema() { + const schema = new Schema(this, this.config.schema) + return schema.compile() + } + + compileCustomDomain() { + const { domain } = this.config + + if ( + !domain || + domain.enabled === false || + domain.useCloudFormation === false + ) { + return {} + } + + const domainNameLogicalId = this.naming.getDomainNameLogicalId() + const domainAssocLogicalId = this.naming.getDomainAssociationLogicalId() + const domainCertificateLogicalId = + this.naming.getDomainCertificateLogicalId() + + const resources = { + [domainNameLogicalId]: { + Type: 'AWS::AppSync::DomainName', + DeletionPolicy: domain.retain ? 'Retain' : 'Delete', + Properties: { + CertificateArn: domain.certificateArn || { + Ref: domainCertificateLogicalId, + }, + DomainName: domain.name, + }, + }, + [domainAssocLogicalId]: { + Type: 'AWS::AppSync::DomainNameApiAssociation', + DeletionPolicy: domain.retain ? 'Retain' : 'Delete', + Properties: { + ApiId: this.getApiId(), + DomainName: domain.name, + }, + DependsOn: [domainNameLogicalId], + }, + } + + if (!domain.certificateArn) { + merge(resources, { + [domainCertificateLogicalId]: { + Type: 'AWS::CertificateManager::Certificate', + DeletionPolicy: domain.retain ? 'Retain' : 'Delete', + Properties: { + DomainName: domain.name, + ValidationMethod: 'DNS', + DomainValidationOptions: [ + { + DomainName: domain.name, + HostedZoneId: domain.hostedZoneId, + }, + ], + }, + }, + }) + } + + if (domain.route53 !== false) { + const hostedZoneName = + domain.hostedZoneName || getHostedZoneName(domain.name) + + const domainRoute53Record = this.naming.getDomainReoute53RecordLogicalId() + + merge(resources, { + [domainRoute53Record]: { + Type: 'AWS::Route53::RecordSet', + DeletionPolicy: domain.retain ? 'Retain' : 'Delete', + Properties: { + ...(domain.hostedZoneId + ? { HostedZoneId: domain.hostedZoneId } + : { HostedZoneName: hostedZoneName }), + Name: domain.name, + Type: 'A', + AliasTarget: { + HostedZoneId: { + 'Fn::GetAtt': [domainNameLogicalId, 'HostedZoneId'], + }, + DNSName: { + 'Fn::GetAtt': [domainNameLogicalId, 'AppSyncDomainName'], + }, + EvaluateTargetHealth: false, + }, + }, + }, + }) + } + + return resources + } + + compileLambdaAuthorizerPermission() { + const lambdaAuth = [ + ...this.config.additionalAuthentications, + this.config.authentication, + ].find(({ type }) => type === 'AWS_LAMBDA') + + if (!lambdaAuth) { + return {} + } + + const logicalId = this.naming.getLambdaAuthLogicalId() + const apiLogicalId = this.naming.getApiLogicalId() + + return { + [logicalId]: { + Type: 'AWS::Lambda::Permission', + Properties: { + Action: 'lambda:InvokeFunction', + FunctionName: this.getLambdaArn( + lambdaAuth.config, + this.naming.getAuthenticationEmbeddedLamdbaName(), + ), + Principal: 'appsync.amazonaws.com', + SourceArn: { Ref: apiLogicalId }, + }, + }, + } + } + + compileApiKey(config) { + const { name, expiresAt, expiresAfter, description, apiKeyId } = config + + const startOfHour = DateTime.now().setZone('UTC').startOf('hour') + let expires + if (expiresAfter) { + let duration = parseDuration(expiresAfter) + // Minimum duration is 1 day from 'now' + // However, api key expiry is rounded down to the hour. + // meaning the minimum expiry date is in fact 25 hours + // We accept 24h durations for simplicity of use + // but fix them to be 25 + // Anything < 24h will be kept to make sure the validation fails later + if (duration.as('hours') >= 24 && duration.as('hours') < 25) { + duration = Duration.fromDurationLike({ hours: 25 }) + } + expires = startOfHour.plus(duration) + } else if (expiresAt) { + expires = DateTime.fromISO(expiresAt) + } else { + // 1 year by default + expires = startOfHour.plus({ days: 365 }) + } + + if ( + expires < DateTime.now().plus({ day: 1 }) || + expires > DateTime.now().plus({ years: 365 }) + ) { + throw new Error( + `Api Key ${name} must be valid for a minimum of 1 day and a maximum of 365 days.`, + ) + } + + const logicalIdApiKey = this.naming.getApiKeyLogicalId(name) + + return { + [logicalIdApiKey]: { + Type: 'AWS::AppSync::ApiKey', + Properties: { + ApiId: this.getApiId(), + Description: description || name, + Expires: Math.round(expires.toMillis() / 1000), + ApiKeyId: apiKeyId, + }, + }, + } + } + + compileCachingResources() { + if (this.config.caching && this.config.caching.enabled !== false) { + const cacheConfig = this.config.caching + const logicalId = this.naming.getCachingLogicalId() + + return { + [logicalId]: { + Type: 'AWS::AppSync::ApiCache', + Properties: { + ApiCachingBehavior: cacheConfig.behavior, + ApiId: this.getApiId(), + AtRestEncryptionEnabled: cacheConfig.atRestEncryption || false, + TransitEncryptionEnabled: cacheConfig.transitEncryption || false, + Ttl: cacheConfig.ttl || 3600, + Type: cacheConfig.type || 'T2_SMALL', + }, + }, + } + } + + return {} + } + + compileDataSource(dsConfig) { + const dataSource = new DataSource(this, dsConfig) + return dataSource.compile() + } + + compileResolver(resolverConfig) { + const resolver = new Resolver(this, resolverConfig) + return resolver.compile() + } + + compilePipelineFunctionResource(config) { + const func = new PipelineFunction(this, config) + return func.compile() + } + + compileWafRules() { + if (!this.config.waf || this.config.waf.enabled === false) { + return {} + } + + const waf = new Waf(this, this.config.waf) + return waf.compile() + } + + getApiId() { + const logicalIdGraphQLApi = this.naming.getApiLogicalId() + return { + 'Fn::GetAtt': [logicalIdGraphQLApi, 'ApiId'], + } + } + + getUserPoolConfig(auth, isAdditionalAuth = false) { + const userPoolConfig = { + AwsRegion: auth.config.awsRegion || { 'Fn::Sub': '${AWS::Region}' }, + UserPoolId: auth.config.userPoolId, + AppIdClientRegex: auth.config.appIdClientRegex, + ...(!isAdditionalAuth + ? { + // Default action is the one passed in the config + // or 'ALLOW' + DefaultAction: auth.config.defaultAction || 'ALLOW', + } + : {}), + } + + return userPoolConfig + } + + getOpenIDConnectConfig(auth) { + if (!auth.config) { + return + } + + const openIdConnectConfig = { + Issuer: auth.config.issuer, + ClientId: auth.config.clientId, + IatTTL: auth.config.iatTTL, + AuthTTL: auth.config.authTTL, + } + + return openIdConnectConfig + } + + getLambdaAuthorizerConfig(auth) { + if (!auth.config) { + return + } + + const lambdaAuthorizerConfig = { + AuthorizerUri: this.getLambdaArn( + auth.config, + this.naming.getAuthenticationEmbeddedLamdbaName(), + ), + IdentityValidationExpression: auth.config.identityValidationExpression, + AuthorizerResultTtlInSeconds: auth.config.authorizerResultTtlInSeconds, + } + + return lambdaAuthorizerConfig + } + + getTagsConfig() { + if (!this.config.tags || isEmpty(this.config.tags)) { + return undefined + } + + const tags = this.config.tags + return Object.keys(this.config.tags).map((key) => ({ + Key: key, + Value: tags[key], + })) + } + + compileAuthenticationProvider(provider, isAdditionalAuth = false) { + const { type } = provider + const authPrivider = { + AuthenticationType: type, + } + + if (type === 'AMAZON_COGNITO_USER_POOLS') { + merge(authPrivider, { + UserPoolConfig: this.getUserPoolConfig(provider, isAdditionalAuth), + }) + } else if (type === 'OPENID_CONNECT') { + merge(authPrivider, { + OpenIDConnectConfig: this.getOpenIDConnectConfig(provider), + }) + } else if (type === 'AWS_LAMBDA') { + merge(authPrivider, { + LambdaAuthorizerConfig: this.getLambdaAuthorizerConfig(provider), + }) + } + + return authPrivider + } + + getLambdaArn(config, embeddedFunctionName) { + if ('functionArn' in config) { + return config.functionArn + } else if ('functionName' in config) { + return this.generateLambdaArn(config.functionName, config.functionAlias) + } else if ('function' in config) { + this.functions[embeddedFunctionName] = config.function + return this.generateLambdaArn(embeddedFunctionName) + } + + throw new Error( + 'You must specify either `functionArn`, `functionName` or `function` for lambda definitions.', + ) + } + + generateLambdaArn(functionName, functionAlias) { + const lambdaLogicalId = this.plugin.serverless + .getProvider('aws') + .naming.getLambdaLogicalId(functionName) + const lambdaArn = { 'Fn::GetAtt': [lambdaLogicalId, 'Arn'] } + + return functionAlias + ? { 'Fn::Join': [':', [lambdaArn, functionAlias]] } + : lambdaArn + } + + hasDataSource(name) { + return name in this.config.dataSources + } + + hasPipelineFunction(name) { + return name in this.config.pipelineFunctions + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/DataSource.js b/packages/serverless/lib/plugins/aws/appsync/resources/DataSource.js new file mode 100644 index 000000000..6290c32b3 --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/DataSource.js @@ -0,0 +1,411 @@ +import _ from 'lodash' +const { merge } = _ + +export class DataSource { + constructor(api, config) { + this.api = api + this.config = config + } + + compile() { + const resource = { + Type: 'AWS::AppSync::DataSource', + Properties: { + ApiId: this.api.getApiId(), + Name: this.config.name, + Description: this.config.description, + Type: this.config.type, + }, + } + + if (this.config.type === 'AWS_LAMBDA') { + resource.Properties.LambdaConfig = { + LambdaFunctionArn: this.api.getLambdaArn( + this.config.config, + this.api.naming.getDataSourceEmbeddedLambdaResolverName(this.config), + ), + } + } else if (this.config.type === 'AMAZON_DYNAMODB') { + resource.Properties.DynamoDBConfig = this.getDynamoDbConfig(this.config) + } else if (this.config.type === 'AMAZON_OPENSEARCH_SERVICE') { + resource.Properties.OpenSearchServiceConfig = this.getOpenSearchConfig( + this.config, + ) + } else if (this.config.type === 'RELATIONAL_DATABASE') { + resource.Properties.RelationalDatabaseConfig = this.getRelationalDbConfig( + this.config, + ) + } else if (this.config.type === 'HTTP') { + resource.Properties.HttpConfig = this.getHttpConfig(this.config) + } else if (this.config.type === 'AMAZON_EVENTBRIDGE') { + resource.Properties.EventBridgeConfig = this.getEventBridgeConfig( + this.config, + ) + } + + const logicalId = this.api.naming.getDataSourceLogicalId(this.config.name) + + const resources = { + [logicalId]: resource, + } + + if ('config' in this.config && this.config.config.serviceRoleArn) { + resource.Properties.ServiceRoleArn = this.config.config.serviceRoleArn + } else { + const role = this.compileDataSourceIamRole() + if (role) { + const roleLogicalId = this.api.naming.getDataSourceRoleLogicalId( + this.config.name, + ) + resource.Properties.ServiceRoleArn = { + 'Fn::GetAtt': [roleLogicalId, 'Arn'], + } + merge(resources, role) + } + } + + return resources + } + + getDynamoDbConfig(config) { + return { + AwsRegion: config.config.region || { Ref: 'AWS::Region' }, + TableName: config.config.tableName, + UseCallerCredentials: !!config.config.useCallerCredentials, + ...this.getDeltaSyncConfig(config), + } + } + + getDeltaSyncConfig(config) { + if (config.config.versioned && config.config.deltaSyncConfig) { + return { + Versioned: true, + DeltaSyncConfig: { + BaseTableTTL: config.config.deltaSyncConfig.baseTableTTL || 43200, + DeltaSyncTableName: config.config.deltaSyncConfig.deltaSyncTableName, + DeltaSyncTableTTL: + config.config.deltaSyncConfig.deltaSyncTableTTL || 1440, + }, + } + } + } + + getEventBridgeConfig(config) { + return { + EventBusArn: config.config.eventBusArn, + } + } + + getOpenSearchConfig(config) { + const endpoint = + config.config.endpoint || + (config.config.domain && { + 'Fn::Join': [ + '', + [ + 'https://', + { 'Fn::GetAtt': [config.config.domain, 'DomainEndpoint'] }, + ], + ], + }) + // FIXME: can we validate this and make TS infer mutually eclusive types? + if (!endpoint) { + throw new Error('Specify either endpoint or domain') + } + return { + AwsRegion: config.config.region || { Ref: 'AWS::Region' }, + Endpoint: endpoint, + } + } + + getRelationalDbConfig(config) { + return { + RdsHttpEndpointConfig: { + AwsRegion: config.config.region || { Ref: 'AWS::Region' }, + DbClusterIdentifier: { + 'Fn::Join': [ + ':', + [ + 'arn', + 'aws', + 'rds', + config.config.region || { Ref: 'AWS::Region' }, + { Ref: 'AWS::AccountId' }, + 'cluster', + config.config.dbClusterIdentifier, + ], + ], + }, + DatabaseName: config.config.databaseName, + Schema: config.config.schema, + AwsSecretStoreArn: config.config.awsSecretStoreArn, + }, + RelationalDatabaseSourceType: + config.config.relationalDatabaseSourceType || 'RDS_HTTP_ENDPOINT', + } + } + + getHttpConfig(config) { + return { + Endpoint: config.config.endpoint, + ...this.getHttpAuthorizationConfig(config), + } + } + + getHttpAuthorizationConfig(config) { + const authConfig = config.config.authorizationConfig + if (authConfig) { + return { + AuthorizationConfig: { + AuthorizationType: authConfig.authorizationType, + AwsIamConfig: { + SigningRegion: authConfig.awsIamConfig.signingRegion || { + Ref: 'AWS::Region', + }, + SigningServiceName: authConfig.awsIamConfig.signingServiceName, + }, + }, + } + } + } + + compileDataSourceIamRole() { + if ('config' in this.config && this.config.config.serviceRoleArn) { + return + } + + let statements + + if ( + this.config.type === 'HTTP' && + this.config.config && + this.config.config.authorizationConfig && + this.config.config.authorizationConfig.authorizationType === 'AWS_IAM' && + !this.config.config.iamRoleStatements + ) { + throw new Error( + `${this.config.name}: When using AWS_IAM signature, you must also specify the required iamRoleStatements`, + ) + } + + if ('config' in this.config && this.config.config.iamRoleStatements) { + statements = this.config.config.iamRoleStatements + } else { + // Try to generate default statements for the given this.config. + statements = this.getDefaultDataSourcePolicyStatements() + } + + if (!statements || statements.length === 0) { + return + } + + const logicalId = this.api.naming.getDataSourceRoleLogicalId( + this.config.name, + ) + + return { + [logicalId]: { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { + Service: ['appsync.amazonaws.com'], + }, + Action: ['sts:AssumeRole'], + }, + ], + }, + Policies: [ + { + PolicyName: `AppSync-Datasource-${this.config.name}`, + PolicyDocument: { + Version: '2012-10-17', + Statement: statements, + }, + }, + ], + }, + }, + } + } + + getDefaultDataSourcePolicyStatements() { + switch (this.config.type) { + case 'AWS_LAMBDA': { + const lambdaArn = this.api.getLambdaArn( + this.config.config, + this.api.naming.getDataSourceEmbeddedLambdaResolverName(this.config), + ) + + // Allow "invoke" for the Datasource's function and its aliases/versions + const defaultLambdaStatement = { + Action: ['lambda:invokeFunction'], + Effect: 'Allow', + Resource: [lambdaArn, { 'Fn::Join': [':', [lambdaArn, '*']] }], + } + + return [defaultLambdaStatement] + } + case 'AMAZON_DYNAMODB': { + const dynamoDbResourceArn = { + 'Fn::Join': [ + ':', + [ + 'arn', + 'aws', + 'dynamodb', + this.config.config.region || { Ref: 'AWS::Region' }, + { Ref: 'AWS::AccountId' }, + `table`, + ], + ], + } + + // Allow any action on the Datasource's table + const defaultDynamoDBStatement = { + Action: [ + 'dynamodb:DeleteItem', + 'dynamodb:GetItem', + 'dynamodb:PutItem', + 'dynamodb:Query', + 'dynamodb:Scan', + 'dynamodb:UpdateItem', + 'dynamodb:BatchGetItem', + 'dynamodb:BatchWriteItem', + 'dynamodb:ConditionCheckItem', + ], + Effect: 'Allow', + Resource: [ + { + 'Fn::Join': [ + '/', + [dynamoDbResourceArn, this.config.config.tableName], + ], + }, + { + 'Fn::Join': [ + '/', + [dynamoDbResourceArn, this.config.config.tableName, '*'], + ], + }, + ], + } + + return [defaultDynamoDBStatement] + } + case 'RELATIONAL_DATABASE': { + const dDbResourceArn = { + 'Fn::Join': [ + ':', + [ + 'arn', + 'aws', + 'rds', + this.config.config.region || { Ref: 'AWS::Region' }, + { Ref: 'AWS::AccountId' }, + 'cluster', + this.config.config.dbClusterIdentifier, + ], + ], + } + const dbStatement = { + Effect: 'Allow', + Action: [ + 'rds-data:DeleteItems', + 'rds-data:ExecuteSql', + 'rds-data:ExecuteStatement', + 'rds-data:GetItems', + 'rds-data:InsertItems', + 'rds-data:UpdateItems', + ], + Resource: [ + dDbResourceArn, + { 'Fn::Join': [':', [dDbResourceArn, '*']] }, + ], + } + + const secretManagerStatement = { + Effect: 'Allow', + Action: ['secretsmanager:GetSecretValue'], + Resource: [ + this.config.config.awsSecretStoreArn, + { 'Fn::Join': [':', [this.config.config.awsSecretStoreArn, '*']] }, + ], + } + + return [dbStatement, secretManagerStatement] + } + case 'AMAZON_OPENSEARCH_SERVICE': { + let arn + if ( + this.config.config.endpoint && + typeof this.config.config.endpoint === 'string' + ) { + // FIXME: Do new domains have a different API? (opensearch) + const rx = + /^https:\/\/([a-z0-9-]+\.(\w{2}-[a-z]+-\d)\.es\.amazonaws\.com)$/ + const result = rx.exec(this.config.config.endpoint) + if (!result) { + throw new Error( + `Invalid AWS OpenSearch endpoint: '${this.config.config.endpoint}`, + ) + } + arn = { + 'Fn::Join': [ + ':', + [ + 'arn', + 'aws', + 'es', + result[2], + { Ref: 'AWS::AccountId' }, + `domain/${result[1]}/*`, + ], + ], + } + } else if (this.config.config.domain) { + arn = { + 'Fn::Join': [ + '/', + [{ 'Fn::GetAtt': [this.config.config.domain, 'Arn'] }, '*'], + ], + } + } else { + throw new Error( + `Could not determine the Arn for dataSource '${this.config.name}`, + ) + } + + // Allow any action on the Datasource's ES endpoint + const defaultESStatement = { + Action: [ + 'es:ESHttpDelete', + 'es:ESHttpGet', + 'es:ESHttpHead', + 'es:ESHttpPost', + 'es:ESHttpPut', + 'es:ESHttpPatch', + ], + Effect: 'Allow', + Resource: [arn], + } + + return [defaultESStatement] + } + case 'AMAZON_EVENTBRIDGE': { + // Allow PutEvents on the EventBridge bus + const defaultEventBridgeStatement = { + Action: ['events:PutEvents'], + Effect: 'Allow', + Resource: [this.config.config.eventBusArn], + } + + return [defaultEventBridgeStatement] + } + } + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/JsResolver.js b/packages/serverless/lib/plugins/aws/appsync/resources/JsResolver.js new file mode 100644 index 000000000..601fa0445 --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/JsResolver.js @@ -0,0 +1,117 @@ +import fs from 'fs' +import { buildSync } from 'esbuild' + +export class JsResolver { + constructor(api, config) { + this.api = api + this.config = config + } + + compile() { + if (!fs.existsSync(this.config.path)) { + throw new this.api.plugin.serverless.classes.Error( + `The resolver handler file '${this.config.path}' does not exist`, + ) + } + + return this.processTemplateSubstitutions(this.getResolverContent()) + } + + getResolverContent() { + if (this.api.config.esbuild === false) { + return fs.readFileSync(this.config.path, 'utf8') + } + + // process with esbuild + // this will: + // - Bundle the code into one file if necessary + // - Transpile typescript to javascript if necessary + const buildResult = buildSync({ + target: 'esnext', + sourcemap: 'inline', + sourcesContent: false, + treeShaking: true, + // custom config overrides + ...this.api.config.esbuild, + // These options are required and can't be changed + platform: 'node', + format: 'esm', + entryPoints: [this.config.path], + bundle: true, + write: false, + external: ['@aws-appsync/utils'], + }) + + if (buildResult.errors.length > 0) { + throw new this.api.plugin.serverless.classes.Error( + `Failed to compile resolver handler file '${this.config.path}': ${buildResult.errors[0].text}`, + ) + } + + if (buildResult.outputFiles.length === 0) { + throw new this.api.plugin.serverless.classes.Error( + `Failed to compile resolver handler file '${this.config.path}': No output files`, + ) + } + + return buildResult.outputFiles[0].text + } + + processTemplateSubstitutions(template) { + const substitutions = { + ...this.api.config.substitutions, + ...this.config.substitutions, + } + const availableVariables = Object.keys(substitutions) + const templateVariables = [] + let searchResult + const variableSyntax = RegExp(/#([\w\d-_]+)#/g) + while ((searchResult = variableSyntax.exec(template)) !== null) { + templateVariables.push(searchResult[1]) + } + + const replacements = availableVariables + .filter((value) => templateVariables.includes(value)) + .filter((value, index, array) => array.indexOf(value) === index) + .reduce( + (accum, value) => + Object.assign(accum, { [value]: substitutions[value] }), + {}, + ) + + // if there are substitutions for this template then add fn:sub + if (Object.keys(replacements).length > 0) { + return this.substituteGlobalTemplateVariables(template, replacements) + } + + return template + } + + /** + * Creates Fn::Join object from given template where all given substitutions + * are wrapped in Fn::Sub objects. This enables template to have also + * characters that are not only alphanumeric, underscores, periods, and colons. + * + * @param {*} template + * @param {*} substitutions + */ + substituteGlobalTemplateVariables(template, substitutions) { + const variables = Object.keys(substitutions).join('|') + const regex = new RegExp(`\\#(${variables})#`, 'g') + const substituteTemplate = template.replace(regex, '|||$1|||') + + const templateJoin = substituteTemplate + .split('|||') + .filter((part) => part !== '') + const parts = [] + for (let i = 0; i < templateJoin.length; i += 1) { + if (templateJoin[i] in substitutions) { + const subs = { [templateJoin[i]]: substitutions[templateJoin[i]] } + parts[i] = { 'Fn::Sub': [`\${${templateJoin[i]}}`, subs] } + } else { + parts[i] = templateJoin[i] + } + } + return { 'Fn::Join': ['', parts] } + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/MappingTemplate.js b/packages/serverless/lib/plugins/aws/appsync/resources/MappingTemplate.js new file mode 100644 index 000000000..0b70e4d73 --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/MappingTemplate.js @@ -0,0 +1,77 @@ +import fs from 'fs' + +export class MappingTemplate { + constructor(api, config) { + this.api = api + this.config = config + } + + compile() { + if (!fs.existsSync(this.config.path)) { + throw new this.api.plugin.serverless.classes.Error( + `Mapping template file '${this.config.path}' does not exist`, + ) + } + + const requestTemplateContent = fs.readFileSync(this.config.path, 'utf8') + return this.processTemplateSubstitutions(requestTemplateContent) + } + + processTemplateSubstitutions(template) { + const substitutions = { + ...this.api.config.substitutions, + ...this.config.substitutions, + } + const availableVariables = Object.keys(substitutions) + const templateVariables = [] + let searchResult + const variableSyntax = RegExp(/\${([\w\d-_]+)}/g) + while ((searchResult = variableSyntax.exec(template)) !== null) { + templateVariables.push(searchResult[1]) + } + + const replacements = availableVariables + .filter((value) => templateVariables.includes(value)) + .filter((value, index, array) => array.indexOf(value) === index) + .reduce( + (accum, value) => + Object.assign(accum, { [value]: substitutions[value] }), + {}, + ) + + // if there are substitutions for this template then add fn:sub + if (Object.keys(replacements).length > 0) { + return this.substituteGlobalTemplateVariables(template, replacements) + } + + return template + } + + /** + * Creates Fn::Join object from given template where all given substitutions + * are wrapped in Fn::Sub objects. This enables template to have also + * characters that are not only alphanumeric, underscores, periods, and colons. + * + * @param {*} template + * @param {*} substitutions + */ + substituteGlobalTemplateVariables(template, substitutions) { + const variables = Object.keys(substitutions).join('|') + const regex = new RegExp(`\\$\{(${variables})}`, 'g') + const substituteTemplate = template.replace(regex, '|||$1|||') + + const templateJoin = substituteTemplate + .split('|||') + .filter((part) => part !== '') + const parts = [] + for (let i = 0; i < templateJoin.length; i += 1) { + if (templateJoin[i] in substitutions) { + const subs = { [templateJoin[i]]: substitutions[templateJoin[i]] } + parts[i] = { 'Fn::Sub': [`\${${templateJoin[i]}}`, subs] } + } else { + parts[i] = templateJoin[i] + } + } + return { 'Fn::Join': ['', parts] } + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/Naming.js b/packages/serverless/lib/plugins/aws/appsync/resources/Naming.js new file mode 100644 index 000000000..df27081c0 --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/Naming.js @@ -0,0 +1,103 @@ +export class Naming { + constructor(apiName) { + this.apiName = apiName + } + + getCfnName(name) { + return name.replace(/[^a-zA-Z0-9]/g, '') + } + + getLogicalId(name) { + return this.getCfnName(name) + } + + getApiLogicalId() { + return this.getLogicalId(`GraphQlApi`) + } + + getSchemaLogicalId() { + return this.getLogicalId(`GraphQlSchema`) + } + + getDomainNameLogicalId() { + return this.getLogicalId(`GraphQlDomainName`) + } + + getDomainCertificateLogicalId() { + return this.getLogicalId(`GraphQlDomainCertificate`) + } + + getDomainAssociationLogicalId() { + return this.getLogicalId(`GraphQlDomainAssociation`) + } + + getDomainReoute53RecordLogicalId() { + return this.getLogicalId(`GraphQlDomainRoute53Record`) + } + + getLogGroupLogicalId() { + return this.getLogicalId(`GraphQlApiLogGroup`) + } + + getLogGroupRoleLogicalId() { + return this.getLogicalId(`GraphQlApiLogGroupRole`) + } + + getLogGroupPolicyLogicalId() { + return this.getLogicalId(`GraphQlApiLogGroupPolicy`) + } + + getCachingLogicalId() { + return this.getLogicalId(`GraphQlCaching`) + } + + getLambdaAuthLogicalId() { + return this.getLogicalId(`LambdaAuthorizerPermission`) + } + + getApiKeyLogicalId(name) { + return this.getLogicalId(`GraphQlApi${name}`) + } + + // Warning: breaking change. + // api name added + getDataSourceLogicalId(name) { + return `GraphQlDs${this.getLogicalId(name)}` + } + + getDataSourceRoleLogicalId(name) { + return this.getDataSourceLogicalId(`${name}Role`) + } + + getResolverLogicalId(type, field) { + return this.getLogicalId(`GraphQlResolver${type}${field}`) + } + + getPipelineFunctionLogicalId(name) { + return this.getLogicalId(`GraphQlFunctionConfiguration${name}`) + } + + getWafLogicalId() { + return this.getLogicalId('GraphQlWaf') + } + + getWafAssociationLogicalId() { + return this.getLogicalId('GraphQlWafAssoc') + } + + getDataSourceEmbeddedLambdaResolverName(config) { + return config.name + } + + getResolverEmbeddedSyncLambdaName(config) { + if ('name' in config) { + return `${config.name}_Sync` + } else { + return `${config.type}_${config.field}_Sync` + } + } + + getAuthenticationEmbeddedLamdbaName() { + return `${this.apiName}Authorizer` + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/PipelineFunction.js b/packages/serverless/lib/plugins/aws/appsync/resources/PipelineFunction.js new file mode 100644 index 000000000..e6d37258f --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/PipelineFunction.js @@ -0,0 +1,97 @@ +import path from 'path' +import { MappingTemplate } from './MappingTemplate.js' +import { SyncConfig } from './SyncConfig.js' +import { JsResolver } from './JsResolver.js' + +export class PipelineFunction { + constructor(api, config) { + this.api = api + this.config = config + } + + compile() { + const { dataSource, code } = this.config + if (!this.api.hasDataSource(dataSource)) { + throw new this.api.plugin.serverless.classes.Error( + `Pipeline Function '${this.config.name}' references unknown DataSource '${dataSource}'`, + ) + } + + const logicalId = this.api.naming.getPipelineFunctionLogicalId( + this.config.name, + ) + const logicalIdDataSource = this.api.naming.getDataSourceLogicalId( + this.config.dataSource, + ) + + const Properties = { + ApiId: this.api.getApiId(), + Name: this.config.name, + DataSourceName: { 'Fn::GetAtt': [logicalIdDataSource, 'Name'] }, + Description: this.config.description, + FunctionVersion: '2018-05-29', + MaxBatchSize: this.config.maxBatchSize, + } + + if (code) { + Properties.Code = this.resolveJsCode(code) + Properties.Runtime = { + Name: 'APPSYNC_JS', + RuntimeVersion: '1.0.0', + } + } else { + const requestMappingTemplates = this.resolveMappingTemplate('request') + if (requestMappingTemplates) { + Properties.RequestMappingTemplate = requestMappingTemplates + } + + const responseMappingTemplate = this.resolveMappingTemplate('response') + if (responseMappingTemplate) { + Properties.ResponseMappingTemplate = responseMappingTemplate + } + } + + if (this.config.sync) { + const asyncConfig = new SyncConfig(this.api, this.config) + Properties.SyncConfig = asyncConfig.compile() + } + + return { + [logicalId]: { + Type: 'AWS::AppSync::FunctionConfiguration', + Properties, + }, + } + } + + resolveJsCode(filePath) { + const codePath = path.join( + this.api.plugin.serverless.config.servicePath, + filePath, + ) + + const template = new JsResolver(this.api, { + path: codePath, + substitutions: this.config.substitutions, + }) + + return template.compile() + } + + resolveMappingTemplate(type) { + const templateName = this.config[type] + + if (templateName) { + const templatePath = path.join( + this.api.plugin.serverless.config.servicePath, + templateName, + ) + const template = new MappingTemplate(this.api, { + path: templatePath, + substitutions: this.config.substitutions, + }) + + return template.compile() + } + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/Resolver.js b/packages/serverless/lib/plugins/aws/appsync/resources/Resolver.js new file mode 100644 index 000000000..f4ef0a181 --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/Resolver.js @@ -0,0 +1,157 @@ +import path from 'path' +import { MappingTemplate } from './MappingTemplate.js' +import { SyncConfig } from './SyncConfig.js' +import { JsResolver } from './JsResolver.js' + +// A decent default for pipeline JS resolvers +const DEFAULT_JS_RESOLVERS = ` +export function request() { + return {}; +} + +export function response(ctx) { + return ctx.prev.result; +} +` + +export class Resolver { + constructor(api, config) { + this.api = api + this.config = config + } + + compile() { + let Properties = { + ApiId: this.api.getApiId(), + TypeName: this.config.type, + FieldName: this.config.field, + } + + const isVTLResolver = 'request' in this.config || 'response' in this.config + const isJsResolver = + 'code' in this.config || (!isVTLResolver && this.config.kind !== 'UNIT') + + if (isJsResolver) { + if (this.config.code) { + Properties.Code = this.resolveJsCode(this.config.code) + } else { + // default for pipeline JS resolvers + Properties.Code = DEFAULT_JS_RESOLVERS + } + Properties.Runtime = { + Name: 'APPSYNC_JS', + RuntimeVersion: '1.0.0', + } + } else if (isVTLResolver) { + const requestMappingTemplates = this.resolveMappingTemplate('request') + if (requestMappingTemplates) { + Properties.RequestMappingTemplate = requestMappingTemplates + } + + const responseMappingTemplate = this.resolveMappingTemplate('response') + if (responseMappingTemplate) { + Properties.ResponseMappingTemplate = responseMappingTemplate + } + } + + if (this.config.caching) { + if (this.config.caching === true) { + // Use defaults + Properties.CachingConfig = { + Ttl: this.api.config.caching?.ttl || 3600, + } + } else if (typeof this.config.caching === 'object') { + Properties.CachingConfig = { + CachingKeys: this.config.caching.keys, + Ttl: this.config.caching.ttl || this.api.config.caching?.ttl || 3600, + } + } + } + + if (this.config.sync) { + const asyncConfig = new SyncConfig(this.api, this.config) + Properties.SyncConfig = asyncConfig.compile() + } + + if (this.config.kind === 'UNIT') { + const { dataSource } = this.config + if (!this.api.hasDataSource(dataSource)) { + throw new this.api.plugin.serverless.classes.Error( + `Resolver '${this.config.type}.${this.config.field}' references unknown DataSource '${dataSource}'`, + ) + } + + const logicalIdDataSource = + this.api.naming.getDataSourceLogicalId(dataSource) + Properties = { + ...Properties, + Kind: 'UNIT', + DataSourceName: { 'Fn::GetAtt': [logicalIdDataSource, 'Name'] }, + MaxBatchSize: this.config.maxBatchSize, + } + } else { + Properties = { + ...Properties, + Kind: 'PIPELINE', + PipelineConfig: { + Functions: this.config.functions.map((name) => { + if (!this.api.hasPipelineFunction(name)) { + throw new this.api.plugin.serverless.classes.Error( + `Resolver '${this.config.type}.${this.config.field}' references unknown Pipeline function '${name}'`, + ) + } + + const logicalIdDataSource = + this.api.naming.getPipelineFunctionLogicalId(name) + return { 'Fn::GetAtt': [logicalIdDataSource, 'FunctionId'] } + }), + }, + } + } + + const logicalIdResolver = this.api.naming.getResolverLogicalId( + this.config.type, + this.config.field, + ) + const logicalIdGraphQLSchema = this.api.naming.getSchemaLogicalId() + + return { + [logicalIdResolver]: { + Type: 'AWS::AppSync::Resolver', + DependsOn: [logicalIdGraphQLSchema], + Properties, + }, + } + } + + resolveJsCode(filePath) { + const codePath = path.join( + this.api.plugin.serverless.config.servicePath, + filePath, + ) + + const template = new JsResolver(this.api, { + path: codePath, + substitutions: this.config.substitutions, + }) + + return template.compile() + } + + resolveMappingTemplate(type) { + const templateName = this.config[type] + + if (templateName) { + const templatePath = path.join( + this.api.plugin.serverless.config.servicePath, + templateName, + ) + const template = new MappingTemplate(this.api, { + path: templatePath, + substitutions: this.config.substitutions, + }) + + return template.compile() + } + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/Schema.js b/packages/serverless/lib/plugins/aws/appsync/resources/Schema.js new file mode 100644 index 000000000..df860253d --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/Schema.js @@ -0,0 +1,96 @@ +import globby from 'globby' +import fs from 'fs' +import path from 'path' +import _ from 'lodash' +const { flatten } = _ +import { parse, print } from 'graphql' +import { validateSDL } from 'graphql/validation/validate.js' +import { mergeTypeDefs } from '@graphql-tools/merge' + +const AWS_TYPES = ` +directive @aws_iam on FIELD_DEFINITION | OBJECT +directive @aws_oidc on FIELD_DEFINITION | OBJECT +directive @aws_api_key on FIELD_DEFINITION | OBJECT +directive @aws_lambda on FIELD_DEFINITION | OBJECT +directive @aws_auth(cognito_groups: [String]) on FIELD_DEFINITION | OBJECT +directive @aws_cognito_user_pools( + cognito_groups: [String] +) on FIELD_DEFINITION | OBJECT +directive @aws_subscribe(mutations: [String]) on FIELD_DEFINITION +directive @canonical on OBJECT +directive @hidden on OBJECT +directive @renamed on OBJECT +scalar AWSDate +scalar AWSTime +scalar AWSDateTime +scalar AWSTimestamp +scalar AWSEmail +scalar AWSJSON +scalar AWSURL +scalar AWSPhone +scalar AWSIPAddress +` + +export class Schema { + constructor(api, schemas) { + this.api = api + this.schemas = schemas + } + + compile() { + const logicalId = this.api.naming.getSchemaLogicalId() + + return { + [logicalId]: { + Type: 'AWS::AppSync::GraphQLSchema', + Properties: { + Definition: this.generateSchema(), + ApiId: this.api.getApiId(), + }, + }, + } + } + + valdiateSchema(schema) { + const errors = validateSDL(parse(schema)) + if (errors.length > 0) { + throw new this.api.plugin.serverless.classes.Error( + 'Invalid GraphQL schema:\n' + + errors.map((error) => ` ${error.message}`).join('\n'), + ) + } + } + + generateSchema() { + const schemaFiles = flatten( + globby.sync( + this.schemas.map((schema) => + path.join(this.api.plugin.serverless.config.servicePath, schema), + ), + ), + ) + + const schemas = schemaFiles.map((file) => { + return fs.readFileSync(file, 'utf8') + }) + + this.valdiateSchema(AWS_TYPES + '\n' + schemas.join('\n')) + + // Return single files as-is. + if (schemas.length === 1) { + return schemas[0] + } + + // AppSync does not support Object extensions + // https://spec.graphql.org/October2021/#sec-Object-Extensions + // Merge the schemas + return print( + mergeTypeDefs(schemas, { + forceSchemaDefinition: false, + useSchemaDefinition: false, + sort: true, + throwOnConflict: true, + }), + ) + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/SyncConfig.js b/packages/serverless/lib/plugins/aws/appsync/resources/SyncConfig.js new file mode 100644 index 000000000..45398c60c --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/SyncConfig.js @@ -0,0 +1,37 @@ +export class SyncConfig { + constructor(api, config) { + this.api = api + this.config = config + } + + compile() { + if (!this.config.sync) { + return undefined + } + + const { + conflictDetection = 'VERSION', + conflictHandler = 'OPTIMISTIC_CONCURRENCY', + } = this.config.sync + return { + ConflictDetection: conflictDetection, + ...(conflictDetection === 'VERSION' + ? { + ConflictHandler: conflictHandler, + ...(conflictHandler === 'LAMBDA' + ? { + LambdaConflictHandlerConfig: { + LambdaConflictHandlerArn: this.api.getLambdaArn( + this.config.sync, + this.api.naming.getResolverEmbeddedSyncLambdaName( + this.config, + ), + ), + }, + } + : {}), + } + : {}), + } + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/resources/Waf.js b/packages/serverless/lib/plugins/aws/appsync/resources/Waf.js new file mode 100644 index 000000000..5078bebff --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/resources/Waf.js @@ -0,0 +1,316 @@ +import _ from 'lodash' +const { isEmpty, reduce } = _ +import { toCfnKeys } from '../utils.js' + +export class Waf { + constructor(api, config) { + this.api = api + this.config = config + } + + compile() { + const wafConfig = this.config + if (wafConfig.enabled === false) { + return {} + } + const apiLogicalId = this.api.naming.getApiLogicalId() + const wafAssocLogicalId = this.api.naming.getWafAssociationLogicalId() + + if (wafConfig.arn) { + return { + [wafAssocLogicalId]: { + Type: 'AWS::WAFv2::WebACLAssociation', + Properties: { + ResourceArn: { 'Fn::GetAtt': [apiLogicalId, 'Arn'] }, + WebACLArn: wafConfig.arn, + }, + }, + } + } + + const name = wafConfig.name || `${this.api.config.name}Waf` + const wafLogicalId = this.api.naming.getWafLogicalId() + const defaultActionSource = wafConfig.defaultAction || 'Allow' + const defaultAction = + typeof defaultActionSource === 'string' + ? { [defaultActionSource]: {} } + : defaultActionSource + + return { + [wafLogicalId]: { + Type: 'AWS::WAFv2::WebACL', + Properties: { + DefaultAction: defaultAction, + Scope: 'REGIONAL', + Description: + wafConfig.description || + `ACL rules for AppSync ${this.api.config.name}`, + Name: name, + Rules: this.buildWafRules(), + VisibilityConfig: this.getWafVisibilityConfig( + this.config.visibilityConfig, + name, + ), + Tags: this.api.getTagsConfig(), + }, + }, + [wafAssocLogicalId]: { + Type: 'AWS::WAFv2::WebACLAssociation', + Properties: { + ResourceArn: { 'Fn::GetAtt': [apiLogicalId, 'Arn'] }, + WebACLArn: { 'Fn::GetAtt': [wafLogicalId, 'Arn'] }, + }, + }, + } + } + + buildWafRules() { + const rules = this.config.rules || [] + + let defaultPriority = 100 + return rules + .map((rule) => this.buildWafRule(rule)) + .concat(this.buildApiKeysWafRules()) + .map((rule) => ({ + ...rule, + Priority: rule.Priority || defaultPriority++, + })) + } + + buildWafRule(rule, defaultNamePrefix) { + // Throttle pre-set rule + if (rule === 'throttle') { + return this.buildThrottleRule({}, defaultNamePrefix) + } else if (typeof rule === 'object' && 'throttle' in rule) { + return this.buildThrottleRule(rule.throttle, defaultNamePrefix) + } + + // Disable Introspection pre-set rule + if (rule === 'disableIntrospection') { + return this.buildDisableIntrospectionRule({}, defaultNamePrefix) + } else if ('disableIntrospection' in rule) { + return this.buildDisableIntrospectionRule( + rule.disableIntrospection, + defaultNamePrefix, + ) + } + + const action = rule.action || 'Allow' + const overrideAction = rule.overrideAction + + const result = { + Name: rule.name, + Priority: rule.priority, + Statement: rule.statement, + VisibilityConfig: this.getWafVisibilityConfig( + rule.visibilityConfig, + rule.name, + ), + } + + if (overrideAction) { + result.OverrideAction = toCfnKeys(overrideAction) + } else { + result.Action = { [action]: {} } + } + + return result + } + + buildApiKeysWafRules() { + return ( + reduce( + this.api.config.apiKeys, + (rules, key) => rules.concat(this.buildApiKeyRules(key)), + [], + ) || [] + ) + } + + buildApiKeyRules(key) { + const rules = key.wafRules + // Build the rule and add a matching rule for the X-Api-Key header + // for the given api key + const allRules = [] + rules?.forEach((keyRule) => { + const builtRule = this.buildWafRule(keyRule, key.name) + const logicalIdApiKey = this.api.naming.getApiKeyLogicalId(key.name) + const { Statement: baseStatement } = builtRule + const apiKeyStatement = { + ByteMatchStatement: { + FieldToMatch: { + SingleHeader: { Name: 'X-Api-key' }, + }, + PositionalConstraint: 'EXACTLY', + SearchString: { 'Fn::GetAtt': [logicalIdApiKey, 'ApiKey'] }, + TextTransformations: [ + { + Type: 'LOWERCASE', + Priority: 0, + }, + ], + }, + } + + let statement + if (baseStatement && baseStatement?.RateBasedStatement) { + let ScopeDownStatement + // For RateBasedStatement, use the api rule as ScopeDownStatement + // merge if with existing needed + if (baseStatement.RateBasedStatement?.ScopeDownStatement) { + ScopeDownStatement = this.mergeWafRuleStatements([ + baseStatement.RateBasedStatement.ScopeDownStatement, + apiKeyStatement, + ]) + } else { + ScopeDownStatement = apiKeyStatement + } + // RateBasedStatement + statement = { + RateBasedStatement: { + ...baseStatement.RateBasedStatement, + ScopeDownStatement, + }, + } + } else if (!isEmpty(baseStatement)) { + // Other rules: combine them (And Statement) + statement = this.mergeWafRuleStatements([ + baseStatement, + apiKeyStatement, + ]) + } else { + // No statement, the rule is the API key rule itself + statement = apiKeyStatement + } + + allRules.push({ + ...builtRule, + Statement: statement, + }) + }) + + return allRules + } + + mergeWafRuleStatements(statements) { + return { + AndStatement: { + Statements: statements, + }, + } + } + + getWafVisibilityConfig(visibilityConfig = {}, defaultName) { + return { + CloudWatchMetricsEnabled: + visibilityConfig.cloudWatchMetricsEnabled ?? + this.config.visibilityConfig?.cloudWatchMetricsEnabled ?? + true, + MetricName: visibilityConfig.name || defaultName, + SampledRequestsEnabled: + visibilityConfig.sampledRequestsEnabled ?? + this.config.visibilityConfig?.sampledRequestsEnabled ?? + true, + } + } + + buildDisableIntrospectionRule(config, defaultNamePrefix) { + const Name = config.name || `${defaultNamePrefix || ''}DisableIntrospection` + + return { + Action: { + Block: {}, + }, + Name, + Priority: config.priority, + Statement: { + OrStatement: { + Statements: [ + { + // Block all requests > 8kb + // https://docs.aws.amazon.com/waf/latest/developerguide/web-request-body-inspection.html + SizeConstraintStatement: { + ComparisonOperator: 'GT', + FieldToMatch: { + Body: {}, + }, + Size: 8 * 1024, + TextTransformations: [ + { + Type: 'NONE', + Priority: 0, + }, + ], + }, + }, + { + ByteMatchStatement: { + FieldToMatch: { + Body: {}, + }, + PositionalConstraint: 'CONTAINS', + SearchString: '__schema', + TextTransformations: [ + { + Type: 'COMPRESS_WHITE_SPACE', + Priority: 0, + }, + ], + }, + }, + ], + }, + }, + VisibilityConfig: this.getWafVisibilityConfig( + typeof config === 'object' ? config.visibilityConfig : undefined, + Name, + ), + } + } + + buildThrottleRule(config, defaultNamePrefix) { + let Name = `${defaultNamePrefix || ''}Throttle` + let Limit = 100 + let AggregateKeyType = 'IP' + let ForwardedIPConfig + let Priority + let ScopeDownStatement + + if (typeof config === 'number') { + Limit = config + } else if (typeof config === 'object') { + Name = config.name || Name + AggregateKeyType = config.aggregateKeyType || AggregateKeyType + Limit = config.limit || Limit + Priority = config.priority + ScopeDownStatement = config.scopeDownStatement + if (AggregateKeyType === 'FORWARDED_IP') { + ForwardedIPConfig = { + HeaderName: config.forwardedIPConfig?.headerName || 'X-Forwarded-For', + FallbackBehavior: + config.forwardedIPConfig?.fallbackBehavior || 'MATCH', + } + } + } + + return { + Action: { + Block: {}, + }, + Name, + Priority, + Statement: { + RateBasedStatement: { + AggregateKeyType, + Limit, + ForwardedIPConfig, + ScopeDownStatement, + }, + }, + VisibilityConfig: this.getWafVisibilityConfig( + typeof config === 'object' ? config.visibilityConfig : undefined, + Name, + ), + } + } +} diff --git a/packages/serverless/lib/plugins/aws/appsync/utils.js b/packages/serverless/lib/plugins/aws/appsync/utils.js new file mode 100644 index 000000000..949026311 --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/utils.js @@ -0,0 +1,113 @@ +import _ from 'lodash' +const { upperFirst, transform, values } = _ +import { DateTime, Duration } from 'luxon' +import { promisify } from 'util' +import * as readline from 'readline' + +export const timeUnits = { + y: 'years', + q: 'quarters', + M: 'months', + w: 'weeks', + d: 'days', + h: 'hours', + m: 'minutes', + s: 'seconds', + ms: 'milliseconds', +} + +const isRecord = (value) => { + return typeof value === 'object' +} + +export const toCfnKeys = (object) => + transform(object, (acc, value, key) => { + const newKey = typeof key === 'string' ? upperFirst(key) : key + + acc[newKey] = isRecord(value) ? toCfnKeys(value) : value + + return acc + }) + +export const wait = async (time) => { + await new Promise((resolve) => setTimeout(resolve, time)) +} + +export const parseDateTimeOrDuration = (input) => { + try { + // Try to parse a date + let date = DateTime.fromISO(input) + if (!date.isValid) { + // try to parse duration + date = DateTime.now().minus(parseDuration(input)) + } + + return date + } catch (error) { + throw new Error('Invalid date or duration') + } +} + +export const parseDuration = (input) => { + let duration + if (typeof input === 'number') { + duration = Duration.fromDurationLike({ hours: input }) + } else if (typeof input === 'string') { + const regexp = new RegExp(`^(\\d+)(${Object.keys(timeUnits).join('|')})?$`) + const match = input.match(regexp) + if (match) { + let amount = parseInt(match[1], 10) + let unit = timeUnits[match[2]] || 'hours' + + // 1 year could be 366 days on or before leap year, + // which would fail. Swap for 365 days + if (input.match(/^1y(ears?)?$/)) { + amount = 365 + unit = 'days' + } + + duration = Duration.fromDurationLike({ [unit]: amount }) + } else { + throw new Error(`Could not parse ${input} as a valid duration`) + } + } else { + throw new Error(`Could not parse ${input} as a valid duration`) + } + + return duration +} + +export const getHostedZoneName = (domain) => { + const parts = domain.split('.') + if (parts.length > 2) { + parts.shift() + } + return `${parts.join('.')}.` +} + +export const getWildCardDomainName = (domain) => { + return `*.${domain.split('.').slice(1).join('.')}` +} + +export const question = async (questionText) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + const q = promisify((questionText, cb) => { + rl.question(questionText, (a) => { + cb(null, a) + }) + }).bind(rl) + + const answer = await q(`${questionText}: `) + rl.close() + + return answer +} + +export const confirmAction = async () => { + const answer = await question('Do you want to continue? y/N') + + return answer.toLowerCase() === 'y' +} diff --git a/packages/serverless/lib/plugins/aws/appsync/validation.js b/packages/serverless/lib/plugins/aws/appsync/validation.js new file mode 100644 index 000000000..90dcdbed7 --- /dev/null +++ b/packages/serverless/lib/plugins/aws/appsync/validation.js @@ -0,0 +1,904 @@ +import Ajv from 'ajv' +import ajvErrors from 'ajv-errors' +import addFormats from 'ajv-formats' +import { timeUnits } from './utils.js' + +const AUTH_TYPES = [ + 'AMAZON_COGNITO_USER_POOLS', + 'AWS_LAMBDA', + 'OPENID_CONNECT', + 'AWS_IAM', + 'API_KEY', +] + +const DATASOURCE_TYPES = [ + 'AMAZON_DYNAMODB', + 'AMAZON_OPENSEARCH_SERVICE', + 'AWS_LAMBDA', + 'HTTP', + 'NONE', + 'RELATIONAL_DATABASE', + 'AMAZON_EVENTBRIDGE', +] + +export const appSyncSchema = { + type: 'object', + definitions: { + stringOrIntrinsicFunction: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + required: [], + additionalProperties: true, + }, + ], + errorMessage: 'must be a string or a CloudFormation intrinsic function', + }, + lambdaFunctionConfig: { + oneOf: [ + { + type: 'object', + properties: { + functionName: { type: 'string' }, + functionAlias: { type: 'string' }, + }, + required: ['functionName'], + }, + { + type: 'object', + properties: { + functionArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + }, + required: ['functionArn'], + }, + { + type: 'object', + properties: { + function: { type: 'object' }, + }, + required: ['function'], + }, + ], + errorMessage: + 'must specify functionName, functionArn or function (all exclusives)', + }, + auth: { + type: 'object', + title: 'Authentication', + description: 'Authentication type and definition', + properties: { + type: { + type: 'string', + enum: AUTH_TYPES, + errorMessage: `must be one of ${AUTH_TYPES.join(', ')}`, + }, + }, + if: { properties: { type: { const: 'AMAZON_COGNITO_USER_POOLS' } } }, + then: { + properties: { config: { $ref: '#/definitions/cognitoAuth' } }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'AWS_LAMBDA' } } }, + then: { + properties: { config: { $ref: '#/definitions/lambdaAuth' } }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'OPENID_CONNECT' } } }, + then: { + properties: { config: { $ref: '#/definitions/oidcAuth' } }, + required: ['config'], + }, + }, + }, + required: ['type'], + }, + cognitoAuth: { + type: 'object', + properties: { + userPoolId: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + awsRegion: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + defaultAction: { + type: 'string', + enum: ['ALLOW', 'DENY'], + errorMessage: 'must be "ALLOW" or "DENY"', + }, + appIdClientRegex: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + required: ['userPoolId'], + }, + lambdaAuth: { + type: 'object', + oneOf: [{ $ref: '#/definitions/lambdaFunctionConfig' }], + properties: { + // Note: functionName and functionArn are already defined in #/definitions/lambdaFunctionConfig + // But if not also defined here, TypeScript shows an error. + functionName: { type: 'string' }, + functionArn: { type: 'string' }, + identityValidationExpression: { type: 'string' }, + authorizerResultTtlInSeconds: { type: 'number' }, + }, + required: [], + }, + oidcAuth: { + type: 'object', + properties: { + issuer: { type: 'string' }, + clientId: { type: 'string' }, + iatTTL: { type: 'number' }, + authTTL: { type: 'number' }, + }, + required: ['issuer'], + }, + iamAuth: { + type: 'object', + properties: { + type: { + type: 'string', + const: 'AWS_IAM', + }, + }, + required: ['type'], + errorMessage: 'must be a valid IAM config', + }, + apiKeyAuth: { + type: 'object', + properties: { + type: { + type: 'string', + const: 'API_KEY', + }, + }, + required: ['type'], + errorMessage: 'must be a valid API_KEY config', + }, + visibilityConfig: { + type: 'object', + properties: { + cloudWatchMetricsEnabled: { type: 'boolean' }, + name: { type: 'string' }, + sampledRequestsEnabled: { type: 'boolean' }, + }, + required: [], + }, + wafRule: { + anyOf: [ + { type: 'string', enum: ['throttle', 'disableIntrospection'] }, + { + type: 'object', + properties: { + disableIntrospection: { + type: 'object', + properties: { + name: { type: 'string' }, + priority: { type: 'integer' }, + visibilityConfig: { $ref: '#/definitions/visibilityConfig' }, + }, + }, + }, + required: ['disableIntrospection'], + }, + { + type: 'object', + properties: { + throttle: { + oneOf: [ + { type: 'integer', minimum: 100 }, + { + type: 'object', + properties: { + name: { type: 'string' }, + action: { + type: 'string', + enum: ['Allow', 'Block'], + }, + aggregateKeyType: { + type: 'string', + enum: ['IP', 'FORWARDED_IP'], + }, + limit: { type: 'integer', minimum: 100 }, + priority: { type: 'integer' }, + scopeDownStatement: { type: 'object' }, + forwardedIPConfig: { + type: 'object', + properties: { + headerName: { + type: 'string', + pattern: '^[a-zA-Z0-9-]+$', + }, + fallbackBehavior: { + type: 'string', + enum: ['MATCH', 'NO_MATCH'], + }, + }, + required: ['headerName', 'fallbackBehavior'], + }, + visibilityConfig: { + $ref: '#/definitions/visibilityConfig', + }, + }, + required: [], + }, + ], + }, + }, + required: ['throttle'], + }, + { $ref: '#/definitions/customWafRule' }, + ], + errorMessage: 'must be a valid WAF rule', + }, + customWafRule: { + type: 'object', + properties: { + name: { type: 'string' }, + priority: { type: 'number' }, + action: { + type: 'string', + enum: ['Allow', 'Block', 'Count', 'Captcha'], + }, + statement: { type: 'object', required: [] }, + visibilityConfig: { $ref: '#/definitions/visibilityConfig' }, + }, + required: ['name', 'statement'], + }, + substitutions: { + type: 'object', + additionalProperties: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + required: [], + errorMessage: 'must be a valid substitutions definition', + }, + environment: { + type: 'object', + additionalProperties: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + required: [], + errorMessage: 'must be a valid environment definition', + }, + dataSource: { + if: { type: 'object' }, + then: { $ref: '#/definitions/dataSourceConfig' }, + else: { + type: 'string', + errorMessage: 'must be a string or data source definition', + }, + }, + resolverConfig: { + type: 'object', + properties: { + kind: { + type: 'string', + enum: ['UNIT', 'PIPELINE'], + errorMessage: 'must be "UNIT" or "PIPELINE"', + }, + type: { type: 'string' }, + field: { type: 'string' }, + maxBatchSize: { type: 'number', minimum: 1, maximum: 2000 }, + code: { type: 'string' }, + request: { type: 'string' }, + response: { type: 'string' }, + sync: { $ref: '#/definitions/syncConfig' }, + substitutions: { $ref: '#/definitions/substitutions' }, + caching: { $ref: '#/definitions/resolverCachingConfig' }, + }, + if: { properties: { kind: { const: 'UNIT' } }, required: ['kind'] }, + then: { + properties: { + dataSource: { $ref: '#/definitions/dataSource' }, + }, + required: ['dataSource'], + }, + else: { + properties: { + functions: { + type: 'array', + items: { $ref: '#/definitions/pipelineFunction' }, + }, + }, + required: ['functions'], + }, + required: [], + }, + resolverConfigMap: { + type: 'object', + patternProperties: { + // Type.field keys, type and field are not required + '^[_A-Za-z][_0-9A-Za-z]*\\.[_A-Za-z][_0-9A-Za-z]*$': { + $ref: '#/definitions/resolverConfig', + }, + }, + additionalProperties: { + // Other keys, type and field are required + allOf: [ + { $ref: '#/definitions/resolverConfig' }, + { type: 'object', required: ['type', 'field'] }, + ], + errorMessage: { + required: { + type: 'resolver definitions that do not specify Type.field in the key must specify the type and field as properties', + field: + 'resolver definitions that do not specify Type.field in the key must specify the type and field as properties', + }, + }, + }, + required: [], + }, + pipelineFunctionConfig: { + type: 'object', + properties: { + dataSource: { $ref: '#/definitions/dataSource' }, + description: { type: 'string' }, + request: { type: 'string' }, + response: { type: 'string' }, + sync: { $ref: '#/definitions/syncConfig' }, + maxBatchSize: { type: 'number', minimum: 1, maximum: 2000 }, + substitutions: { $ref: '#/definitions/substitutions' }, + }, + required: ['dataSource'], + }, + pipelineFunction: { + if: { type: 'object' }, + then: { $ref: '#/definitions/pipelineFunctionConfig' }, + else: { + type: 'string', + errorMessage: 'must be a string or function definition', + }, + }, + pipelineFunctionConfigMap: { + type: 'object', + additionalProperties: { + if: { type: 'object' }, + then: { $ref: '#/definitions/pipelineFunctionConfig' }, + else: { + type: 'string', + errorMessage: 'must be a string or an object', + }, + }, + required: [], + }, + resolverCachingConfig: { + oneOf: [ + { type: 'boolean' }, + { + type: 'object', + properties: { + ttl: { type: 'integer', minimum: 1, maximum: 3600 }, + keys: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: [], + }, + ], + errorMessage: 'must be a valid resolver caching config', + }, + syncConfig: { + type: 'object', + if: { properties: { conflictHandler: { const: ['LAMBDA'] } } }, + then: { $ref: '#/definitions/lambdaFunctionConfig' }, + properties: { + functionArn: { type: 'string' }, + functionName: { type: 'string' }, + conflictDetection: { type: 'string', enum: ['VERSION', 'NONE'] }, + conflictHandler: { + type: 'string', + enum: ['LAMBDA', 'OPTIMISTIC_CONCURRENCY', 'AUTOMERGE'], + }, + }, + required: [], + }, + iamRoleStatements: { + type: 'array', + items: { + type: 'object', + properties: { + Effect: { type: 'string', enum: ['Allow', 'Deny'] }, + Action: { type: 'array', items: { type: 'string' } }, + Resource: { + oneOf: [ + { $ref: '#/definitions/stringOrIntrinsicFunction' }, + { + type: 'array', + items: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + ], + errorMessage: 'contains invalid resolver definitions', + }, + }, + required: ['Effect', 'Action', 'Resource'], + errorMessage: 'must be a valid IAM role statement', + }, + }, + dataSourceConfig: { + type: 'object', + properties: { + type: { + type: 'string', + enum: DATASOURCE_TYPES, + errorMessage: `must be one of ${DATASOURCE_TYPES.join(', ')}`, + }, + description: { type: 'string' }, + }, + if: { properties: { type: { const: 'AMAZON_DYNAMODB' } } }, + then: { + properties: { config: { $ref: '#/definitions/dataSourceDynamoDb' } }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'AWS_LAMBDA' } } }, + then: { + properties: { + config: { $ref: '#/definitions/datasourceLambdaConfig' }, + }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'HTTP' } } }, + then: { + properties: { + config: { $ref: '#/definitions/dataSourceHttpConfig' }, + }, + required: ['config'], + }, + else: { + if: { + properties: { + type: { const: 'AMAZON_OPENSEARCH_SERVICE' }, + }, + }, + then: { + properties: { + config: { $ref: '#/definitions/datasourceEsConfig' }, + }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'RELATIONAL_DATABASE' } } }, + then: { + properties: { + config: { + $ref: '#/definitions/datasourceRelationalDbConfig', + }, + }, + required: ['config'], + }, + else: { + if: { properties: { type: { const: 'AMAZON_EVENTBRIDGE' } } }, + then: { + properties: { + config: { + $ref: '#/definitions/datasourceEventBridgeConfig', + }, + }, + required: ['config'], + }, + }, + }, + }, + }, + }, + required: ['type'], + }, + dataSourceHttpConfig: { + type: 'object', + properties: { + endpoint: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + serviceRoleArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + iamRoleStatements: { + $ref: '#/definitions/iamRoleStatements', + }, + authorizationConfig: { + type: 'object', + properties: { + authorizationType: { + type: 'string', + enum: ['AWS_IAM'], + errorMessage: 'must be AWS_IAM', + }, + awsIamConfig: { + type: 'object', + properties: { + signingRegion: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + signingServiceName: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + }, + required: ['signingRegion'], + }, + }, + required: ['authorizationType', 'awsIamConfig'], + }, + }, + required: ['endpoint'], + }, + dataSourceDynamoDb: { + type: 'object', + properties: { + tableName: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + useCallerCredentials: { type: 'boolean' }, + serviceRoleArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + region: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + iamRoleStatements: { + $ref: '#/definitions/iamRoleStatements', + }, + versioned: { type: 'boolean' }, + deltaSyncConfig: { + type: 'object', + properties: { + deltaSyncTableName: { type: 'string' }, + baseTableTTL: { type: 'integer' }, + deltaSyncTableTTL: { type: 'integer' }, + }, + required: ['deltaSyncTableName'], + }, + }, + required: ['tableName'], + }, + datasourceRelationalDbConfig: { + type: 'object', + properties: { + region: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + relationalDatabaseSourceType: { + type: 'string', + enum: ['RDS_HTTP_ENDPOINT'], + }, + serviceRoleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + dbClusterIdentifier: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + databaseName: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + schema: { type: 'string' }, + awsSecretStoreArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + iamRoleStatements: { + $ref: '#/definitions/iamRoleStatements', + }, + }, + required: ['awsSecretStoreArn', 'dbClusterIdentifier'], + }, + datasourceLambdaConfig: { + type: 'object', + oneOf: [ + { + $ref: '#/definitions/lambdaFunctionConfig', + }, + ], + properties: { + functionName: { type: 'string' }, + functionArn: { + $ref: '#/definitions/stringOrIntrinsicFunction', + }, + serviceRoleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + iamRoleStatements: { $ref: '#/definitions/iamRoleStatements' }, + }, + required: [], + }, + datasourceEsConfig: { + type: 'object', + oneOf: [ + { + oneOf: [ + { + type: 'object', + properties: { + endpoint: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + required: ['endpoint'], + }, + { + type: 'object', + properties: { + domain: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + required: ['domain'], + }, + ], + errorMessage: 'must have a endpoint or domain (but not both)', + }, + ], + properties: { + endpoint: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + domain: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + region: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + serviceRoleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + iamRoleStatements: { $ref: '#/definitions/iamRoleStatements' }, + }, + required: [], + }, + datasourceEventBridgeConfig: { + type: 'object', + properties: { + eventBusArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + required: ['eventBusArn'], + }, + }, + properties: { + name: { type: 'string' }, + authentication: { $ref: '#/definitions/auth' }, + schema: { + anyOf: [ + { + type: 'string', + }, + { + type: 'array', + items: { type: 'string' }, + }, + ], + errorMessage: 'must be a valid schema config', + }, + domain: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + useCloudFormation: { type: 'boolean' }, + retain: { type: 'boolean' }, + name: { + type: 'string', + pattern: '^([a-z][a-z0-9+-]*\\.)+[a-z][a-z0-9]*$', + errorMessage: 'must be a valid domain name', + }, + certificateArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + hostedZoneId: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + hostedZoneName: { + type: 'string', + pattern: '^([a-z][a-z0-9+-]*\\.)+[a-z][a-z0-9]*\\.$', + errorMessage: + 'must be a valid zone name. Note: you must include a trailing dot (eg: `example.com.`)', + }, + route53: { type: 'boolean' }, + }, + required: ['name'], + if: { + anyOf: [ + { + not: { properties: { useCloudFormation: { const: false } } }, + }, + { not: { required: ['useCloudFormation'] } }, + ], + }, + then: { + anyOf: [ + { required: ['certificateArn'] }, + { required: ['hostedZoneId'] }, + ], + errorMessage: + 'when using CloudFormation, you must provide either certificateArn or hostedZoneId.', + }, + }, + xrayEnabled: { type: 'boolean' }, + visibility: { + type: 'string', + enum: ['GLOBAL', 'PRIVATE'], + errorMessage: 'must be "GLOBAL" or "PRIVATE"', + }, + introspection: { type: 'boolean' }, + queryDepthLimit: { type: 'integer', minimum: 1, maximum: 75 }, + resolverCountLimit: { type: 'integer', minimum: 1, maximum: 1000 }, + substitutions: { $ref: '#/definitions/substitutions' }, + environment: { $ref: '#/definitions/environment' }, + waf: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + }, + if: { + required: ['arn'], + }, + then: { + properties: { + arn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + }, + }, + else: { + properties: { + name: { type: 'string' }, + defaultAction: { + type: 'string', + enum: ['Allow', 'Block'], + errorMessage: "must be 'Allow' or 'Block'", + }, + description: { type: 'string' }, + rules: { + type: 'array', + items: { $ref: '#/definitions/wafRule' }, + }, + }, + required: ['rules'], + }, + }, + tags: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + caching: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + behavior: { + type: 'string', + enum: ['FULL_REQUEST_CACHING', 'PER_RESOLVER_CACHING'], + errorMessage: + "must be one of 'FULL_REQUEST_CACHING', 'PER_RESOLVER_CACHING'", + }, + type: { + enum: [ + 'SMALL', + 'MEDIUM', + 'LARGE', + 'XLARGE', + 'LARGE_2X', + 'LARGE_4X', + 'LARGE_8X', + 'LARGE_12X', + ], + errorMessage: + "must be one of 'SMALL', 'MEDIUM', 'LARGE', 'XLARGE', 'LARGE_2X', 'LARGE_4X', 'LARGE_8X', 'LARGE_12X'", + }, + ttl: { type: 'integer', minimum: 1, maximum: 3600 }, + atRestEncryption: { type: 'boolean' }, + transitEncryption: { type: 'boolean' }, + }, + required: ['behavior'], + }, + additionalAuthentications: { + type: 'array', + items: { $ref: '#/definitions/auth' }, + }, + apiKeys: { + type: 'array', + items: { + if: { type: 'object' }, + then: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + expiresAfter: { + type: ['string', 'number'], + pattern: `^(\\d+)(${Object.keys(timeUnits).join('|')})?$`, + errorMessage: 'must be a valid duration.', + }, + expiresAt: { + type: 'string', + format: 'date-time', + errorMessage: 'must be a valid date-time', + }, + wafRules: { + type: 'array', + items: { $ref: '#/definitions/wafRule' }, + }, + }, + required: ['name'], + }, + else: { + type: 'string', + }, + }, + }, + logging: { + type: 'object', + properties: { + roleArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + level: { + type: 'string', + enum: ['ALL', 'INFO', 'DEBUG', 'ERROR', 'NONE'], + errorMessage: + "must be one of 'ALL', 'INFO', 'DEBUG', 'ERROR' or 'NONE'", + }, + retentionInDays: { type: 'integer' }, + excludeVerboseContent: { type: 'boolean' }, + enabled: { type: 'boolean' }, + }, + required: ['level'], + }, + dataSources: { + oneOf: [ + { + type: 'object', + additionalProperties: { $ref: '#/definitions/dataSourceConfig' }, + }, + { + type: 'array', + items: { + type: 'object', + additionalProperties: { $ref: '#/definitions/dataSourceConfig' }, + }, + }, + ], + errorMessage: 'contains invalid data source definitions', + }, + resolvers: { + oneOf: [ + { $ref: '#/definitions/resolverConfigMap' }, + { + type: 'array', + items: { $ref: '#/definitions/resolverConfigMap' }, + }, + ], + errorMessage: 'contains invalid resolver definitions', + }, + pipelineFunctions: { + oneOf: [ + { + $ref: '#/definitions/pipelineFunctionConfigMap', + }, + { + type: 'array', + items: { + $ref: '#/definitions/pipelineFunctionConfigMap', + }, + }, + ], + errorMessage: 'contains invalid pipeline function definitions', + }, + esbuild: { + oneOf: [ + { + type: 'object', + }, + { const: false }, + ], + errorMessage: 'must be an esbuild config object or false', + }, + }, + required: ['name', 'authentication'], + additionalProperties: { + not: true, + errorMessage: 'invalid (unknown) property', + }, +} + +const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }) +ajvErrors(ajv) +addFormats(ajv) + +const validator = ajv.compile(appSyncSchema) + +export const validateConfig = (data) => { + const isValid = validator(data) + if (isValid === false && validator.errors) { + throw new AppSyncValidationError( + validator.errors + .filter((error) => !['if', 'oneOf', 'anyOf'].includes(error.keyword)) + .map((error) => { + return { + path: error.instancePath, + message: error.message || 'unknown error', + } + }), + ) + } + + return isValid +} + +export class AppSyncValidationError extends Error { + constructor(validationErrors) { + super( + validationErrors + .map((error) => `${error.path}: ${error.message}`) + .join('\n'), + ) + this.validationErrors = validationErrors + Object.setPrototypeOf(this, AppSyncValidationError.prototype) + } +} diff --git a/packages/serverless/package.json b/packages/serverless/package.json index c96b411c9..5654951a3 100644 --- a/packages/serverless/package.json +++ b/packages/serverless/package.json @@ -34,12 +34,14 @@ "@aws-sdk/client-sts": "3.958.0", "@aws-sdk/credential-providers": "3.958.0", "@aws-sdk/lib-storage": "3.958.0", + "@graphql-tools/merge": "^8.3.12", "@iarna/toml": "^2.2.5", "@serverlessinc/sf-core": "*", "@smithy/node-http-handler": "^4.4.5", "@smithy/util-retry": "^4.2.6", "adm-zip": "^0.5.16", "ajv": "8.17.1", + "ajv-errors": "^3.0.0", "ajv-formats": "2.1.1", "appdirectory": "^0.1.0", "archiver": "^7.0.1", @@ -58,6 +60,7 @@ "get-stdin": "^9.0.0", "glob": "^10.5.0", "globby": "^11.1.0", + "graphql": "^16.6.0", "https-proxy-agent": "^7.0.6", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", @@ -67,6 +70,7 @@ "jszip": "^3.10.1", "lodash": "^4.17.21", "lodash.uniqby": "^4.7.0", + "luxon": "^2.5.0", "memoizee": "^0.4.17", "micromatch": "^4.0.8", "object-hash": "^3.0.0", @@ -82,6 +86,7 @@ "sha256-file": "^1.0.0", "shell-quote": "^1.8.3", "strip-ansi": "^7.1.2", + "terminal-link": "^2.1.1", "timers-ext": "^0.1.8", "toml": "^3.0.0", "tsx": "^4.21.0", diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/api.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/api.test.js.snap new file mode 100644 index 000000000..44c4e5311 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/api.test.js.snap @@ -0,0 +1,747 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Api LambdaAuthorizer should generate the Lambda Authorizer Resources from additional auth 1`] = ` +{ + "LambdaAuthorizerPermission": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": "arn:", + "Principal": "appsync.amazonaws.com", + "SourceArn": { + "Ref": "GraphQlApi", + }, + }, + "Type": "AWS::Lambda::Permission", + }, +} +`; + +exports[`Api LambdaAuthorizer should generate the Lambda Authorizer Resources from basic auth 1`] = ` +{ + "LambdaAuthorizerPermission": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": "arn:", + "Principal": "appsync.amazonaws.com", + "SourceArn": { + "Ref": "GraphQlApi", + }, + }, + "Type": "AWS::Lambda::Permission", + }, +} +`; + +exports[`Api LambdaAuthorizer should not generate the Lambda Authorizer Resources 1`] = `{}`; + +exports[`Api Logs should compile CloudWatch Resources when enabled 1`] = ` +{ + "GraphQlApiLogGroup": { + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "/", + [ + "/aws/appsync/apis", + { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + ], + ], + }, + "RetentionInDays": 14, + }, + "Type": "AWS::Logs::LogGroup", + }, + "GraphQlApiLogGroupPolicy": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "GraphQlApiLogGroup", + "Arn", + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "GraphQlApiLogGroupPolicy", + "Roles": [ + { + "Ref": "GraphQlApiLogGroupRole", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "GraphQlApiLogGroupRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`Api Logs should not compile CloudWatch Resources when logging is disabled 1`] = `{}`; + +exports[`Api Logs should not compile CloudWatch Resources when logging not configured 1`] = `{}`; + +exports[`Api apiKeys should generate an api key with default expiry 1`] = ` +{ + "GraphQlApiDefault": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "ApiKeyId": undefined, + "Description": "Default Key", + "Expires": 1639065600, + }, + "Type": "AWS::AppSync::ApiKey", + }, +} +`; + +exports[`Api apiKeys should generate an api key with explicit expiresAt 1`] = ` +{ + "GraphQlApiDefault": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "ApiKeyId": undefined, + "Description": "Default Key", + "Expires": 1672524000, + }, + "Type": "AWS::AppSync::ApiKey", + }, +} +`; + +exports[`Api apiKeys should generate an api key with sliding window expiration in duration 1`] = ` +{ + "GraphQlApiDefault": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "ApiKeyId": undefined, + "Description": "Default Key", + "Expires": 1610121600, + }, + "Type": "AWS::AppSync::ApiKey", + }, +} +`; + +exports[`Api apiKeys should generate an api key with sliding window expiration in numeric hours 1`] = ` +{ + "GraphQlApiDefault": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "ApiKeyId": undefined, + "Description": "Default Key", + "Expires": 1607619600, + }, + "Type": "AWS::AppSync::ApiKey", + }, +} +`; + +exports[`Api apiKeys should generate an api key with sliding window expiration in string hours 1`] = ` +{ + "GraphQlApiDefault": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "ApiKeyId": undefined, + "Description": "Default Key", + "Expires": 1607619600, + }, + "Type": "AWS::AppSync::ApiKey", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource 1`] = ` +{ + "GraphQlApi": { + "Properties": { + "AuthenticationType": "API_KEY", + "EnvironmentVariables": undefined, + "Name": "MyApi", + "Tags": [ + { + "Key": "stage", + "Value": "Dev", + }, + ], + "XrayEnabled": false, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource for a private endpoint 1`] = ` +{ + "GraphQlApi": { + "Properties": { + "AuthenticationType": "API_KEY", + "EnvironmentVariables": undefined, + "Name": "MyApi", + "Tags": [ + { + "Key": "stage", + "Value": "Dev", + }, + ], + "Visibility": "PRIVATE", + "XrayEnabled": false, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource with Environments 1`] = ` +{ + "GraphQlApi": { + "Properties": { + "AuthenticationType": "API_KEY", + "EnvironmentVariables": { + "OTHER_TABLE": { + "Ref": "OtherTable", + }, + "TABLE_NAME": "MyTable", + }, + "Name": "MyApi", + "Tags": [ + { + "Key": "stage", + "Value": "Dev", + }, + ], + "XrayEnabled": false, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource with additional auths 1`] = ` +{ + "GraphQlApi": { + "Properties": { + "AdditionalAuthenticationProviders": [ + { + "AuthenticationType": "AMAZON_COGNITO_USER_POOLS", + "UserPoolConfig": { + "AppIdClientRegex": "[a-z]", + "AwsRegion": "us-east-1", + "UserPoolId": "pool123", + }, + }, + { + "AuthenticationType": "AWS_IAM", + }, + { + "AuthenticationType": "OPENID_CONNECT", + "OpenIDConnectConfig": { + "AuthTTL": 60, + "ClientId": "333746dd-06fc-44df-bce2-5ff108724044", + "IatTTL": 3600, + "Issuer": "https://auth.example.com", + }, + }, + { + "AuthenticationType": "AWS_LAMBDA", + "LambdaAuthorizerConfig": { + "AuthorizerResultTtlInSeconds": 300, + "AuthorizerUri": { + "Fn::GetAtt": [ + "AuthFunctionLambdaFunction", + "Arn", + ], + }, + "IdentityValidationExpression": "customm-*", + }, + }, + ], + "AuthenticationType": "AMAZON_COGNITO_USER_POOLS", + "EnvironmentVariables": undefined, + "Name": "MyApi", + "Tags": [ + { + "Key": "stage", + "Value": "Dev", + }, + ], + "UserPoolConfig": { + "AppIdClientRegex": "[a-z]", + "AwsRegion": "us-east-1", + "DefaultAction": "ALLOW", + "UserPoolId": "pool123", + }, + "XrayEnabled": false, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource with config 1`] = ` +{ + "GraphQlApi": { + "Properties": { + "AuthenticationType": "API_KEY", + "EnvironmentVariables": undefined, + "IntrospectionConfig": "DISABLED", + "Name": "MyApi", + "QueryDepthLimit": 10, + "ResolverCountLimit": 20, + "Tags": [ + { + "Key": "stage", + "Value": "Dev", + }, + ], + "XrayEnabled": false, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource with embedded additional authorizer Lambda function 1`] = ` +{ + "GraphQlApi": { + "Properties": { + "AdditionalAuthenticationProviders": [ + { + "AuthenticationType": "AWS_LAMBDA", + "LambdaAuthorizerConfig": { + "AuthorizerResultTtlInSeconds": undefined, + "AuthorizerUri": { + "Fn::GetAtt": [ + "MyApiAuthorizerLambdaFunction", + "Arn", + ], + }, + "IdentityValidationExpression": undefined, + }, + }, + ], + "AuthenticationType": "API_KEY", + "EnvironmentVariables": undefined, + "Name": "MyApi", + "Tags": [ + { + "Key": "stage", + "Value": "Dev", + }, + ], + "XrayEnabled": false, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource with embedded additional authorizer Lambda function 2`] = ` +{ + "MyApiAuthorizer": { + "handler": "index.handler", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource with embedded authorizer Lambda function 1`] = ` +{ + "GraphQlApi": { + "Properties": { + "AuthenticationType": "AWS_LAMBDA", + "EnvironmentVariables": undefined, + "LambdaAuthorizerConfig": { + "AuthorizerResultTtlInSeconds": undefined, + "AuthorizerUri": { + "Fn::GetAtt": [ + "MyApiAuthorizerLambdaFunction", + "Arn", + ], + }, + "IdentityValidationExpression": undefined, + }, + "Name": "MyApi", + "Tags": [ + { + "Key": "stage", + "Value": "Dev", + }, + ], + "XrayEnabled": false, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource with embedded authorizer Lambda function 2`] = ` +{ + "MyApiAuthorizer": { + "handler": "index.handler", + }, +} +`; + +exports[`Api compileEndpoint should compile the Api Resource with logs enabled 1`] = ` +{ + "GraphQlApi": { + "Properties": { + "AuthenticationType": "API_KEY", + "EnvironmentVariables": undefined, + "LogConfig": { + "CloudWatchLogsRoleArn": { + "Fn::GetAtt": [ + "GraphQlApiLogGroupRole", + "Arn", + ], + }, + "ExcludeVerboseContent": false, + "FieldLogLevel": "ERROR", + }, + "Name": "MyApi", + "Tags": [ + { + "Key": "stage", + "Value": "Dev", + }, + ], + "XrayEnabled": false, + }, + "Type": "AWS::AppSync::GraphQLApi", + }, +} +`; + +exports[`Caching should generate Resources with custom Config 1`] = ` +{ + "GraphQlCaching": { + "Properties": { + "ApiCachingBehavior": "FULL_REQUEST_CACHING", + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "AtRestEncryptionEnabled": true, + "TransitEncryptionEnabled": true, + "Ttl": 500, + "Type": "T2_MEDIUM", + }, + "Type": "AWS::AppSync::ApiCache", + }, +} +`; + +exports[`Caching should generate Resources with defaults 1`] = ` +{ + "GraphQlCaching": { + "Properties": { + "ApiCachingBehavior": "FULL_REQUEST_CACHING", + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "AtRestEncryptionEnabled": false, + "TransitEncryptionEnabled": false, + "Ttl": 3600, + "Type": "T2_SMALL", + }, + "Type": "AWS::AppSync::ApiCache", + }, +} +`; + +exports[`Domains should generate domain resources 1`] = ` +{ + "GraphQlDomainAssociation": { + "DeletionPolicy": "Delete", + "DependsOn": [ + "GraphQlDomainName", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DomainName": "api.example.com", + }, + "Type": "AWS::AppSync::DomainNameApiAssociation", + }, + "GraphQlDomainCertificate": { + "DeletionPolicy": "Delete", + "Properties": { + "DomainName": "api.example.com", + "DomainValidationOptions": [ + { + "DomainName": "api.example.com", + "HostedZoneId": "Z111111QQQQQQQ", + }, + ], + "ValidationMethod": "DNS", + }, + "Type": "AWS::CertificateManager::Certificate", + }, + "GraphQlDomainName": { + "DeletionPolicy": "Delete", + "Properties": { + "CertificateArn": { + "Ref": "GraphQlDomainCertificate", + }, + "DomainName": "api.example.com", + }, + "Type": "AWS::AppSync::DomainName", + }, + "GraphQlDomainRoute53Record": { + "DeletionPolicy": "Delete", + "Properties": { + "AliasTarget": { + "DNSName": { + "Fn::GetAtt": [ + "GraphQlDomainName", + "AppSyncDomainName", + ], + }, + "EvaluateTargetHealth": false, + "HostedZoneId": { + "Fn::GetAtt": [ + "GraphQlDomainName", + "HostedZoneId", + ], + }, + }, + "HostedZoneId": "Z111111QQQQQQQ", + "Name": "api.example.com", + "Type": "A", + }, + "Type": "AWS::Route53::RecordSet", + }, +} +`; + +exports[`Domains should generate domain resources with custom certificate ARN 1`] = ` +{ + "GraphQlDomainAssociation": { + "DeletionPolicy": "Delete", + "DependsOn": [ + "GraphQlDomainName", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DomainName": "api.example.com", + }, + "Type": "AWS::AppSync::DomainNameApiAssociation", + }, + "GraphQlDomainName": { + "DeletionPolicy": "Delete", + "Properties": { + "CertificateArn": "arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52", + "DomainName": "api.example.com", + }, + "Type": "AWS::AppSync::DomainName", + }, + "GraphQlDomainRoute53Record": { + "DeletionPolicy": "Delete", + "Properties": { + "AliasTarget": { + "DNSName": { + "Fn::GetAtt": [ + "GraphQlDomainName", + "AppSyncDomainName", + ], + }, + "EvaluateTargetHealth": false, + "HostedZoneId": { + "Fn::GetAtt": [ + "GraphQlDomainName", + "HostedZoneId", + ], + }, + }, + "HostedZoneName": "example.com.", + "Name": "api.example.com", + "Type": "A", + }, + "Type": "AWS::Route53::RecordSet", + }, +} +`; + +exports[`Domains should generate domain resources with custom hostedZoneId 1`] = ` +{ + "GraphQlDomainAssociation": { + "DeletionPolicy": "Delete", + "DependsOn": [ + "GraphQlDomainName", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DomainName": "api.example.com", + }, + "Type": "AWS::AppSync::DomainNameApiAssociation", + }, + "GraphQlDomainName": { + "DeletionPolicy": "Delete", + "Properties": { + "CertificateArn": "arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52", + "DomainName": "api.example.com", + }, + "Type": "AWS::AppSync::DomainName", + }, + "GraphQlDomainRoute53Record": { + "DeletionPolicy": "Delete", + "Properties": { + "AliasTarget": { + "DNSName": { + "Fn::GetAtt": [ + "GraphQlDomainName", + "AppSyncDomainName", + ], + }, + "EvaluateTargetHealth": false, + "HostedZoneId": { + "Fn::GetAtt": [ + "GraphQlDomainName", + "HostedZoneId", + ], + }, + }, + "HostedZoneId": "Z111111QQQQQQQ", + "Name": "api.example.com", + "Type": "A", + }, + "Type": "AWS::Route53::RecordSet", + }, +} +`; + +exports[`Domains should generate domain resources with custom hostedZoneName 1`] = ` +{ + "GraphQlDomainAssociation": { + "DeletionPolicy": "Delete", + "DependsOn": [ + "GraphQlDomainName", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DomainName": "foo.api.example.com", + }, + "Type": "AWS::AppSync::DomainNameApiAssociation", + }, + "GraphQlDomainName": { + "DeletionPolicy": "Delete", + "Properties": { + "CertificateArn": "arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52", + "DomainName": "foo.api.example.com", + }, + "Type": "AWS::AppSync::DomainName", + }, + "GraphQlDomainRoute53Record": { + "DeletionPolicy": "Delete", + "Properties": { + "AliasTarget": { + "DNSName": { + "Fn::GetAtt": [ + "GraphQlDomainName", + "AppSyncDomainName", + ], + }, + "EvaluateTargetHealth": false, + "HostedZoneId": { + "Fn::GetAtt": [ + "GraphQlDomainName", + "HostedZoneId", + ], + }, + }, + "HostedZoneName": "example.com.", + "Name": "foo.api.example.com", + "Type": "A", + }, + "Type": "AWS::Route53::RecordSet", + }, +} +`; + +exports[`Domains should not generate domain resources when disabled 1`] = `{}`; + +exports[`Domains should not generate domain resources when not configured 1`] = `{}`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/dataSources.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/dataSources.test.js.snap new file mode 100644 index 000000000..ac23e8c5b --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/dataSources.test.js.snap @@ -0,0 +1,1595 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataSource AWS Lambda should generate Resource with default role 1`] = ` +{ + "GraphQlDsmyFunction": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My lambda resolver", + "LambdaConfig": { + "LambdaFunctionArn": { + "Fn::GetAtt": [ + "MyFunctionLambdaFunction", + "Arn", + ], + }, + }, + "Name": "myFunction", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsmyFunctionRole", + "Arn", + ], + }, + "Type": "AWS_LAMBDA", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsmyFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "lambda:invokeFunction", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "MyFunctionLambdaFunction", + "Arn", + ], + }, + { + "Fn::Join": [ + ":", + [ + { + "Fn::GetAtt": [ + "MyFunctionLambdaFunction", + "Arn", + ], + }, + "*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-myFunction", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource AWS Lambda should generate Resource with embedded function 1`] = ` +{ + "GraphQlDsmyDataSource": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My lambda resolver", + "LambdaConfig": { + "LambdaFunctionArn": { + "Fn::GetAtt": [ + "MyDataSourceLambdaFunction", + "Arn", + ], + }, + }, + "Name": "myDataSource", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsmyDataSourceRole", + "Arn", + ], + }, + "Type": "AWS_LAMBDA", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsmyDataSourceRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "lambda:invokeFunction", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "MyDataSourceLambdaFunction", + "Arn", + ], + }, + { + "Fn::Join": [ + ":", + [ + { + "Fn::GetAtt": [ + "MyDataSourceLambdaFunction", + "Arn", + ], + }, + "*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-myDataSource", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource AWS Lambda should generate Resource with embedded function 2`] = ` +{ + "myDataSource": { + "handler": "index.handler", + }, +} +`; + +exports[`DataSource AWS Lambda should generate default role with custom statements 1`] = ` +{ + "GraphQlDsmyFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "lambda:invokeFunction", + ], + "Effect": "Allow", + "Resource": [ + { + "Ref": "MyFunction", + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-myFunction", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource DynamoDB should generate Resource with default deltaSync 1`] = ` +{ + "GraphQlDsdynamo": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My dynamo table", + "DynamoDBConfig": { + "AwsRegion": { + "Ref": "AWS::Region", + }, + "DeltaSyncConfig": { + "BaseTableTTL": 60, + "DeltaSyncTableName": "syncTable", + "DeltaSyncTableTTL": 120, + }, + "TableName": "data", + "UseCallerCredentials": false, + "Versioned": true, + }, + "Name": "dynamo", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsdynamoRole", + "Arn", + ], + }, + "Type": "AMAZON_DYNAMODB", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsdynamoRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:ConditionCheckItem", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "/", + [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "dynamodb", + { + "Ref": "AWS::Region", + }, + { + "Ref": "AWS::AccountId", + }, + "table", + ], + ], + }, + "data", + ], + ], + }, + { + "Fn::Join": [ + "/", + [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "dynamodb", + { + "Ref": "AWS::Region", + }, + { + "Ref": "AWS::AccountId", + }, + "table", + ], + ], + }, + "data", + "*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-dynamo", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource DynamoDB should generate Resource with default role 1`] = ` +{ + "GraphQlDsdynamo": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My dynamo table", + "DynamoDBConfig": { + "AwsRegion": { + "Ref": "AWS::Region", + }, + "TableName": "data", + "UseCallerCredentials": false, + }, + "Name": "dynamo", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsdynamoRole", + "Arn", + ], + }, + "Type": "AMAZON_DYNAMODB", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsdynamoRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:ConditionCheckItem", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "/", + [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "dynamodb", + { + "Ref": "AWS::Region", + }, + { + "Ref": "AWS::AccountId", + }, + "table", + ], + ], + }, + "data", + ], + ], + }, + { + "Fn::Join": [ + "/", + [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "dynamodb", + { + "Ref": "AWS::Region", + }, + { + "Ref": "AWS::AccountId", + }, + "table", + ], + ], + }, + "data", + "*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-dynamo", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource DynamoDB should generate default role with a Ref for the table name 1`] = ` +{ + "GraphQlDsdynamo": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My dynamo table", + "DynamoDBConfig": { + "AwsRegion": { + "Ref": "AWS::Region", + }, + "TableName": { + "Ref": "MyTable", + }, + "UseCallerCredentials": false, + }, + "Name": "dynamo", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsdynamoRole", + "Arn", + ], + }, + "Type": "AMAZON_DYNAMODB", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsdynamoRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:ConditionCheckItem", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "/", + [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "dynamodb", + { + "Ref": "AWS::Region", + }, + { + "Ref": "AWS::AccountId", + }, + "table", + ], + ], + }, + { + "Ref": "MyTable", + }, + ], + ], + }, + { + "Fn::Join": [ + "/", + [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "dynamodb", + { + "Ref": "AWS::Region", + }, + { + "Ref": "AWS::AccountId", + }, + "table", + ], + ], + }, + { + "Ref": "MyTable", + }, + "*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-dynamo", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource DynamoDB should generate default role with custom region 1`] = ` +{ + "GraphQlDsdynamo": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My dynamo table", + "DynamoDBConfig": { + "AwsRegion": "us-east-2", + "TableName": "data", + "UseCallerCredentials": false, + }, + "Name": "dynamo", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsdynamoRole", + "Arn", + ], + }, + "Type": "AMAZON_DYNAMODB", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsdynamoRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:ConditionCheckItem", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "/", + [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "dynamodb", + "us-east-2", + { + "Ref": "AWS::AccountId", + }, + "table", + ], + ], + }, + "data", + ], + ], + }, + { + "Fn::Join": [ + "/", + [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "dynamodb", + "us-east-2", + { + "Ref": "AWS::AccountId", + }, + "table", + ], + ], + }, + "data", + "*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-dynamo", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource DynamoDB should generate default role with custom statement 1`] = ` +{ + "GraphQlDsdynamoRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "dynamodb:GetItem", + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:dynamodb:us-east-1:12345678:myTable", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-dynamo", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource EventBridge should generate Resource with default role 1`] = ` +{ + "GraphQlDseventBridge": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My eventBridge bus", + "EventBridgeConfig": { + "EventBusArn": "arn:aws:events:us-east-1:123456789012:event-bus/default", + }, + "Name": "eventBridge", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDseventBridgeRole", + "Arn", + ], + }, + "Type": "AMAZON_EVENTBRIDGE", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDseventBridgeRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "events:PutEvents", + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:events:us-east-1:123456789012:event-bus/default", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-eventBridge", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource EventBridge should generate default role with a Ref for the bus ARN 1`] = ` +{ + "GraphQlDseventBridge": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My eventBridge bus", + "EventBridgeConfig": { + "EventBusArn": { + "Fn::GetAtt": [ + "MyEventBus", + "Arn", + ], + }, + }, + "Name": "eventBridge", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDseventBridgeRole", + "Arn", + ], + }, + "Type": "AMAZON_EVENTBRIDGE", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDseventBridgeRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "events:PutEvents", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "MyEventBus", + "Arn", + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-eventBridge", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource EventBridge should generate default role with custom statement 1`] = ` +{ + "GraphQlDseventBridgeRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "events:PutEvents", + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:events:us-east-1:123456789012:event-bus/default", + "arn:aws:events:us-east-1:123456789012:event-bus/other", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-eventBridge", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource HTTP should generate Resource with IAM authorization config 1`] = ` +{ + "GraphQlDsmyEndpoint": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My HTTP resolver", + "HttpConfig": { + "AuthorizationConfig": { + "AuthorizationType": "AWS_IAM", + "AwsIamConfig": { + "SigningRegion": { + "Ref": "AWS::Region", + }, + "SigningServiceName": "events", + }, + }, + "Endpoint": "https://events.us-east-1.amazonaws.com/", + }, + "Name": "myEndpoint", + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsmyEndpointRole", + "Arn", + ], + }, + "Type": "HTTP", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsmyEndpointRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "events:PutEvents", + ], + "Effect": "Allow", + "Resource": [ + "*", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-myEndpoint", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource HTTP should generate Resource without roles 1`] = ` +{ + "GraphQlDsmyEndpoint": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My HTTP resolver", + "HttpConfig": { + "Endpoint": "https://api.example.com", + }, + "Name": "myEndpoint", + "Type": "HTTP", + }, + "Type": "AWS::AppSync::DataSource", + }, +} +`; + +exports[`DataSource HTTP should generate default role with custom statements 1`] = ` +{ + "GraphQlDsmyEndpointRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "events:PutEvents", + ], + "Effect": "Allow", + "Resource": [ + "*", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-myEndpoint", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource OpenSearch should generate Resource with endpoint 1`] = ` +{ + "GraphQlDsopensearch": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "OpenSearch resolver", + "Name": "opensearch", + "OpenSearchServiceConfig": { + "AwsRegion": { + "Ref": "AWS::Region", + }, + "Endpoint": "https://mydomain.us-east-1.es.amazonaws.com", + }, + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsopensearchRole", + "Arn", + ], + }, + "Type": "AMAZON_OPENSEARCH_SERVICE", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsopensearchRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "es:ESHttpDelete", + "es:ESHttpGet", + "es:ESHttpHead", + "es:ESHttpPost", + "es:ESHttpPut", + "es:ESHttpPatch", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "es", + "us-east-1", + { + "Ref": "AWS::AccountId", + }, + "domain/mydomain.us-east-1.es.amazonaws.com/*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-opensearch", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource OpenSearch should generate Resource without roles 1`] = ` +{ + "GraphQlDsopensearch": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "OpenSearch resolver", + "Name": "opensearch", + "OpenSearchServiceConfig": { + "AwsRegion": { + "Ref": "AWS::Region", + }, + "Endpoint": { + "Fn::Join": [ + "", + [ + "https://", + { + "Fn::GetAtt": [ + "myDomain", + "DomainEndpoint", + ], + }, + ], + ], + }, + }, + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsopensearchRole", + "Arn", + ], + }, + "Type": "AMAZON_OPENSEARCH_SERVICE", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsopensearchRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "es:ESHttpDelete", + "es:ESHttpGet", + "es:ESHttpHead", + "es:ESHttpPost", + "es:ESHttpPut", + "es:ESHttpPatch", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "/", + [ + { + "Fn::GetAtt": [ + "myDomain", + "Arn", + ], + }, + "*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-opensearch", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource OpenSearch should generate default role with custom statements 1`] = ` +{ + "GraphQlDsopensearchRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "es:ESHttpGet", + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:es:us-east-1:12345678:domain/myDomain", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-opensearch", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource Relational Databases should generate DynamoDB default role with custom statement 1`] = ` +{ + "GraphQlDsmyDatabaseRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "rds-data:DeleteItems", + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:rds:us-east-1:12345678:cluster:myCluster", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-myDatabase", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; + +exports[`DataSource Relational Databases should generate Resource with default role 1`] = ` +{ + "GraphQlDsmyDatabase": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Description": "My RDS database", + "Name": "myDatabase", + "RelationalDatabaseConfig": { + "RdsHttpEndpointConfig": { + "AwsRegion": { + "Ref": "AWS::Region", + }, + "AwsSecretStoreArn": { + "Ref": "MyRdsCluster", + }, + "DatabaseName": "myDatabase", + "DbClusterIdentifier": { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "rds", + { + "Ref": "AWS::Region", + }, + { + "Ref": "AWS::AccountId", + }, + "cluster", + "myCluster", + ], + ], + }, + "Schema": undefined, + }, + "RelationalDatabaseSourceType": "RDS_HTTP_ENDPOINT", + }, + "ServiceRoleArn": { + "Fn::GetAtt": [ + "GraphQlDsmyDatabaseRole", + "Arn", + ], + }, + "Type": "RELATIONAL_DATABASE", + }, + "Type": "AWS::AppSync::DataSource", + }, + "GraphQlDsmyDatabaseRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "appsync.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "rds-data:DeleteItems", + "rds-data:ExecuteSql", + "rds-data:ExecuteStatement", + "rds-data:GetItems", + "rds-data:InsertItems", + "rds-data:UpdateItems", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "rds", + { + "Ref": "AWS::Region", + }, + { + "Ref": "AWS::AccountId", + }, + "cluster", + "myCluster", + ], + ], + }, + { + "Fn::Join": [ + ":", + [ + { + "Fn::Join": [ + ":", + [ + "arn", + "aws", + "rds", + { + "Ref": "AWS::Region", + }, + { + "Ref": "AWS::AccountId", + }, + "cluster", + "myCluster", + ], + ], + }, + "*", + ], + ], + }, + ], + }, + { + "Action": [ + "secretsmanager:GetSecretValue", + ], + "Effect": "Allow", + "Resource": [ + { + "Ref": "MyRdsCluster", + }, + { + "Fn::Join": [ + ":", + [ + { + "Ref": "MyRdsCluster", + }, + "*", + ], + ], + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "AppSync-Datasource-myDatabase", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, +} +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/getAppSyncConfig.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/getAppSyncConfig.test.js.snap new file mode 100644 index 000000000..8f45c8d88 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/getAppSyncConfig.test.js.snap @@ -0,0 +1,257 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataSources should merge dataSource arrays 1`] = ` +{ + "anotherNamedSource": { + "name": "anotherNamedSource", + "type": "NONE", + }, + "myDataSource": { + "name": "myDataSource", + "type": "NONE", + }, + "myOtherDataSource": { + "name": "myOtherDataSource", + "type": "NONE", + }, + "otherSource": { + "name": "otherSource", + "type": "NONE", + }, +} +`; + +exports[`DataSources should merge dataSources embedded into resolvers and pipelineFunctions 1`] = ` +{ + "dataSources": { + "Mutation_createUser_0": { + "config": { + "functionName": "createUser", + }, + "name": "Mutation_createUser_0", + "type": "AWS_LAMBDA", + }, + "Query_getUser": { + "config": { + "functionName": "getUser", + }, + "name": "Query_getUser", + "type": "AWS_LAMBDA", + }, + "function1": { + "config": { + "functionName": "function1", + }, + "name": "function1", + "type": "AWS_LAMBDA", + }, + "function2": { + "config": { + "functionName": "function2", + }, + "name": "function2", + "type": "AWS_LAMBDA", + }, + "getUsers": { + "config": { + "functionName": "getUsers", + }, + "name": "getUsers", + "type": "AWS_LAMBDA", + }, + "myDataSource": { + "name": "myDataSource", + "type": "NONE", + }, + "myOtherDataSource": { + "name": "myOtherDataSource", + "type": "NONE", + }, + }, + "pipelineFunctions": { + "Mutation_createUser_0": { + "dataSource": "Mutation_createUser_0", + "name": "Mutation_createUser_0", + }, + "function1": { + "dataSource": "function1", + "name": "function1", + }, + "function2": { + "dataSource": "function2", + "name": "function2", + }, + }, + "resolvers": { + "Mutation.createUser": { + "field": "createUser", + "functions": [ + "Mutation_createUser_0", + ], + "kind": "PIPELINE", + "type": "Mutation", + }, + "Query.getUser": { + "dataSource": "Query_getUser", + "field": "getUser", + "kind": "UNIT", + "type": "Query", + }, + "getUsers": { + "dataSource": "getUsers", + "field": "getUsers", + "kind": "UNIT", + "type": "Query", + }, + }, +} +`; + +exports[`Pipeline Functions should merge function arrays 1`] = ` +{ + "function1": { + "dataSource": "users", + "name": "function1", + }, + "function2": { + "dataSource": "users", + "name": "function2", + }, + "function3": { + "dataSource": "users", + "name": "function3", + }, + "function4": { + "dataSource": "users", + "name": "function4", + }, +} +`; + +exports[`Pipeline Functions should merge inline function definitions 1`] = ` +{ + "Mutation_createUser_0": { + "code": "function1.js", + "dataSource": "users", + "name": "Mutation_createUser_0", + }, + "Mutation_createUser_1": { + "code": "function2.js", + "dataSource": "users", + "name": "Mutation_createUser_1", + }, + "Mutation_updateUser_0": { + "code": "updateUser.js", + "dataSource": "Mutation_updateUser_0", + "name": "Mutation_updateUser_0", + }, + "function1": { + "dataSource": "users", + "name": "function1", + }, + "function2": { + "dataSource": "users", + "name": "function2", + }, +} +`; + +exports[`Resolvers should merge resolvers arrays 1`] = ` +{ + "Query.getPost": { + "dataSource": "posts", + "field": "getPost", + "kind": "UNIT", + "type": "Query", + }, + "Query.getUser": { + "dataSource": "users", + "field": "getUser", + "kind": "UNIT", + "type": "Query", + }, + "Query.pipeline": { + "field": "pipeline", + "functions": [ + "function1", + "function2", + ], + "kind": "PIPELINE", + "type": "Query", + }, + "getPostsResolver": { + "dataSource": "posts", + "field": "getPosts", + "kind": "UNIT", + "type": "Query", + }, + "getUsersResolver": { + "dataSource": "users", + "field": "getUsers", + "kind": "UNIT", + "type": "Query", + }, + "pipelineResolver2": { + "field": "getUsers", + "functions": [ + "function1", + "function2", + ], + "kind": "PIPELINE", + "type": "Query", + }, +} +`; + +exports[`Resolvers should resolve resolver type and fields 1`] = ` +{ + "Query.getUser": { + "dataSource": "users", + "field": "getUser", + "kind": "UNIT", + "type": "Query", + }, + "getUsersResolver": { + "dataSource": "users", + "field": "getUsers", + "kind": "UNIT", + "type": "Query", + }, +} +`; + +exports[`Schema should return a schema array unchanged 1`] = ` +[ + "users.graphql", + "posts.graphql", +] +`; + +exports[`Schema should return a single schema as an array 1`] = ` +[ + "mySchema.graphql", +] +`; + +exports[`Schema should return the default schema 1`] = ` +[ + "schema.graphql", +] +`; + +exports[`returns basic config 1`] = ` +{ + "additionalAuthentications": [], + "apiKeys": {}, + "authentication": { + "type": "API_KEY", + }, + "dataSources": {}, + "name": "My Api", + "pipelineFunctions": {}, + "resolvers": {}, + "schema": [ + "schema.graphql", + ], +} +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/js-resolvers.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/js-resolvers.test.js.snap new file mode 100644 index 000000000..12527dfcb --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/js-resolvers.test.js.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`JS Resolvers should fail if template is missing 1`] = `"Cannot read properties of undefined (reading 'Error')"`; + +exports[`JS Resolvers should substitute variables 1`] = ` +{ + "Fn::Join": [ + "", + [ + "const foo = '", + { + "Fn::Sub": [ + "\${foo}", + { + "foo": "bar", + }, + ], + }, + "'; + const var = '", + { + "Fn::Sub": [ + "\${var}", + { + "var": { + "Ref": "MyReference", + }, + }, + ], + }, + "'; + const unknonw = '#unknown#'", + ], + ], +} +`; + +exports[`JS Resolvers should substitute variables and use defaults 1`] = ` +{ + "Fn::Join": [ + "", + [ + "const foo = '", + { + "Fn::Sub": [ + "\${foo}", + { + "foo": "fuzz", + }, + ], + }, + "'; + const var = '", + { + "Fn::Sub": [ + "\${var}", + { + "var": "bizz", + }, + ], + }, + "';", + ], + ], +} +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/mapping-templates.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/mapping-templates.test.js.snap new file mode 100644 index 000000000..f31ea1fb9 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/mapping-templates.test.js.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Mapping Templates should fail if template is missing 1`] = `"Cannot read properties of undefined (reading 'Error')"`; + +exports[`Mapping Templates should substitute variables 1`] = ` +{ + "Fn::Join": [ + "", + [ + "Foo: ", + { + "Fn::Sub": [ + "\${foo}", + { + "foo": "bar", + }, + ], + }, + ", Var: ", + { + "Fn::Sub": [ + "\${var}", + { + "var": { + "Ref": "MyReference", + }, + }, + ], + }, + ", Context: \${ctx.args.id}, Unknonw: \${unknown}", + ], + ], +} +`; + +exports[`Mapping Templates should substitute variables and use defaults 1`] = ` +{ + "Fn::Join": [ + "", + [ + "Foo: ", + { + "Fn::Sub": [ + "\${foo}", + { + "foo": "fuzz", + }, + ], + }, + ", Var: ", + { + "Fn::Sub": [ + "\${var}", + { + "var": "bizz", + }, + ], + }, + ], + ], +} +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/resolvers.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/resolvers.test.js.snap new file mode 100644 index 000000000..fbc73e696 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/resolvers.test.js.snap @@ -0,0 +1,456 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Resolvers Caching should fallback to global caching TTL 1`] = ` +{ + "GraphQlResolverQueryuser": { + "DependsOn": [ + "GraphQlSchema", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "CachingConfig": { + "CachingKeys": [ + "$context.identity.sub", + "$context.arguments.id", + ], + "Ttl": 300, + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyTable", + "Name", + ], + }, + "FieldName": "user", + "Kind": "UNIT", + "MaxBatchSize": undefined, + "TypeName": "Query", + }, + "Type": "AWS::AppSync::Resolver", + }, +} +`; + +exports[`Resolvers Caching should generate Resources with caching enabled 1`] = ` +{ + "GraphQlResolverQueryuser": { + "DependsOn": [ + "GraphQlSchema", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "CachingConfig": { + "Ttl": 3600, + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyTable", + "Name", + ], + }, + "FieldName": "user", + "Kind": "UNIT", + "MaxBatchSize": undefined, + "TypeName": "Query", + }, + "Type": "AWS::AppSync::Resolver", + }, +} +`; + +exports[`Resolvers Caching should generate Resources with custom keys 1`] = ` +{ + "GraphQlResolverQueryuser": { + "DependsOn": [ + "GraphQlSchema", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "CachingConfig": { + "CachingKeys": [ + "$context.identity.sub", + "$context.arguments.id", + ], + "Ttl": 200, + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyTable", + "Name", + ], + }, + "FieldName": "user", + "Kind": "UNIT", + "MaxBatchSize": undefined, + "TypeName": "Query", + }, + "Type": "AWS::AppSync::Resolver", + }, +} +`; + +exports[`Resolvers Pipeline Function should fail if Pipeline Function references unexisting data source 1`] = `"Cannot read properties of undefined (reading 'Error')"`; + +exports[`Resolvers Pipeline Function should generate Pipeline Function Resources with VTL mapping templates 1`] = ` +{ + "GraphQlFunctionConfigurationfunction1": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyTable", + "Name", + ], + }, + "Description": "Function1 Pipeline Resolver", + "FunctionVersion": "2018-05-29", + "MaxBatchSize": undefined, + "Name": "function1", + "RequestMappingTemplate": "Content of path/to/mappingTemplates/function1.request.vtl", + "ResponseMappingTemplate": "Content of path/to/mappingTemplates/function1.response.vtl", + }, + "Type": "AWS::AppSync::FunctionConfiguration", + }, +} +`; + +exports[`Resolvers Pipeline Function should generate Pipeline Function Resources with direct Lambda 1`] = ` +{ + "GraphQlFunctionConfigurationfunction1": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyLambdaFunction", + "Name", + ], + }, + "Description": "Function1 Pipeline Resolver", + "FunctionVersion": "2018-05-29", + "MaxBatchSize": undefined, + "Name": "function1", + }, + "Type": "AWS::AppSync::FunctionConfiguration", + }, +} +`; + +exports[`Resolvers Pipeline Function should generate Pipeline Function Resources with maxBatchSize 1`] = ` +{ + "GraphQlFunctionConfigurationfunction1": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyFunction", + "Name", + ], + }, + "Description": "Function1 Pipeline Resolver", + "FunctionVersion": "2018-05-29", + "MaxBatchSize": 200, + "Name": "function1", + "RequestMappingTemplate": "Content of function1.request.vtl", + "ResponseMappingTemplate": "Content of function1.response.vtl", + }, + "Type": "AWS::AppSync::FunctionConfiguration", + }, +} +`; + +exports[`Resolvers Pipeline Function should generate Resources with sync configuration 1`] = ` +{ + "GraphQlFunctionConfigurationmyFunction": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyLambdaFunction", + "Name", + ], + }, + "Description": undefined, + "FunctionVersion": "2018-05-29", + "MaxBatchSize": undefined, + "Name": "myFunction", + "RequestMappingTemplate": "Content of myFunction.request.vtl", + "ResponseMappingTemplate": "Content of myFunction.response.vtl", + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "LAMBDA", + "LambdaConflictHandlerConfig": { + "LambdaConflictHandlerArn": { + "Fn::GetAtt": [ + "MyFunction_SyncLambdaFunction", + "Arn", + ], + }, + }, + }, + }, + "Type": "AWS::AppSync::FunctionConfiguration", + }, +} +`; + +exports[`Resolvers Pipeline Resolvers should fail when referencing unknown pipeline function 1`] = `"Cannot read properties of undefined (reading 'Error')"`; + +exports[`Resolvers Pipeline Resolvers should generate JS Resources with default empty resolver 1`] = ` +{ + "GraphQlResolverQueryuser": { + "DependsOn": [ + "GraphQlSchema", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Code": " +export function request() { + return {}; +} + +export function response(ctx) { + return ctx.prev.result; +} +", + "FieldName": "user", + "Kind": "PIPELINE", + "PipelineConfig": { + "Functions": [ + { + "Fn::GetAtt": [ + "GraphQlFunctionConfigurationgetUser", + "FunctionId", + ], + }, + ], + }, + "Runtime": { + "Name": "APPSYNC_JS", + "RuntimeVersion": "1.0.0", + }, + "TypeName": "Query", + }, + "Type": "AWS::AppSync::Resolver", + }, +} +`; + +exports[`Resolvers Pipeline Resolvers should generate Resources with VTL mapping templates 1`] = ` +{ + "GraphQlResolverQueryuser": { + "DependsOn": [ + "GraphQlSchema", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "FieldName": "user", + "Kind": "PIPELINE", + "PipelineConfig": { + "Functions": [ + { + "Fn::GetAtt": [ + "GraphQlFunctionConfigurationfunction1", + "FunctionId", + ], + }, + { + "Fn::GetAtt": [ + "GraphQlFunctionConfigurationfunction2", + "FunctionId", + ], + }, + ], + }, + "RequestMappingTemplate": "Content of Query.user.request.vtl", + "ResponseMappingTemplate": "Content of Query.user.response.vtl", + "TypeName": "Query", + }, + "Type": "AWS::AppSync::Resolver", + }, +} +`; + +exports[`Resolvers Unit Resolvers should fail when referencing unknown data source 1`] = `"Cannot read properties of undefined (reading 'Error')"`; + +exports[`Resolvers Unit Resolvers should generate Resources with VTL mapping templates 1`] = ` +{ + "GraphQlResolverQueryuser": { + "DependsOn": [ + "GraphQlSchema", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyTable", + "Name", + ], + }, + "FieldName": "user", + "Kind": "UNIT", + "MaxBatchSize": undefined, + "RequestMappingTemplate": "Content of path/to/mappingTemplates/Query.user.request.vtl", + "ResponseMappingTemplate": "Content of path/to/mappingTemplates/Query.user.response.vtl", + "TypeName": "Query", + }, + "Type": "AWS::AppSync::Resolver", + }, +} +`; + +exports[`Resolvers Unit Resolvers should generate Resources with direct Lambda 1`] = ` +{ + "GraphQlResolverQueryuser": { + "DependsOn": [ + "GraphQlSchema", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyLambdaFunction", + "Name", + ], + }, + "FieldName": "user", + "Kind": "UNIT", + "MaxBatchSize": undefined, + "TypeName": "Query", + }, + "Type": "AWS::AppSync::Resolver", + }, +} +`; + +exports[`Resolvers Unit Resolvers should generate Resources with maxBatchSize 1`] = ` +{ + "GraphQlResolverQueryuser": { + "DependsOn": [ + "GraphQlSchema", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyFunction", + "Name", + ], + }, + "FieldName": "user", + "Kind": "UNIT", + "MaxBatchSize": 200, + "TypeName": "Query", + }, + "Type": "AWS::AppSync::Resolver", + }, +} +`; + +exports[`Resolvers Unit Resolvers should generate Resources with sync configuration 1`] = ` +{ + "GraphQlResolverQueryuser": { + "DependsOn": [ + "GraphQlSchema", + ], + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQlDsmyLambdaFunction", + "Name", + ], + }, + "FieldName": "user", + "Kind": "UNIT", + "MaxBatchSize": undefined, + "SyncConfig": { + "ConflictDetection": "VERSION", + "ConflictHandler": "LAMBDA", + "LambdaConflictHandlerConfig": { + "LambdaConflictHandlerArn": { + "Fn::GetAtt": [ + "Query_user_SyncLambdaFunction", + "Arn", + ], + }, + }, + }, + "TypeName": "Query", + }, + "Type": "AWS::AppSync::Resolver", + }, +} +`; + +exports[`Resolvers Unit Resolvers should generate Resources with sync configuration 2`] = ` +{ + "Query_user_Sync": { + "handler": "index.handler", + }, +} +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/schema.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/schema.test.js.snap new file mode 100644 index 000000000..917fb193e --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/schema.test.js.snap @@ -0,0 +1,136 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`schema should fail if schema is invalid 1`] = `"Cannot read properties of undefined (reading 'Error')"`; + +exports[`schema should generate a schema resource 1`] = ` +{ + "GraphQlSchema": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + "Definition": "type Query { + getUser: User! +} + +type Mutation { + createUser(post: UserInput!): User! +} + +""" +A User +""" +type User { + id: ID! + name: String! +} + +# Input for user +input UserInput { + name: String! +} +", + }, + "Type": "AWS::AppSync::GraphQLSchema", + }, +} +`; + +exports[`schema should merge glob schemas 1`] = ` +"type Mutation { + createPost(post: PostInput!): Post! + createUser(post: UserInput!): User! +} + +type Post @aws_oidc { + id: ID! + title: String! + createdAt: AWSDateTime! + updatedAt: AWSDateTime! +} + +"""This is a description""" +input PostInput { + title: String! +} + +type Query { + getPost(id: ID!): Post! + getUser: User! +} + +type User { + id: ID! + name: String! + role: String! @aws_oidc + email: AWSEmail! + posts: [Post!]! +} + +input UserInput { + name: String! +}" +`; + +exports[`schema should merge the schemas 1`] = ` +"type Mutation { + createPost(post: PostInput!): Post! + createUser(post: UserInput!): User! +} + +type Post @aws_oidc { + id: ID! + title: String! + createdAt: AWSDateTime! + updatedAt: AWSDateTime! +} + +"""This is a description""" +input PostInput { + title: String! +} + +type Query { + getPost(id: ID!): Post! + getUser: User! +} + +type User { + id: ID! + name: String! + role: String! @aws_oidc + email: AWSEmail! + posts: [Post!]! +} + +input UserInput { + name: String! +}" +`; + +exports[`schema should return single files schemas as-is 1`] = ` +"type Query { + getUser: User! +} + +type Mutation { + createUser(post: UserInput!): User! +} + +""" +A User +""" +type User { + id: ID! + name: String! +} + +# Input for user +input UserInput { + name: String! +} +" +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/waf.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/waf.test.js.snap new file mode 100644 index 000000000..ac199bb78 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/__snapshots__/waf.test.js.snap @@ -0,0 +1,607 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Waf ApiKey rules should generate a rule for customRule 1`] = ` +[ + { + "Action": { + "Allow": {}, + }, + "Name": "MyCustomRule", + "Priority": undefined, + "Statement": { + "AndStatement": { + "Statements": [ + { + "GeoMatchStatement": { + "CountryCodes": [ + "US", + ], + }, + }, + { + "ByteMatchStatement": { + "FieldToMatch": { + "SingleHeader": { + "Name": "X-Api-key", + }, + }, + "PositionalConstraint": "EXACTLY", + "SearchString": { + "Fn::GetAtt": [ + "GraphQlApiMyKey", + "ApiKey", + ], + }, + "TextTransformations": [ + { + "Priority": 0, + "Type": "LOWERCASE", + }, + ], + }, + }, + ], + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "MyCustomRule", + "SampledRequestsEnabled": true, + }, + }, +] +`; + +exports[`Waf ApiKey rules should generate a rule for disableIntrospection 1`] = ` +[ + { + "Action": { + "Block": {}, + }, + "Name": "MyKeyDisableIntrospection", + "Priority": undefined, + "Statement": { + "AndStatement": { + "Statements": [ + { + "OrStatement": { + "Statements": [ + { + "SizeConstraintStatement": { + "ComparisonOperator": "GT", + "FieldToMatch": { + "Body": {}, + }, + "Size": 8192, + "TextTransformations": [ + { + "Priority": 0, + "Type": "NONE", + }, + ], + }, + }, + { + "ByteMatchStatement": { + "FieldToMatch": { + "Body": {}, + }, + "PositionalConstraint": "CONTAINS", + "SearchString": "__schema", + "TextTransformations": [ + { + "Priority": 0, + "Type": "COMPRESS_WHITE_SPACE", + }, + ], + }, + }, + ], + }, + }, + { + "ByteMatchStatement": { + "FieldToMatch": { + "SingleHeader": { + "Name": "X-Api-key", + }, + }, + "PositionalConstraint": "EXACTLY", + "SearchString": { + "Fn::GetAtt": [ + "GraphQlApiMyKey", + "ApiKey", + ], + }, + "TextTransformations": [ + { + "Priority": 0, + "Type": "LOWERCASE", + }, + ], + }, + }, + ], + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "MyKeyDisableIntrospection", + "SampledRequestsEnabled": true, + }, + }, +] +`; + +exports[`Waf ApiKey rules should generate a rule for emptyStatements 1`] = ` +[ + { + "Action": { + "Allow": {}, + }, + "Name": "rulesWithoutStatements", + "Priority": undefined, + "Statement": { + "ByteMatchStatement": { + "FieldToMatch": { + "SingleHeader": { + "Name": "X-Api-key", + }, + }, + "PositionalConstraint": "EXACTLY", + "SearchString": { + "Fn::GetAtt": [ + "GraphQlApiMyKey", + "ApiKey", + ], + }, + "TextTransformations": [ + { + "Priority": 0, + "Type": "LOWERCASE", + }, + ], + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "rulesWithoutStatements", + "SampledRequestsEnabled": true, + }, + }, +] +`; + +exports[`Waf ApiKey rules should generate a rule for throttle 1`] = ` +[ + { + "Action": { + "Block": {}, + }, + "Name": "MyKeyThrottle", + "Priority": undefined, + "Statement": { + "RateBasedStatement": { + "AggregateKeyType": "IP", + "ForwardedIPConfig": undefined, + "Limit": 100, + "ScopeDownStatement": { + "ByteMatchStatement": { + "FieldToMatch": { + "SingleHeader": { + "Name": "X-Api-key", + }, + }, + "PositionalConstraint": "EXACTLY", + "SearchString": { + "Fn::GetAtt": [ + "GraphQlApiMyKey", + "ApiKey", + ], + }, + "TextTransformations": [ + { + "Priority": 0, + "Type": "LOWERCASE", + }, + ], + }, + }, + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "MyKeyThrottle", + "SampledRequestsEnabled": true, + }, + }, +] +`; + +exports[`Waf ApiKey rules should generate a rule for throttleWithStatements 1`] = ` +[ + { + "Action": { + "Block": {}, + }, + "Name": "Throttle rule with custom ScopeDownStatement", + "Priority": undefined, + "Statement": { + "RateBasedStatement": { + "AggregateKeyType": "IP", + "ForwardedIPConfig": undefined, + "Limit": 100, + "ScopeDownStatement": { + "AndStatement": { + "Statements": [ + { + "ByteMatchStatement": { + "FieldToMatch": { + "SingleHeader": { + "Name": "X-Foo", + }, + }, + "PositionalConstraint": "EXACTLY", + "SearchString": "Bar", + "TextTransformations": [ + { + "Priority": 0, + "Type": "LOWERCASE", + }, + ], + }, + }, + { + "ByteMatchStatement": { + "FieldToMatch": { + "SingleHeader": { + "Name": "X-Api-key", + }, + }, + "PositionalConstraint": "EXACTLY", + "SearchString": { + "Fn::GetAtt": [ + "GraphQlApiMyKey", + "ApiKey", + ], + }, + "TextTransformations": [ + { + "Priority": 0, + "Type": "LOWERCASE", + }, + ], + }, + }, + ], + }, + }, + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "Throttle rule with custom ScopeDownStatement", + "SampledRequestsEnabled": true, + }, + }, +] +`; + +exports[`Waf Base Resources should generate only the waf association 1`] = ` +{ + "GraphQlWafAssoc": { + "Properties": { + "ResourceArn": { + "Fn::GetAtt": [ + "GraphQlApi", + "Arn", + ], + }, + "WebACLArn": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/my-Waf/d7b694d2-4f7d-4dd6-a9a9-843dd1931330", + }, + "Type": "AWS::WAFv2::WebACLAssociation", + }, +} +`; + +exports[`Waf Base Resources should generate waf Resources 1`] = ` +{ + "GraphQlWaf": { + "Properties": { + "DefaultAction": { + "Allow": {}, + }, + "Description": "My Waf ACL", + "Name": "Waf", + "Rules": [], + "Scope": "REGIONAL", + "Tags": [ + { + "Key": "stage", + "Value": "Dev", + }, + ], + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "MyVisibilityConfig", + "SampledRequestsEnabled": true, + }, + }, + "Type": "AWS::WAFv2::WebACL", + }, + "GraphQlWafAssoc": { + "Properties": { + "ResourceArn": { + "Fn::GetAtt": [ + "GraphQlApi", + "Arn", + ], + }, + "WebACLArn": { + "Fn::GetAtt": [ + "GraphQlWaf", + "Arn", + ], + }, + }, + "Type": "AWS::WAFv2::WebACLAssociation", + }, +} +`; + +exports[`Waf Base Resources should generate waf Resources without tags 1`] = ` +{ + "GraphQlWaf": { + "Properties": { + "DefaultAction": { + "Allow": {}, + }, + "Description": "My Waf ACL", + "Name": "Waf", + "Rules": [], + "Scope": "REGIONAL", + "Tags": undefined, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "MyVisibilityConfig", + "SampledRequestsEnabled": true, + }, + }, + "Type": "AWS::WAFv2::WebACL", + }, + "GraphQlWafAssoc": { + "Properties": { + "ResourceArn": { + "Fn::GetAtt": [ + "GraphQlApi", + "Arn", + ], + }, + "WebACLArn": { + "Fn::GetAtt": [ + "GraphQlWaf", + "Arn", + ], + }, + }, + "Type": "AWS::WAFv2::WebACLAssociation", + }, +} +`; + +exports[`Waf Custom rules should generate a custom rule 1`] = ` +{ + "Action": { + "Block": {}, + }, + "Name": "disable US", + "Priority": 200, + "Statement": { + "GeoMatchStatement": { + "CountryCodes": [ + "US", + ], + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "disable US", + "SampledRequestsEnabled": true, + }, +} +`; + +exports[`Waf Custom rules should generate a custom rule with ManagedRuleGroup 1`] = ` +{ + "Name": "MyRule1", + "OverrideAction": { + "None": {}, + }, + "Priority": 200, + "Statement": { + "ManagedRuleGroupStatement": { + "Name": "AWSManagedRulesCommonRuleSet", + "VendorName": "AWS", + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "MyRule1", + "SampledRequestsEnabled": true, + }, +} +`; + +exports[`Waf Disable introspection should generate a preset rule 1`] = ` +{ + "Action": { + "Block": {}, + }, + "Name": "BaseDisableIntrospection", + "Priority": undefined, + "Statement": { + "OrStatement": { + "Statements": [ + { + "SizeConstraintStatement": { + "ComparisonOperator": "GT", + "FieldToMatch": { + "Body": {}, + }, + "Size": 8192, + "TextTransformations": [ + { + "Priority": 0, + "Type": "NONE", + }, + ], + }, + }, + { + "ByteMatchStatement": { + "FieldToMatch": { + "Body": {}, + }, + "PositionalConstraint": "CONTAINS", + "SearchString": "__schema", + "TextTransformations": [ + { + "Priority": 0, + "Type": "COMPRESS_WHITE_SPACE", + }, + ], + }, + }, + ], + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "BaseDisableIntrospection", + "SampledRequestsEnabled": true, + }, +} +`; + +exports[`Waf Disable introspection should generate a preset rule with custon config 1`] = ` +{ + "Action": { + "Block": {}, + }, + "Name": "BaseDisableIntrospection", + "Priority": 200, + "Statement": { + "OrStatement": { + "Statements": [ + { + "SizeConstraintStatement": { + "ComparisonOperator": "GT", + "FieldToMatch": { + "Body": {}, + }, + "Size": 8192, + "TextTransformations": [ + { + "Priority": 0, + "Type": "NONE", + }, + ], + }, + }, + { + "ByteMatchStatement": { + "FieldToMatch": { + "Body": {}, + }, + "PositionalConstraint": "CONTAINS", + "SearchString": "__schema", + "TextTransformations": [ + { + "Priority": 0, + "Type": "COMPRESS_WHITE_SPACE", + }, + ], + }, + }, + ], + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": false, + "MetricName": "DisableIntrospection", + "SampledRequestsEnabled": false, + }, +} +`; + +exports[`Waf Throttle rules should generate a preset rule 1`] = ` +{ + "Action": { + "Block": {}, + }, + "Name": "BaseThrottle", + "Priority": undefined, + "Statement": { + "RateBasedStatement": { + "AggregateKeyType": "IP", + "ForwardedIPConfig": undefined, + "Limit": 100, + "ScopeDownStatement": undefined, + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "BaseThrottle", + "SampledRequestsEnabled": true, + }, +} +`; + +exports[`Waf Throttle rules should generate a preset rule with config 1`] = ` +{ + "Action": { + "Block": {}, + }, + "Name": "BaseThrottle", + "Priority": 300, + "Statement": { + "RateBasedStatement": { + "AggregateKeyType": "FORWARDED_IP", + "ForwardedIPConfig": { + "FallbackBehavior": "MATCH", + "HeaderName": "X-Forwarded-To", + }, + "Limit": 200, + "ScopeDownStatement": undefined, + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": false, + "MetricName": "ThrottleRule", + "SampledRequestsEnabled": false, + }, +} +`; + +exports[`Waf Throttle rules should generate a preset rule with limit 1`] = ` +{ + "Action": { + "Block": {}, + }, + "Name": "BaseThrottle", + "Priority": undefined, + "Statement": { + "RateBasedStatement": { + "AggregateKeyType": "IP", + "ForwardedIPConfig": undefined, + "Limit": 500, + "ScopeDownStatement": undefined, + }, + }, + "VisibilityConfig": { + "CloudWatchMetricsEnabled": true, + "MetricName": "BaseThrottle", + "SampledRequestsEnabled": true, + }, +} +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/api.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/api.test.js new file mode 100644 index 000000000..8ff42edc0 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/api.test.js @@ -0,0 +1,427 @@ +import { jest } from '@jest/globals' +import { Api } from '../../../../../../lib/plugins/aws/appsync/resources/Api.js' +import * as given from './given.js' + +// 2020-12-09T16:24:22+00:00 +jest.spyOn(Date, 'now').mockImplementation(() => 1607531062000) + +const plugin = given.plugin() + +describe('Api', () => { + describe('compileEndpoint', () => { + it('should compile the Api Resource', () => { + const api = new Api(given.appSyncConfig(), plugin) + expect(api.compileEndpoint()).toMatchSnapshot() + }) + + it('should compile the Api Resource for a private endpoint', () => { + const api = new Api( + given.appSyncConfig({ + visibility: 'PRIVATE', + }), + plugin, + ) + expect(api.compileEndpoint()).toMatchSnapshot() + }) + + it('should compile the Api Resource with config', () => { + const api = new Api( + given.appSyncConfig({ + introspection: false, + queryDepthLimit: 10, + resolverCountLimit: 20, + }), + plugin, + ) + expect(api.compileEndpoint()).toMatchSnapshot() + }) + + it('should compile the Api Resource with Environments', () => { + const api = new Api( + given.appSyncConfig({ + environment: { + TABLE_NAME: 'MyTable', + OTHER_TABLE: { + Ref: 'OtherTable', + }, + }, + }), + plugin, + ) + expect(api.compileEndpoint()).toMatchSnapshot() + }) + + it('should compile the Api Resource with logs enabled', () => { + const api = new Api( + given.appSyncConfig({ + logging: { + level: 'ERROR', + excludeVerboseContent: false, + retentionInDays: 14, + }, + }), + plugin, + ) + expect(api.compileEndpoint()).toMatchSnapshot() + }) + + it('should compile the Api Resource with additional auths', () => { + const api = new Api( + given.appSyncConfig({ + authentication: { + type: 'AMAZON_COGNITO_USER_POOLS', + config: { + userPoolId: 'pool123', + awsRegion: 'us-east-1', + appIdClientRegex: '[a-z]', + }, + }, + additionalAuthentications: [ + { + type: 'AMAZON_COGNITO_USER_POOLS', + config: { + userPoolId: 'pool123', + awsRegion: 'us-east-1', + appIdClientRegex: '[a-z]', + }, + }, + { + type: 'AWS_IAM', + }, + { + type: 'OPENID_CONNECT', + config: { + issuer: 'https://auth.example.com', + clientId: '333746dd-06fc-44df-bce2-5ff108724044', + iatTTL: 3600, + authTTL: 60, + }, + }, + { + type: 'AWS_LAMBDA', + config: { + functionName: 'authFunction', + identityValidationExpression: 'customm-*', + authorizerResultTtlInSeconds: 300, + }, + }, + ], + }), + plugin, + ) + expect(api.compileEndpoint()).toMatchSnapshot() + }) + + it('should compile the Api Resource with embedded authorizer Lambda function', () => { + const api = new Api( + given.appSyncConfig({ + authentication: { + type: 'AWS_LAMBDA', + config: { + function: { + handler: 'index.handler', + }, + }, + }, + }), + plugin, + ) + expect(api.compileEndpoint()).toMatchSnapshot() + expect(api.functions).toMatchSnapshot() + }) + + it('should compile the Api Resource with embedded additional authorizer Lambda function', () => { + const api = new Api( + given.appSyncConfig({ + additionalAuthentications: [ + { + type: 'AWS_LAMBDA', + config: { + function: { + handler: 'index.handler', + }, + }, + }, + ], + }), + plugin, + ) + expect(api.compileEndpoint()).toMatchSnapshot() + expect(api.functions).toMatchSnapshot() + }) + }) + + describe('Logs', () => { + it('should not compile CloudWatch Resources when logging not configured', () => { + const api = new Api(given.appSyncConfig(), plugin) + expect(api.compileCloudWatchLogGroup()).toMatchSnapshot() + }) + + it('should not compile CloudWatch Resources when logging is disabled', () => { + const api = new Api( + given.appSyncConfig({ + logging: { + level: 'ERROR', + retentionInDays: 14, + enabled: false, + }, + }), + plugin, + ) + expect(api.compileCloudWatchLogGroup()).toMatchSnapshot() + }) + + it('should compile CloudWatch Resources when enabled', () => { + const api = new Api( + given.appSyncConfig({ + logging: { + level: 'ERROR', + retentionInDays: 14, + }, + }), + plugin, + ) + expect(api.compileCloudWatchLogGroup()).toMatchSnapshot() + }) + }) + + describe('apiKeys', () => { + const api = new Api(given.appSyncConfig(), plugin) + + it('should generate an api key with sliding window expiration in numeric hours', () => { + expect( + api.compileApiKey({ + name: 'Default', + description: 'Default Key', + expiresAfter: 24, + }), + ).toMatchSnapshot() + }) + + it('should generate an api key with sliding window expiration in string hours', () => { + expect( + api.compileApiKey({ + name: 'Default', + description: 'Default Key', + expiresAfter: '24', + }), + ).toMatchSnapshot() + }) + + it('should generate an api key with sliding window expiration in duration', () => { + expect( + api.compileApiKey({ + name: 'Default', + description: 'Default Key', + expiresAfter: '30d', + }), + ).toMatchSnapshot() + }) + + it('should generate an api key with explicit expiresAt', () => { + expect( + api.compileApiKey({ + name: 'Default', + description: 'Default Key', + expiresAt: '2022-12-31T22:00:00+00:00', + }), + ).toMatchSnapshot() + }) + + it('should generate an api key with default expiry', () => { + expect( + api.compileApiKey({ + name: 'Default', + description: 'Default Key', + }), + ).toMatchSnapshot() + }) + }) + + describe('LambdaAuthorizer', () => { + it('should not generate the Lambda Authorizer Resources', () => { + const api = new Api( + given.appSyncConfig({ + authentication: { + type: 'API_KEY', + }, + }), + plugin, + ) + expect(api.compileLambdaAuthorizerPermission()).toMatchSnapshot() + }) + + it('should generate the Lambda Authorizer Resources from basic auth', () => { + const api = new Api( + given.appSyncConfig({ + authentication: { + type: 'AWS_LAMBDA', + config: { + functionArn: 'arn:', + }, + }, + }), + plugin, + ) + expect(api.compileLambdaAuthorizerPermission()).toMatchSnapshot() + }) + + it('should generate the Lambda Authorizer Resources from additional auth', () => { + const api = new Api( + given.appSyncConfig({ + additionalAuthentications: [ + { + type: 'AWS_LAMBDA', + config: { + functionArn: 'arn:', + }, + }, + ], + }), + plugin, + ) + expect(api.compileLambdaAuthorizerPermission()).toMatchSnapshot() + }) + }) +}) + +describe('Caching', () => { + it('should not generate Resources when not configured', () => { + const api = new Api(given.appSyncConfig({ caching: undefined }), plugin) + expect(api.compileCachingResources()).toEqual({}) + }) + + it('should not generate Resources when disabled', () => { + const api = new Api( + given.appSyncConfig({ + caching: { enabled: false, behavior: 'FULL_REQUEST_CACHING' }, + }), + plugin, + ) + expect(api.compileCachingResources()).toEqual({}) + }) + + it('should generate Resources with defaults', () => { + const api = new Api( + given.appSyncConfig({ + caching: { + behavior: 'FULL_REQUEST_CACHING', + }, + }), + plugin, + ) + expect(api.compileCachingResources()).toMatchSnapshot() + }) + + it('should generate Resources with custom Config', () => { + const api = new Api( + given.appSyncConfig({ + caching: { + behavior: 'FULL_REQUEST_CACHING', + atRestEncryption: true, + transitEncryption: true, + ttl: 500, + type: 'T2_MEDIUM', + }, + }), + plugin, + ) + expect(api.compileCachingResources()).toMatchSnapshot() + }) +}) + +describe('Domains', () => { + it('should not generate domain resources when not configured', () => { + const api = new Api(given.appSyncConfig({ domain: undefined }), plugin) + expect(api.compileCustomDomain()).toMatchSnapshot() + }) + + it('should not generate domain resources when disabled', () => { + const api = new Api( + given.appSyncConfig({ + domain: { + enabled: false, + name: 'api.example.com', + certificateArn: + 'arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52', + }, + }), + plugin, + ) + expect(api.compileCustomDomain()).toMatchSnapshot() + }) + + it('should generate domain resources', () => { + const api = new Api( + given.appSyncConfig({ + domain: { + name: 'api.example.com', + hostedZoneId: 'Z111111QQQQQQQ', + }, + }), + plugin, + ) + expect(api.compileCustomDomain()).toMatchSnapshot() + }) + + it('should generate domain resources with custom certificate ARN', () => { + const api = new Api( + given.appSyncConfig({ + domain: { + name: 'api.example.com', + certificateArn: + 'arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52', + }, + }), + plugin, + ) + expect(api.compileCustomDomain()).toMatchSnapshot() + }) + + it('should not generate Route53 Record when disabled', () => { + const api = new Api( + given.appSyncConfig({ + domain: { + name: 'api.example.com', + certificateArn: + 'arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52', + route53: false, + }, + }), + plugin, + ) + expect(api.compileCustomDomain().GraphQlDomainRoute53Record).toBeUndefined() + }) + + it('should generate domain resources with custom hostedZoneId', () => { + const api = new Api( + given.appSyncConfig({ + domain: { + name: 'api.example.com', + certificateArn: + 'arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52', + hostedZoneId: 'Z111111QQQQQQQ', + route53: true, + }, + }), + plugin, + ) + expect(api.compileCustomDomain()).toMatchSnapshot() + }) + + it('should generate domain resources with custom hostedZoneName', () => { + const api = new Api( + given.appSyncConfig({ + domain: { + name: 'foo.api.example.com', + certificateArn: + 'arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52', + hostedZoneName: 'example.com.', + route53: true, + }, + }), + plugin, + ) + expect(api.compileCustomDomain()).toMatchSnapshot() + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/basicConfig.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/basicConfig.js new file mode 100644 index 000000000..0d1a4a2c6 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/basicConfig.js @@ -0,0 +1,8 @@ +export const basicConfig = { + name: 'My Api', + authentication: { + type: 'API_KEY', + }, + dataSources: {}, + resolvers: {}, +} diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/dataSources.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/dataSources.test.js new file mode 100644 index 000000000..9bcff05fe --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/dataSources.test.js @@ -0,0 +1,466 @@ +import { Api } from '../../../../../../lib/plugins/aws/appsync/resources/Api.js' +import { DataSource } from '../../../../../../lib/plugins/aws/appsync/resources/DataSource.js' +import * as given from './given.js' + +const plugin = given.plugin() + +describe('DataSource', () => { + describe('DynamoDB', () => { + it('should generate Resource with default role', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_DYNAMODB', + name: 'dynamo', + description: 'My dynamo table', + config: { + tableName: 'data', + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate Resource with default deltaSync', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_DYNAMODB', + name: 'dynamo', + description: 'My dynamo table', + config: { + tableName: 'data', + versioned: true, + deltaSyncConfig: { + deltaSyncTableName: 'syncTable', + baseTableTTL: 60, + deltaSyncTableTTL: 120, + }, + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate default role with custom region', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_DYNAMODB', + name: 'dynamo', + description: 'My dynamo table', + config: { + tableName: 'data', + region: 'us-east-2', + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate default role with a Ref for the table name', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_DYNAMODB', + name: 'dynamo', + description: 'My dynamo table', + config: { + tableName: { Ref: 'MyTable' }, + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate default role with custom statement', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_DYNAMODB', + name: 'dynamo', + description: 'My dynamo table', + config: { + tableName: 'data', + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['dynamodb:GetItem'], + Resource: ['arn:aws:dynamodb:us-east-1:12345678:myTable'], + }, + ], + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toMatchSnapshot() + }) + + it('should not generate default role when arn is passed', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_DYNAMODB', + name: 'dynamo', + description: 'My dynamo table', + config: { + tableName: 'data', + serviceRoleArn: 'arn:aws:iam:', + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toBeUndefined() + }) + }) + + describe('EventBridge', () => { + it('should generate Resource with default role', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_EVENTBRIDGE', + name: 'eventBridge', + description: 'My eventBridge bus', + config: { + eventBusArn: + 'arn:aws:events:us-east-1:123456789012:event-bus/default', + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate default role with a Ref for the bus ARN', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_EVENTBRIDGE', + name: 'eventBridge', + description: 'My eventBridge bus', + config: { + eventBusArn: { 'Fn::GetAtt': ['MyEventBus', 'Arn'] }, + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate default role with custom statement', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_EVENTBRIDGE', + name: 'eventBridge', + description: 'My eventBridge bus', + config: { + eventBusArn: + 'arn:aws:events:us-east-1:123456789012:event-bus/default', + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['events:PutEvents'], + Resource: [ + 'arn:aws:events:us-east-1:123456789012:event-bus/default', + 'arn:aws:events:us-east-1:123456789012:event-bus/other', + ], + }, + ], + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toMatchSnapshot() + }) + + it('should not generate default role when a service role arn is passed', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_EVENTBRIDGE', + name: 'eventBridge', + description: 'My eventBridge bus', + config: { + eventBusArn: + 'arn:aws:events:us-east-1:123456789012:event-bus/default', + serviceRoleArn: 'arn:aws:iam:', + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toBeUndefined() + }) + }) + + describe('AWS Lambda', () => { + it('should generate Resource with default role', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AWS_LAMBDA', + name: 'myFunction', + description: 'My lambda resolver', + config: { + functionName: 'myFunction', + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate Resource with embedded function', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AWS_LAMBDA', + name: 'myDataSource', + description: 'My lambda resolver', + config: { + function: { + handler: 'index.handler', + }, + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + expect(api.functions).toMatchSnapshot() + }) + + it('should generate default role with custom statements', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AWS_LAMBDA', + name: 'myFunction', + description: 'My lambda resolver', + config: { + functionName: 'myFunction', + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['lambda:invokeFunction'], + Resource: [{ Ref: 'MyFunction' }], + }, + ], + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toMatchSnapshot() + }) + + it('should not generate default role when arn is passed', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AWS_LAMBDA', + name: 'myFunction', + description: 'My lambda resolver', + config: { + functionName: 'myFunction', + serviceRoleArn: 'arn:aws:iam:', + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toBeUndefined() + }) + }) + + describe('HTTP', () => { + it('should generate Resource without roles', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'HTTP', + name: 'myEndpoint', + description: 'My HTTP resolver', + config: { + endpoint: 'https://api.example.com', + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate Resource with IAM authorization config', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'HTTP', + name: 'myEndpoint', + description: 'My HTTP resolver', + config: { + endpoint: 'https://events.us-east-1.amazonaws.com/', + authorizationConfig: { + authorizationType: 'AWS_IAM', + awsIamConfig: { + signingRegion: { Ref: 'AWS::Region' }, + signingServiceName: 'events', + }, + }, + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['events:PutEvents'], + Resource: ['*'], + }, + ], + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate default role with custom statements', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'HTTP', + name: 'myEndpoint', + description: 'My HTTP resolver', + config: { + endpoint: 'https://events.us-east-1.amazonaws.com/', + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['events:PutEvents'], + Resource: ['*'], + }, + ], + authorizationConfig: { + authorizationType: 'AWS_IAM', + awsIamConfig: { + signingRegion: { Ref: 'AWS::Region' }, + signingServiceName: 'events', + }, + }, + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toMatchSnapshot() + }) + + it('should not generate default role when arn is passed', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'HTTP', + name: 'myEndpoint', + description: 'My HTTP resolver', + config: { + endpoint: 'https://events.us-east-1.amazonaws.com/', + serviceRoleArn: 'arn:aws:iam:', + authorizationConfig: { + authorizationType: 'AWS_IAM', + awsIamConfig: { + signingRegion: { Ref: 'AWS::Region' }, + signingServiceName: 'events', + }, + }, + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toBeUndefined() + }) + }) + + describe('OpenSearch', () => { + it('should generate Resource without roles', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_OPENSEARCH_SERVICE', + name: 'opensearch', + description: 'OpenSearch resolver', + config: { + domain: 'myDomain', + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate Resource with endpoint', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_OPENSEARCH_SERVICE', + name: 'opensearch', + description: 'OpenSearch resolver', + config: { + endpoint: 'https://mydomain.us-east-1.es.amazonaws.com', + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate default role with custom statements', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_OPENSEARCH_SERVICE', + name: 'opensearch', + description: 'OpenSearch resolver', + config: { + domain: 'myDomain', + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['es:ESHttpGet'], + Resource: ['arn:aws:es:us-east-1:12345678:domain/myDomain'], + }, + ], + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toMatchSnapshot() + }) + + it('should not generate default role when arn is passed', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_OPENSEARCH_SERVICE', + name: 'opensearch', + description: 'OpenSearch resolver', + config: { + domain: 'myDomain', + serviceRoleArn: 'arn:aim::', + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toBeUndefined() + }) + }) + + describe('Relational Databases', () => { + it('should generate Resource with default role', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'RELATIONAL_DATABASE', + name: 'myDatabase', + description: 'My RDS database', + config: { + dbClusterIdentifier: 'myCluster', + databaseName: 'myDatabase', + awsSecretStoreArn: { Ref: 'MyRdsCluster' }, + }, + }) + + expect(dataSource.compile()).toMatchSnapshot() + }) + + it('should generate DynamoDB default role with custom statement', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'RELATIONAL_DATABASE', + name: 'myDatabase', + description: 'My RDS database', + config: { + dbClusterIdentifier: 'myCluster', + databaseName: 'myDatabase', + awsSecretStoreArn: { Ref: 'MyRdsCluster' }, + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['rds-data:DeleteItems'], + Resource: ['arn:aws:rds:us-east-1:12345678:cluster:myCluster'], + }, + ], + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toMatchSnapshot() + }) + + it('should not generate default role when arn is passed', () => { + const api = new Api(given.appSyncConfig(), plugin) + const dataSource = new DataSource(api, { + type: 'AMAZON_DYNAMODB', + name: 'dynamo', + description: 'My dynamo table', + config: { + tableName: 'data', + region: 'us-east-1', + serviceRoleArn: 'arn:aws:iam:', + }, + }) + + expect(dataSource.compileDataSourceIamRole()).toBeUndefined() + }) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/post.graphql b/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/post.graphql new file mode 100644 index 000000000..7af0864d7 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/post.graphql @@ -0,0 +1,22 @@ +extend type Query { + getPost(id: ID!): Post! +} + +extend type Mutation { + createPost(post: PostInput!): Post! +} + +# This is a comment +type Post @aws_oidc { + id: ID! + title: String! + createdAt: AWSDateTime! + updatedAt: AWSDateTime! +} + +""" +This is a description +""" +input PostInput { + title: String! +} diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/schema.graphql b/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/schema.graphql new file mode 100644 index 000000000..a0b58a0ab --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/schema.graphql @@ -0,0 +1,3 @@ +type Query + +type Mutation diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/user.graphql b/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/user.graphql new file mode 100644 index 000000000..2df43c26d --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/user.graphql @@ -0,0 +1,19 @@ +extend type Query { + getUser: User! +} + +extend type Mutation { + createUser(post: UserInput!): User! +} + +type User { + id: ID! + name: String! + role: String! @aws_oidc + email: AWSEmail! + posts: [Post!]! +} + +input UserInput { + name: String! +} diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/single/schema.graphql b/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/single/schema.graphql new file mode 100644 index 000000000..d869f472b --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/fixtures/schemas/single/schema.graphql @@ -0,0 +1,20 @@ +type Query { + getUser: User! +} + +type Mutation { + createUser(post: UserInput!): User! +} + +""" +A User +""" +type User { + id: ID! + name: String! +} + +# Input for user +input UserInput { + name: String! +} diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/getAppSyncConfig.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/getAppSyncConfig.test.js new file mode 100644 index 000000000..39ef83965 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/getAppSyncConfig.test.js @@ -0,0 +1,308 @@ +import _ from 'lodash' +const { pick } = _ +import { getAppSyncConfig } from '../../../../../../lib/plugins/aws/appsync/get-appsync-config.js' +import { basicConfig } from './basicConfig.js' + +test('returns basic config', async () => { + expect(getAppSyncConfig(basicConfig)).toMatchSnapshot() +}) + +describe('Schema', () => { + it('should return the default schema', () => { + expect( + getAppSyncConfig({ ...basicConfig, schema: undefined }).schema, + ).toMatchSnapshot() + }) + + it('should return a single schema as an array', () => { + expect( + getAppSyncConfig({ ...basicConfig, schema: 'mySchema.graphql' }).schema, + ).toMatchSnapshot() + }) + + it('should return a schema array unchanged', () => { + expect( + getAppSyncConfig({ + ...basicConfig, + schema: ['users.graphql', 'posts.graphql'], + }).schema, + ).toMatchSnapshot() + }) +}) + +describe('Api Keys', () => { + it('should not generate a default Api Key when auth is not API_KEY', () => { + expect( + getAppSyncConfig({ ...basicConfig, authentication: { type: 'AWS_IAM' } }) + .apiKeys, + ).toBeUndefined() + }) + + it('should generate api keys', () => { + expect( + getAppSyncConfig({ + ...basicConfig, + apiKeys: [ + { + name: 'John', + description: "John's key", + expiresAt: '2021-03-09T16:00:00+00:00', + }, + { + name: 'Jane', + expiresAfter: '1y', + }, + 'InlineKey', + ], + }).apiKeys, + ).toMatchInlineSnapshot(` + { + "InlineKey": { + "name": "InlineKey", + }, + "Jane": { + "expiresAfter": "1y", + "name": "Jane", + }, + "John": { + "description": "John's key", + "expiresAt": "2021-03-09T16:00:00+00:00", + "name": "John", + }, + } + `) + }) +}) + +describe('DataSources', () => { + it('should merge dataSource arrays', async () => { + const config = getAppSyncConfig({ + ...basicConfig, + dataSources: [ + { + myDataSource: { + type: 'NONE', + }, + myOtherDataSource: { + type: 'NONE', + }, + }, + { + otherSource: { + type: 'NONE', + }, + anotherNamedSource: { + type: 'NONE', + }, + }, + ], + }) + expect(config.dataSources).toMatchSnapshot() + }) + + it('should merge dataSources embedded into resolvers and pipelineFunctions', async () => { + const config = getAppSyncConfig({ + ...basicConfig, + dataSources: { + myDataSource: { + type: 'NONE', + }, + myOtherDataSource: { + type: 'NONE', + }, + }, + resolvers: { + 'Query.getUser': { + kind: 'UNIT', + dataSource: { + type: 'AWS_LAMBDA', + config: { + functionName: 'getUser', + }, + }, + }, + getUsers: { + kind: 'UNIT', + type: 'Query', + field: 'getUsers', + dataSource: { + type: 'AWS_LAMBDA', + config: { + functionName: 'getUsers', + }, + }, + }, + 'Mutation.createUser': { + kind: 'PIPELINE', + functions: [ + { + dataSource: { + type: 'AWS_LAMBDA', + config: { + functionName: 'createUser', + }, + }, + }, + ], + }, + }, + pipelineFunctions: { + function1: { + dataSource: { + type: 'AWS_LAMBDA', + config: { + functionName: 'function1', + }, + }, + }, + function2: { + dataSource: { + type: 'AWS_LAMBDA', + config: { + functionName: 'function2', + }, + }, + }, + }, + }) + expect( + pick(config, ['dataSources', 'resolvers', 'pipelineFunctions']), + ).toMatchSnapshot() + }) +}) + +describe('Resolvers', () => { + it('should resolve resolver type and fields', async () => { + const config = getAppSyncConfig({ + ...basicConfig, + resolvers: { + 'Query.getUser': { + kind: 'UNIT', + dataSource: 'users', + }, + getUsersResolver: { + type: 'Query', + field: 'getUsers', + kind: 'UNIT', + dataSource: 'users', + }, + }, + }) + expect(config.resolvers).toMatchSnapshot() + }) + + it('should merge resolvers arrays', async () => { + const config = getAppSyncConfig({ + ...basicConfig, + resolvers: [ + { + 'Query.getUser': { + kind: 'UNIT', + dataSource: 'users', + }, + getUsersResolver: { + kind: 'UNIT', + type: 'Query', + field: 'getUsers', + dataSource: 'users', + }, + 'Query.pipeline': { + kind: 'PIPELINE', + functions: ['function1', 'function2'], + }, + }, + { + 'Query.getPost': { + kind: 'UNIT', + dataSource: 'posts', + }, + getPostsResolver: { + type: 'Query', + kind: 'UNIT', + field: 'getPosts', + dataSource: 'posts', + }, + pipelineResolver2: { + kind: 'PIPELINE', + functions: ['function1', 'function2'], + type: 'Query', + field: 'getUsers', + }, + }, + ], + }) + expect(config.resolvers).toMatchSnapshot() + }) +}) + +describe('Pipeline Functions', () => { + it('should merge function arrays', async () => { + const config = getAppSyncConfig({ + ...basicConfig, + + pipelineFunctions: [ + { + function1: { + dataSource: 'users', + }, + function2: { + dataSource: 'users', + }, + }, + { + function3: { + dataSource: 'users', + }, + function4: { + dataSource: 'users', + }, + }, + ], + }) + expect(config.pipelineFunctions).toMatchSnapshot() + }) + + it('should merge inline function definitions', async () => { + const config = getAppSyncConfig({ + ...basicConfig, + resolvers: { + 'Mutation.createUser': { + kind: 'PIPELINE', + functions: [ + { + dataSource: 'users', + code: 'function1.js', + }, + { + dataSource: 'users', + code: 'function2.js', + }, + ], + }, + 'Mutation.updateUser': { + kind: 'PIPELINE', + functions: [ + { + code: 'updateUser.js', + dataSource: { + type: 'AWS_LAMBDA', + config: { + functionName: 'updateUser', + }, + }, + }, + ], + }, + }, + pipelineFunctions: { + function1: { + dataSource: 'users', + }, + function2: { + dataSource: 'users', + }, + }, + }) + expect(config.pipelineFunctions).toMatchSnapshot() + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/given.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/given.js new file mode 100644 index 000000000..7d4abf572 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/given.js @@ -0,0 +1,103 @@ +import { jest } from '@jest/globals' +import _ from 'lodash' +const { set } = _ +import ServerlessAppsyncPlugin from '../../../../../../lib/plugins/aws/appsync/index.js' + +/** + * Creates a minimal mock Serverless object for testing. + * This avoids the complexity of the real Serverless class which requires + * credential providers and other initialization. + */ +export const createServerless = () => { + const serverless = { + config: { + servicePath: '', + }, + configurationInput: { + appSync: appSyncConfig(), + }, + configSchemaHandler: { + defineTopLevelProperty: jest.fn(), + }, + service: { + service: 'test-service', + provider: { + name: 'aws', + region: 'us-east-1', + stage: 'dev', + compiledCloudFormationTemplate: { + Resources: {}, + Outputs: {}, + }, + }, + resources: { + Resources: {}, + }, + functions: {}, + custom: {}, + }, + getProvider: () => ({ + naming: { + getStackName: () => 'test-stack', + getNormalizedFunctionName: (name) => + `${name.charAt(0).toUpperCase()}${name.slice(1)}`, + getLambdaLogicalId: (name) => + `${name.charAt(0).toUpperCase()}${name.slice(1)}LambdaFunction`, + getLambdaVersionLogicalId: (name, hash) => + `${name.charAt(0).toUpperCase()}${name.slice(1)}LambdaVersion${hash}`, + }, + getRegion: () => 'us-east-1', + getStage: () => 'dev', + }), + setProvider: function () {}, + } + + set(serverless, 'configurationInput.appSync', appSyncConfig()) + + return serverless +} + +export const plugin = () => { + const options = { + stage: 'dev', + region: 'us-east-1', + } + return new ServerlessAppsyncPlugin(createServerless(), options, { + log: { + error: jest.fn(), + warning: jest.fn(), + info: jest.fn(), + success: jest.fn(), + }, + progress: { + create: () => ({ + remove: jest.fn(), + }), + }, + writeText: jest.fn(), + }) +} + +export const appSyncConfig = (partial) => { + const config = { + name: 'MyApi', + xrayEnabled: false, + schema: ['schema.graphql'], + authentication: { + type: 'API_KEY', + }, + additionalAuthentications: [], + resolvers: {}, + pipelineFunctions: {}, + dataSources: {}, + substitutions: {}, + tags: { + stage: 'Dev', + }, + } + + return { + ...config, + ...partial, + } +} diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/index.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/index.test.js new file mode 100644 index 000000000..c67fb29f7 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/index.test.js @@ -0,0 +1,81 @@ +import * as given from './given.js' + +const plugin = given.plugin() + +describe('variable', () => { + it('should resolve the api id', () => { + expect( + plugin.resolveVariable({ + address: 'id', + options: {}, + resolveVariable: () => '', + }), + ).toMatchInlineSnapshot(` + { + "value": { + "Fn::GetAtt": [ + "GraphQlApi", + "ApiId", + ], + }, + } + `) + }) + + it('should resolve the api url', () => { + expect( + plugin.resolveVariable({ + address: 'url', + options: {}, + resolveVariable: () => '', + }), + ).toMatchInlineSnapshot(` + { + "value": { + "Fn::GetAtt": [ + "GraphQlApi", + "GraphQLUrl", + ], + }, + } + `) + }) + + it('should resolve the api arn', () => { + expect( + plugin.resolveVariable({ + address: 'arn', + options: {}, + resolveVariable: () => '', + }), + ).toMatchInlineSnapshot(` + { + "value": { + "Fn::GetAtt": [ + "GraphQlApi", + "Arn", + ], + }, + } + `) + }) + + it('should resolve an api key', () => { + expect( + plugin.resolveVariable({ + address: 'apiKey.foo', + options: {}, + resolveVariable: () => '', + }), + ).toMatchInlineSnapshot(` + { + "value": { + "Fn::GetAtt": [ + "GraphQlApifoo", + "ApiKey", + ], + }, + } + `) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/js-resolvers.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/js-resolvers.test.js new file mode 100644 index 000000000..72b77e358 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/js-resolvers.test.js @@ -0,0 +1,78 @@ +import { jest } from '@jest/globals' +import fs from 'fs' +import { Api } from '../../../../../../lib/plugins/aws/appsync/resources/Api.js' +import { JsResolver } from '../../../../../../lib/plugins/aws/appsync/resources/JsResolver.js' +import * as given from './given.js' + +const plugin = given.plugin() + +describe('JS Resolvers', () => { + let mock + let mockExists + + beforeEach(() => { + mock = jest + .spyOn(fs, 'readFileSync') + .mockImplementation( + (path) => `Content of ${`${path}`.replace(/\\/g, '/')}`, + ) + mockExists = jest.spyOn(fs, 'existsSync').mockReturnValue(true) + }) + + afterEach(() => { + mock.mockRestore() + mockExists.mockRestore() + }) + + it('should substitute variables', () => { + const api = new Api(given.appSyncConfig(), plugin) + const mapping = new JsResolver(api, { + path: 'foo.vtl', + substitutions: { + foo: 'bar', + var: { Ref: 'MyReference' }, + }, + }) + const template = `const foo = '#foo#'; + const var = '#var#'; + const unknonw = '#unknown#'` + expect(mapping.processTemplateSubstitutions(template)).toMatchSnapshot() + }) + + it('should substitute variables and use defaults', () => { + const api = new Api( + given.appSyncConfig({ + substitutions: { + foo: 'bar', + var: 'bizz', + }, + }), + plugin, + ) + const mapping = new JsResolver(api, { + path: 'foo.vtl', + substitutions: { + foo: 'fuzz', + }, + }) + const template = `const foo = '#foo#'; + const var = '#var#';` + expect(mapping.processTemplateSubstitutions(template)).toMatchSnapshot() + }) + + it('should fail if template is missing', () => { + mockExists = jest.spyOn(fs, 'existsSync').mockReturnValue(false) + const api = new Api(given.appSyncConfig(), plugin) + const mapping = new JsResolver(api, { + path: 'foo.vtl', + substitutions: { + foo: 'bar', + var: { Ref: 'MyReference' }, + }, + }) + + expect(function () { + mapping.compile() + }).toThrowErrorMatchingSnapshot() + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/mapping-templates.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/mapping-templates.test.js new file mode 100644 index 000000000..f62ae618f --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/mapping-templates.test.js @@ -0,0 +1,76 @@ +import { jest } from '@jest/globals' +import fs from 'fs' +import { Api } from '../../../../../../lib/plugins/aws/appsync/resources/Api.js' +import { MappingTemplate } from '../../../../../../lib/plugins/aws/appsync/resources/MappingTemplate.js' +import * as given from './given.js' + +const plugin = given.plugin() + +describe('Mapping Templates', () => { + let mock + let mockExists + + beforeEach(() => { + mock = jest + .spyOn(fs, 'readFileSync') + .mockImplementation( + (path) => `Content of ${`${path}`.replace(/\\/g, '/')}`, + ) + mockExists = jest.spyOn(fs, 'existsSync').mockReturnValue(true) + }) + + afterEach(() => { + mock.mockRestore() + mockExists.mockRestore() + }) + + it('should substitute variables', () => { + const api = new Api(given.appSyncConfig(), plugin) + const mapping = new MappingTemplate(api, { + path: 'foo.vtl', + substitutions: { + foo: 'bar', + var: { Ref: 'MyReference' }, + }, + }) + const template = + 'Foo: ${foo}, Var: ${var}, Context: ${ctx.args.id}, Unknonw: ${unknown}' + expect(mapping.processTemplateSubstitutions(template)).toMatchSnapshot() + }) + + it('should substitute variables and use defaults', () => { + const api = new Api( + given.appSyncConfig({ + substitutions: { + foo: 'bar', + var: 'bizz', + }, + }), + plugin, + ) + const mapping = new MappingTemplate(api, { + path: 'foo.vtl', + substitutions: { + foo: 'fuzz', + }, + }) + const template = 'Foo: ${foo}, Var: ${var}' + expect(mapping.processTemplateSubstitutions(template)).toMatchSnapshot() + }) + + it('should fail if template is missing', () => { + mockExists = jest.spyOn(fs, 'existsSync').mockReturnValue(false) + const api = new Api(given.appSyncConfig(), plugin) + const mapping = new MappingTemplate(api, { + path: 'foo.vtl', + substitutions: { + foo: 'bar', + var: { Ref: 'MyReference' }, + }, + }) + + expect(function () { + mapping.compile() + }).toThrowErrorMatchingSnapshot() + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/resolvers.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/resolvers.test.js new file mode 100644 index 000000000..c92196535 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/resolvers.test.js @@ -0,0 +1,531 @@ +import { jest } from '@jest/globals' +import fs from 'fs' +import { Api } from '../../../../../../lib/plugins/aws/appsync/resources/Api.js' +import * as given from './given.js' + +const plugin = given.plugin() + +describe('Resolvers', () => { + let mock + let mockExists + + beforeEach(() => { + mock = jest + .spyOn(fs, 'readFileSync') + .mockImplementation( + (path) => `Content of ${`${path}`.replace(/\\/g, '/')}`, + ) + mockExists = jest.spyOn(fs, 'existsSync').mockReturnValue(true) + }) + + afterEach(() => { + mock.mockRestore() + mockExists.mockRestore() + }) + + // Note: esbuild tests skipped - ESM modules can't be mocked with jest.spyOn + + describe('Unit Resolvers', () => { + it('should generate Resources with VTL mapping templates', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + dataSource: 'myTable', + kind: 'UNIT', + type: 'Query', + field: 'user', + request: 'path/to/mappingTemplates/Query.user.request.vtl', + response: 'path/to/mappingTemplates/Query.user.response.vtl', + }), + ).toMatchSnapshot() + }) + + // Skipped: requires esbuild mock (ESM can't mock esbuild module) + it.skip('should generate JS Resources with specific code', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + type: 'Query', + kind: 'UNIT', + field: 'user', + dataSource: 'myTable', + code: 'resolvers/getUserFunction.js', + }), + ).toMatchSnapshot() + }) + + it('should generate Resources with direct Lambda', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myLambdaFunction: { + name: 'myLambdaFunction', + type: 'AWS_LAMBDA', + config: { functionArn: 'arn:lambda:' }, + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + dataSource: 'myLambdaFunction', + kind: 'UNIT', + type: 'Query', + field: 'user', + }), + ).toMatchSnapshot() + }) + + it('should generate Resources with maxBatchSize', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myFunction: { + name: 'myFunction', + type: 'AWS_LAMBDA', + config: { functionName: 'myFunction' }, + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + dataSource: 'myFunction', + kind: 'UNIT', + type: 'Query', + field: 'user', + maxBatchSize: 200, + }), + ).toMatchSnapshot() + }) + + it('should generate Resources with sync configuration', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myLambdaFunction: { + name: 'myLambdaFunction', + type: 'AWS_LAMBDA', + config: { functionArn: 'arn:lambda:' }, + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + dataSource: 'myLambdaFunction', + kind: 'UNIT', + type: 'Query', + field: 'user', + sync: { + conflictDetection: 'VERSION', + conflictHandler: 'LAMBDA', + function: { + handler: 'index.handler', + }, + }, + }), + ).toMatchSnapshot() + expect(api.functions).toMatchSnapshot() + }) + + it('should fail when referencing unknown data source', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: {}, + }), + plugin, + ) + expect(function () { + api.compileResolver({ + dataSource: 'myLambdaFunction', + kind: 'UNIT', + type: 'Query', + field: 'user', + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + + describe('Pipeline Resolvers', () => { + it('should generate JS Resources with default empty resolver', () => { + mockExists.mockReturnValue(false) + const api = new Api( + given.appSyncConfig({ + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + pipelineFunctions: { + getUser: { + name: 'getUser', + dataSource: 'myTable', + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + type: 'Query', + field: 'user', + functions: ['getUser'], + }), + ).toMatchSnapshot() + }) + + it('should generate Resources with VTL mapping templates', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + pipelineFunctions: { + function1: { + name: 'function1', + dataSource: 'myTable', + }, + function2: { + name: 'function2', + dataSource: 'myTable', + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + kind: 'PIPELINE', + type: 'Query', + field: 'user', + request: 'Query.user.request.vtl', + response: 'Query.user.response.vtl', + functions: ['function1', 'function2'], + }), + ).toMatchSnapshot() + }) + + // Skipped: requires esbuild mock + it.skip('should generate JS Resources with specific code', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + pipelineFunctions: { + getUser: { + name: 'getUser', + dataSource: 'myTable', + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + type: 'Query', + field: 'user', + functions: ['getUser'], + code: 'resolvers/getUserFunction.js', + }), + ).toMatchSnapshot() + }) + + it('should fail when referencing unknown pipeline function', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + pipelineFunctions: { + function1: { + name: 'function1', + dataSource: 'myTable', + }, + }, + }), + plugin, + ) + expect(function () { + api.compileResolver({ + kind: 'PIPELINE', + type: 'Query', + field: 'user', + functions: ['function1', 'function2'], + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + + describe('Pipeline Function', () => { + // Skipped: requires esbuild mock + it.skip('should generate Pipeline Function Resources with JS code', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + }), + plugin, + ) + expect( + api.compilePipelineFunctionResource({ + name: 'function1', + dataSource: 'myTable', + description: 'Function1 Pipeline Resolver', + code: 'funciton1.js', + }), + ).toMatchSnapshot() + }) + + it('should generate Pipeline Function Resources with VTL mapping templates', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + }), + plugin, + ) + expect( + api.compilePipelineFunctionResource({ + name: 'function1', + dataSource: 'myTable', + description: 'Function1 Pipeline Resolver', + request: 'path/to/mappingTemplates/function1.request.vtl', + response: 'path/to/mappingTemplates/function1.response.vtl', + }), + ).toMatchSnapshot() + }) + + it('should generate Pipeline Function Resources with direct Lambda', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myLambdaFunction: { + name: 'myLambdaFunction', + type: 'AWS_LAMBDA', + config: { functionArn: 'arn:lambda:' }, + }, + }, + }), + plugin, + ) + expect( + api.compilePipelineFunctionResource({ + name: 'function1', + dataSource: 'myLambdaFunction', + description: 'Function1 Pipeline Resolver', + }), + ).toMatchSnapshot() + }) + + it('should generate Resources with sync configuration', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myLambdaFunction: { + name: 'myLambdaFunction', + type: 'AWS_LAMBDA', + config: { functionArn: 'arn:lambda:' }, + }, + }, + }), + plugin, + ) + expect( + api.compilePipelineFunctionResource({ + dataSource: 'myLambdaFunction', + name: 'myFunction', + request: 'myFunction.request.vtl', + response: 'myFunction.response.vtl', + sync: { + conflictDetection: 'VERSION', + conflictHandler: 'LAMBDA', + function: { + handler: 'index.handler', + }, + }, + }), + ).toMatchSnapshot() + }) + + it('should generate Pipeline Function Resources with maxBatchSize', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: { + myFunction: { + name: 'myFunction', + type: 'AWS_LAMBDA', + config: { functionName: 'myFunction' }, + }, + }, + }), + plugin, + ) + expect( + api.compilePipelineFunctionResource({ + name: 'function1', + dataSource: 'myFunction', + request: 'function1.request.vtl', + response: 'function1.response.vtl', + description: 'Function1 Pipeline Resolver', + maxBatchSize: 200, + }), + ).toMatchSnapshot() + }) + + it('should fail if Pipeline Function references unexisting data source', () => { + const api = new Api( + given.appSyncConfig({ + dataSources: {}, + }), + plugin, + ) + expect(function () { + api.compilePipelineFunctionResource({ + name: 'function1', + dataSource: 'myLambdaFunction', + description: 'Function1 Pipeline Resolver', + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + + describe('Caching', () => { + it('should generate Resources with caching enabled', () => { + const api = new Api( + given.appSyncConfig({ + caching: { + behavior: 'PER_RESOLVER_CACHING', + }, + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + dataSource: 'myTable', + kind: 'UNIT', + type: 'Query', + field: 'user', + caching: true, + }), + ).toMatchSnapshot() + }) + + it('should generate Resources with custom keys', () => { + const api = new Api( + given.appSyncConfig({ + caching: { + behavior: 'PER_RESOLVER_CACHING', + }, + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + dataSource: 'myTable', + kind: 'UNIT', + type: 'Query', + field: 'user', + caching: { + ttl: 200, + keys: ['$context.identity.sub', '$context.arguments.id'], + }, + }), + ).toMatchSnapshot() + }) + + it('should fallback to global caching TTL', () => { + const api = new Api( + given.appSyncConfig({ + caching: { + behavior: 'PER_RESOLVER_CACHING', + ttl: 300, + }, + dataSources: { + myTable: { + name: 'myTable', + type: 'AMAZON_DYNAMODB', + config: { tableName: 'data' }, + }, + }, + }), + plugin, + ) + expect( + api.compileResolver({ + dataSource: 'myTable', + kind: 'UNIT', + type: 'Query', + field: 'user', + caching: { + keys: ['$context.identity.sub', '$context.arguments.id'], + }, + }), + ).toMatchSnapshot() + }) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/schema.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/schema.test.js new file mode 100644 index 000000000..ad88a1fc6 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/schema.test.js @@ -0,0 +1,59 @@ +import { Api } from '../../../../../../lib/plugins/aws/appsync/resources/Api.js' +import { Schema } from '../../../../../../lib/plugins/aws/appsync/resources/Schema.js' +import * as given from './given.js' + +const plugin = given.plugin() + +describe('schema', () => { + it('should generate a schema resource', () => { + const api = new Api( + given.appSyncConfig({ + schema: [ + 'test/unit/lib/plugins/aws/appsync/fixtures/schemas/single/schema.graphql', + ], + }), + plugin, + ) + + expect(api.compileSchema()).toMatchSnapshot() + }) + + it('should merge the schemas', () => { + const api = new Api(given.appSyncConfig(), plugin) + const schema = new Schema(api, [ + 'test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/schema.graphql', + 'test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/user.graphql', + 'test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/post.graphql', + ]) + expect(schema.generateSchema()).toMatchSnapshot() + }) + + it('should merge glob schemas', () => { + const api = new Api(given.appSyncConfig(), plugin) + const schema = new Schema(api, [ + 'test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/*.graphql', + ]) + expect(schema.generateSchema()).toMatchSnapshot() + }) + + it('should fail if schema is invalid', () => { + const api = new Api( + given.appSyncConfig({ + schema: [ + 'test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/schema.graphql', + 'test/unit/lib/plugins/aws/appsync/fixtures/schemas/multiple/user.graphql', + ], + }), + plugin, + ) + expect(() => api.compileSchema()).toThrowErrorMatchingSnapshot() + }) + + it('should return single files schemas as-is', () => { + const api = new Api(given.appSyncConfig(), plugin) + const schema = new Schema(api, [ + 'test/unit/lib/plugins/aws/appsync/fixtures/schemas/single/schema.graphql', + ]) + expect(schema.generateSchema()).toMatchSnapshot() + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/utils.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/utils.test.js new file mode 100644 index 000000000..46f6ea807 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/utils.test.js @@ -0,0 +1,72 @@ +import { jest } from '@jest/globals' +import { + getHostedZoneName, + getWildCardDomainName, + parseDateTimeOrDuration, + parseDuration, +} from '../../../../../../lib/plugins/aws/appsync/utils.js' + +beforeAll(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2020-01-01T17:00:00+00:00')) +}) + +afterAll(() => { + jest.useRealTimers() +}) + +describe('parseDuration', () => { + it('should parse valid duration', () => { + expect(parseDuration('2d').toString()).toEqual('P2D') + expect(parseDuration('365d').toString()).toEqual('P365D') + }) + + it('should throw on invalid duration', () => { + expect(() => parseDuration('foo')).toThrowError() + }) + + it('should auto-fix 1y durations to 365 days', () => { + expect(parseDuration('1y').toString()).toEqual('P365D') + }) +}) + +describe('parseDateTimeOrDuration', () => { + it('should parse valid date', () => { + expect( + parseDateTimeOrDuration('2021-12-31T16:57:00+00:00'), + ).toMatchInlineSnapshot(`"2021-12-31T16:57:00.000+00:00"`) + + expect(parseDateTimeOrDuration('10m')).toMatchInlineSnapshot( + `"2020-01-01T16:50:00.000+00:00"`, + ) + + expect(parseDateTimeOrDuration('1h')).toMatchInlineSnapshot( + `"2020-01-01T16:00:00.000+00:00"`, + ) + + expect(function () { + parseDateTimeOrDuration('foo') + }).toThrowErrorMatchingInlineSnapshot(`"Invalid date or duration"`) + }) +}) + +describe('domain', () => { + describe('getHostedZoneName', () => { + it('should extract a correct hostedZoneName', () => { + expect(getHostedZoneName('example.com')).toMatch('example.com.') + expect(getHostedZoneName('api.example.com')).toMatch('example.com.') + expect(getHostedZoneName('api.prod.example.com')).toMatch( + 'prod.example.com.', + ) + }) + }) + + describe('getWildCardDomainName', () => { + it('should extract a correct getWildCardDomainName', () => { + expect(getWildCardDomainName('api.example.com')).toMatch('*.example.com') + expect(getWildCardDomainName('api.prod.example.com')).toMatch( + '*.prod.example.com', + ) + }) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/apiKeys.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/apiKeys.test.js.snap new file mode 100644 index 000000000..01b6e01b6 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/apiKeys.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Basic Invalid should validate: Invalid WAF rules 1`] = `"/apiKeys/0/wafRules/0: must be a valid WAF rule"`; + +exports[`Basic Invalid should validate: Invalid duration 1`] = `"/apiKeys/0/expiresAfter: must be a valid duration."`; + +exports[`Basic Invalid should validate: Invalid expiresAt 1`] = `"/apiKeys/0/expiresAt: must be a valid date-time"`; + +exports[`Basic Invalid should validate: Missing name 1`] = `"/apiKeys/0: must have required property 'name'"`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/auth.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/auth.test.js.snap new file mode 100644 index 000000000..28f2e2760 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/auth.test.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Validation Invalid should validate a Cognito empty config 1`] = `"/authentication/config: must have required property 'userPoolId'"`; + +exports[`Validation Invalid should validate a Cognito missing config 1`] = `"/authentication: must have required property 'config'"`; + +exports[`Validation Invalid should validate a Cognito with invalid userPoolId 1`] = ` +"/authentication/config/userPoolId: must be a string or a CloudFormation intrinsic function +/authentication/config/awsRegion: must be a string or a CloudFormation intrinsic function +/authentication/config/defaultAction: must be "ALLOW" or "DENY" +/authentication/config/appIdClientRegex: must be a string or a CloudFormation intrinsic function" +`; + +exports[`Validation Invalid should validate a Lambda with empty config 1`] = `"/authentication/config: must specify functionName, functionArn or function (all exclusives)"`; + +exports[`Validation Invalid should validate a Lambda with invalid config functionnArn 1`] = ` +"/authentication/config/functionArn: must be a string or a CloudFormation intrinsic function +/authentication/config: must specify functionName, functionArn or function (all exclusives) +/authentication/config/functionArn: must be string +/authentication/config/identityValidationExpression: must be string +/authentication/config/authorizerResultTtlInSeconds: must be number" +`; + +exports[`Validation Invalid should validate a Lambda with invalid config: both functionName and functionnArn are set 1`] = `"/authentication/config: must specify functionName, functionArn or function (all exclusives)"`; + +exports[`Validation Invalid should validate a Lambda with invalid functionName and functionVersion 1`] = ` +"/authentication/config: must specify functionName, functionArn or function (all exclusives) +/authentication/config/functionName: must be string +/authentication/config/identityValidationExpression: must be string +/authentication/config/authorizerResultTtlInSeconds: must be number" +`; + +exports[`Validation Invalid should validate a Lambda with missing config 1`] = `"/authentication: must have required property 'config'"`; + +exports[`Validation Invalid should validate a OIDC with empty config 1`] = `"/authentication/config: must have required property 'issuer'"`; + +exports[`Validation Invalid should validate a OIDC with invalid config 1`] = ` +"/authentication/config/issuer: must be string +/authentication/config/clientId: must be string +/authentication/config/iatTTL: must be number +/authentication/config/authTTL: must be number" +`; + +exports[`Validation Invalid should validate a OIDC with missing config 1`] = `"/authentication: must have required property 'config'"`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/base.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/base.test.js.snap new file mode 100644 index 000000000..cfc36d801 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/base.test.js.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Validation Caching Invalid should validate a Invalid 1`] = ` +"/caching/enabled: must be boolean +/caching/behavior: must be one of 'FULL_REQUEST_CACHING', 'PER_RESOLVER_CACHING' +/caching/type: must be one of 'SMALL', 'MEDIUM', 'LARGE', 'XLARGE', 'LARGE_2X', 'LARGE_4X', 'LARGE_8X', 'LARGE_12X' +/caching/ttl: must be integer +/caching/atRestEncryption: must be boolean +/caching/transitEncryption: must be boolean" +`; + +exports[`Validation Caching Invalid should validate a Ttl max value 1`] = `"/caching/ttl: must be <= 3600"`; + +exports[`Validation Caching Invalid should validate a Ttl min value 1`] = `"/caching/ttl: must be >= 1"`; + +exports[`Validation Domain Invalid should validate a Invalid 1`] = ` +"/domain/enabled: must be boolean +/domain/name: must be a valid domain name +/domain/certificateArn: must be a string or a CloudFormation intrinsic function +/domain/route53: must be boolean" +`; + +exports[`Validation Domain Invalid should validate a Invalid Route 53 1`] = ` +"/domain/name: must be a valid domain name +/domain/route53: must be boolean" +`; + +exports[`Validation Domain Invalid should validate a useCloudFormation: not present, certificateArn or hostedZoneId is required 1`] = `"/domain: when using CloudFormation, you must provide either certificateArn or hostedZoneId."`; + +exports[`Validation Domain Invalid should validate a useCloudFormation: true, certificateArn or hostedZoneId is required 1`] = `"/domain: when using CloudFormation, you must provide either certificateArn or hostedZoneId."`; + +exports[`Validation Log Invalid should validate a Invalid 1`] = ` +"/logging/level: must be one of 'ALL', 'INFO', 'DEBUG', 'ERROR' or 'NONE' +/logging/retentionInDays: must be integer +/logging/excludeVerboseContent: must be boolean" +`; + +exports[`Validation Waf Invalid should validate a Invalid 1`] = ` +"/waf/name: must be string +/waf/defaultAction: must be 'Allow' or 'Block' +/waf/rules/0: must be a valid WAF rule +/waf/rules/1: must be a valid WAF rule +/waf/rules/2: must be a valid WAF rule +/waf/enabled: must be boolean" +`; + +exports[`Validation Waf Invalid should validate a Invalid arn 1`] = `"/waf/arn: must be a string or a CloudFormation intrinsic function"`; + +exports[`Validation Waf Invalid should validate a Throttle limit 1`] = ` +"/waf/rules/0: must be a valid WAF rule +/waf/rules/1: must be a valid WAF rule" +`; + +exports[`Validation should validate 1`] = ` +": must have required property 'name' +: must have required property 'authentication' +/unknownPorp: invalid (unknown) property +/xrayEnabled: must be boolean +/visibility: must be "GLOBAL" or "PRIVATE" +/introspection: must be boolean +/queryDepthLimit: must be integer +/resolverCountLimit: must be integer +/environment: must be a valid environment definition +/esbuild: must be an esbuild config object or false" +`; + +exports[`Validation should validate 2`] = ` +"/queryDepthLimit: must be <= 75 +/resolverCountLimit: must be <= 1000" +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/datasources.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/datasources.test.js.snap new file mode 100644 index 000000000..303e25c10 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/datasources.test.js.snap @@ -0,0 +1,131 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Basic Invalid should validate: Invalid Datasource 1`] = ` +"/dataSources/myDynamoSource1/type: must be one of AMAZON_DYNAMODB, AMAZON_OPENSEARCH_SERVICE, AWS_LAMBDA, HTTP, NONE, RELATIONAL_DATABASE, AMAZON_EVENTBRIDGE +/dataSources: contains invalid data source definitions" +`; + +exports[`DynamoDB Invalid should validate: Empty config 1`] = ` +"/dataSources/myDynamoSource1/config: must have required property 'tableName' +/dataSources: contains invalid data source definitions" +`; + +exports[`DynamoDB Invalid should validate: Invalid config 1`] = ` +"/dataSources/myDynamoSource1/config/tableName: must be a string or a CloudFormation intrinsic function +/dataSources/myDynamoSource1/config/useCallerCredentials: must be boolean +/dataSources/myDynamoSource1/config/serviceRoleArn: must be a string or a CloudFormation intrinsic function +/dataSources/myDynamoSource1/config/region: must be a string or a CloudFormation intrinsic function +/dataSources/myDynamoSource1/config/iamRoleStatements/0: must be a valid IAM role statement +/dataSources/myDynamoSource1/config/versioned: must be boolean +/dataSources/myDynamoSource1/config/deltaSyncConfig: must have required property 'deltaSyncTableName' +/dataSources/myDynamoSource1/config/deltaSyncConfig/baseTableTTL: must be integer +/dataSources/myDynamoSource1/config/deltaSyncConfig/deltaSyncTableTTL: must be integer +/dataSources: contains invalid data source definitions" +`; + +exports[`DynamoDB Invalid should validate: Missing config 1`] = ` +"/dataSources/myDynamoSource1: must have required property 'config' +/dataSources: contains invalid data source definitions" +`; + +exports[`EventBridge Invalid should not validate: Empty config 1`] = ` +"/dataSources/myEventBridgeSource1/config: must have required property 'eventBusArn' +/dataSources: contains invalid data source definitions" +`; + +exports[`EventBridge Invalid should not validate: Invalid config 1`] = ` +"/dataSources/myEventBridgeSource1/config/eventBusArn: must be a string or a CloudFormation intrinsic function +/dataSources: contains invalid data source definitions" +`; + +exports[`EventBridge Invalid should not validate: Missing config 1`] = ` +"/dataSources/myEventBridgeSource1: must have required property 'config' +/dataSources: contains invalid data source definitions" +`; + +exports[`HTTP Invalid should validate: Empty config 1`] = ` +"/dataSources/http1/config: must have required property 'endpoint' +/dataSources: contains invalid data source definitions" +`; + +exports[`HTTP Invalid should validate: Invalid config 1`] = ` +"/dataSources/http1/config/endpoint: must be a string or a CloudFormation intrinsic function +/dataSources/http1/config/authorizationConfig/authorizationType: must be AWS_IAM +/dataSources/http1/config/authorizationConfig/awsIamConfig/signingRegion: must be a string or a CloudFormation intrinsic function +/dataSources/http1/config/authorizationConfig/awsIamConfig/signingServiceName: must be a string or a CloudFormation intrinsic function +/dataSources: contains invalid data source definitions" +`; + +exports[`HTTP Invalid should validate: Missing config 1`] = ` +"/dataSources/http1: must have required property 'config' +/dataSources: contains invalid data source definitions" +`; + +exports[`Lambda Invalid should validate: Empty config 1`] = ` +"/dataSources/myLambda1/config: must specify functionName, functionArn or function (all exclusives) +/dataSources: contains invalid data source definitions" +`; + +exports[`Lambda Invalid should validate: Invalid config 1`] = ` +"/dataSources/myLambda1/config: must specify functionName, functionArn or function (all exclusives) +/dataSources: contains invalid data source definitions" +`; + +exports[`Lambda Invalid should validate: Invalid embedded function 1`] = ` +"/dataSources/myLambda1/config: must specify functionName, functionArn or function (all exclusives) +/dataSources: contains invalid data source definitions" +`; + +exports[`Lambda Invalid should validate: Invalid functionArn 1`] = ` +"/dataSources/myLambda1/config/functionArn: must be a string or a CloudFormation intrinsic function +/dataSources/myLambda1/config: must specify functionName, functionArn or function (all exclusives) +/dataSources/myLambda1/config/functionArn: must be a string or a CloudFormation intrinsic function +/dataSources: contains invalid data source definitions" +`; + +exports[`Lambda Invalid should validate: Invalid functionName 1`] = ` +"/dataSources/myLambda1/config: must specify functionName, functionArn or function (all exclusives) +/dataSources/myLambda1/config/functionName: must be string +/dataSources: contains invalid data source definitions" +`; + +exports[`Lambda Invalid should validate: Missing config 1`] = ` +"/dataSources/myLambda1: must have required property 'config' +/dataSources: contains invalid data source definitions" +`; + +exports[`OpenSearch Invalid should validate: Empty config 1`] = ` +"/dataSources/openSearch1/config: must have a endpoint or domain (but not both) +/dataSources: contains invalid data source definitions" +`; + +exports[`OpenSearch Invalid should validate: Invalid config 1`] = ` +"/dataSources/openSearch1/config/endpoint: must be a string or a CloudFormation intrinsic function +/dataSources/openSearch1/config: must have a endpoint or domain (but not both) +/dataSources/openSearch1/config/endpoint: must be a string or a CloudFormation intrinsic function +/dataSources/openSearch1/config/region: must be a string or a CloudFormation intrinsic function +/dataSources: contains invalid data source definitions" +`; + +exports[`OpenSearch Invalid should validate: Missing config 1`] = ` +"/dataSources/openSearch1: must have required property 'config' +/dataSources: contains invalid data source definitions" +`; + +exports[`RelationalDb Invalid should validate: Empty config 1`] = ` +"/dataSources/http1/config: must have required property 'endpoint' +/dataSources: contains invalid data source definitions" +`; + +exports[`RelationalDb Invalid should validate: Invalid config 1`] = ` +"/dataSources/http1/config/endpoint: must be a string or a CloudFormation intrinsic function +/dataSources/http1/config/authorizationConfig/authorizationType: must be AWS_IAM +/dataSources/http1/config/authorizationConfig/awsIamConfig/signingRegion: must be a string or a CloudFormation intrinsic function +/dataSources/http1/config/authorizationConfig/awsIamConfig/signingServiceName: must be a string or a CloudFormation intrinsic function +/dataSources: contains invalid data source definitions" +`; + +exports[`RelationalDb Invalid should validate: Missing config 1`] = ` +"/dataSources/http1: must have required property 'config' +/dataSources: contains invalid data source definitions" +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/pipelineFunctions.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/pipelineFunctions.test.js.snap new file mode 100644 index 000000000..fe5775609 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/pipelineFunctions.test.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Basic Invalid should validate: Invalid 1`] = ` +"/pipelineFunctions/function1/dataSource: must be a string or data source definition +/pipelineFunctions/function1/description: must be string +/pipelineFunctions/function1/request: must be string +/pipelineFunctions/function1/response: must be string +/pipelineFunctions/function1/maxBatchSize: must be <= 2000 +/pipelineFunctions: contains invalid pipeline function definitions" +`; + +exports[`Basic Invalid should validate: Invalid embedded datasource 1`] = ` +"/pipelineFunctions/function1/dataSource/config: must specify functionName, functionArn or function (all exclusives) +/pipelineFunctions: contains invalid pipeline function definitions" +`; + +exports[`Basic Invalid should validate: Invalid inline datasource 1`] = ` +"/pipelineFunctions/function1: must be a string or an object +/pipelineFunctions: contains invalid pipeline function definitions" +`; + +exports[`Basic Invalid should validate: Missing datasource 1`] = ` +"/pipelineFunctions/function1: must have required property 'dataSource' +/pipelineFunctions: contains invalid pipeline function definitions" +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/resolvers.test.js.snap b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/resolvers.test.js.snap new file mode 100644 index 000000000..eed86ae8c --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/__snapshots__/resolvers.test.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Basic Invalid should validate: Invalid 1`] = ` +"/resolvers/myResolver/functions: must be array +/resolvers/myResolver/kind: must be "UNIT" or "PIPELINE" +/resolvers/myResolver/type: must be string +/resolvers/myResolver/field: must be string +/resolvers/myResolver/maxBatchSize: must be <= 2000 +/resolvers/myResolver/request: must be string +/resolvers/myResolver/response: must be string +/resolvers: contains invalid resolver definitions" +`; + +exports[`Basic Invalid should validate: Invalid datasource 1`] = ` +"/resolvers/Query.getUser: must be object +/resolvers: contains invalid resolver definitions" +`; + +exports[`Basic Invalid should validate: Invalid embedded datasource 1`] = ` +"/resolvers/Query.getUser/dataSource/config: must specify functionName, functionArn or function (all exclusives) +/resolvers: contains invalid resolver definitions" +`; + +exports[`Basic Invalid should validate: Missing datasource 1`] = ` +"/resolvers/Query.user: must have required property 'dataSource' +/resolvers: contains invalid resolver definitions" +`; + +exports[`Basic Invalid should validate: Missing functions 1`] = ` +"/resolvers/Query.user: must have required property 'functions' +/resolvers: contains invalid resolver definitions" +`; + +exports[`Basic Invalid should validate: Missing type and field 1`] = ` +"/resolvers/myResolver: must have required property 'type' +/resolvers/myResolver: must have required property 'field' +/resolvers: contains invalid resolver definitions" +`; + +exports[`Basic Invalid should validate: Missing type and field inline 1`] = ` +"/resolvers/myResolver: must be object +/resolvers/myResolver: must be object +/resolvers: contains invalid resolver definitions" +`; diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/apiKeys.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/apiKeys.test.js new file mode 100644 index 000000000..07dde89ae --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/apiKeys.test.js @@ -0,0 +1,127 @@ +import { validateConfig } from '../../../../../../../lib/plugins/aws/appsync/validation.js' +import { basicConfig } from '../basicConfig.js' + +describe('Basic', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + apiKeys: [ + { + name: 'John', + description: "John's key", + expiresAt: '2021-03-09T16:00:00+00:00', + wafRuels: [ + { + throttle: { + priority: 300, + limit: 200, + aggregateKeyType: 'FORWARDED_IP', + forwardedIPConfig: { + headerName: 'X-Forwarded-To', + fallbackBehavior: 'MATCH', + }, + visibilityConfig: { + name: 'ThrottleRule', + cloudWatchMetricsEnabled: false, + sampledRequestsEnabled: false, + }, + }, + }, + ], + }, + { + name: 'Jane', + expiresAfter: '1y', + }, + { + name: 'AfterHoursNumber', + expiresAfter: 48, + }, + { + name: 'AfterHoursString', + expiresAfter: '48', + }, + { + name: 'Name only', + }, + 'InlineKey', + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Missing name', + config: { + apiKeys: [ + { + description: "John's key", + expiresAfter: 100, + }, + ], + }, + }, + { + name: 'Invalid expiresAt', + config: { + apiKeys: [ + { + name: 'Default', + expiresAt: 'invalid-date', + }, + ], + }, + }, + { + name: 'Invalid duration', + config: { + apiKeys: [ + { + name: 'Default', + expiresAfter: 'invalid-duration', + }, + ], + }, + }, + { + name: 'Invalid WAF rules', + config: { + apiKeys: [ + { + name: 'Default', + wafRules: [ + { + invalid: { + foo: 'bar', + }, + }, + ], + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/auth.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/auth.test.js new file mode 100644 index 000000000..d3399b158 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/auth.test.js @@ -0,0 +1,262 @@ +import { validateConfig } from '../../../../../../../lib/plugins/aws/appsync/validation.js' +import { basicConfig } from '../basicConfig.js' + +describe('Validation', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Api Key', + config: { + ...basicConfig, + authentication: { + type: 'API_KEY', + }, + }, + }, + { + name: 'Cognito', + config: { + ...basicConfig, + authentication: { + type: 'AMAZON_COGNITO_USER_POOLS', + config: { + userPoolId: '123456', + awsRegion: 'us-east-1', + defaultAction: 'ALLOW', + appIdClientRegex: '.*', + }, + }, + }, + }, + { + name: 'Cognito with Refs', + config: { + ...basicConfig, + authentication: { + type: 'AMAZON_COGNITO_USER_POOLS', + config: { + userPoolId: { + Ref: 'CognitoUserPool', + }, + appIdClientRegex: { + Ref: 'CognitoUserPoolClient', + }, + }, + }, + }, + }, + { + name: 'OIDC', + config: { + ...basicConfig, + authentication: { + type: 'OPENID_CONNECT', + config: { + issuer: 'https://auth.example.com', + clientId: '90941906-004b-4cc5-9685-6864a8e08835', + iatTTL: 3600, + authTTL: 3600, + }, + }, + }, + }, + { + name: 'OIDC without a clientId', + config: { + ...basicConfig, + authentication: { + type: 'OPENID_CONNECT', + config: { + issuer: 'https://auth.example.com', + iatTTL: 3600, + authTTL: 3600, + }, + }, + }, + }, + { + name: 'IAM', + config: { + ...basicConfig, + authentication: { + type: 'AWS_IAM', + }, + }, + }, + { + name: 'Lambda with functionName', + config: { + ...basicConfig, + authentication: { + type: 'AWS_LAMBDA', + config: { + functionName: 'myFunction', + identityValidationExpression: '*', + authorizerResultTtlInSeconds: 600, + }, + }, + }, + }, + { + name: 'Lambda with functionArn', + config: { + ...basicConfig, + authentication: { + type: 'AWS_LAMBDA', + config: { + functionArn: 'arn:aws:lambda:...', + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig(config.config)).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Cognito missing config', + config: { + ...basicConfig, + authentication: { + type: 'AMAZON_COGNITO_USER_POOLS', + }, + }, + }, + { + name: 'Cognito empty config', + config: { + ...basicConfig, + authentication: { + type: 'AMAZON_COGNITO_USER_POOLS', + config: {}, + }, + }, + }, + { + name: 'Cognito with invalid userPoolId', + config: { + ...basicConfig, + authentication: { + type: 'AMAZON_COGNITO_USER_POOLS', + config: { + userPoolId: 124, + awsRegion: 456, + defaultAction: 'Foo', + appIdClientRegex: 123, + }, + }, + }, + }, + { + name: 'OIDC with missing config', + config: { + ...basicConfig, + authentication: { + type: 'OPENID_CONNECT', + }, + }, + }, + { + name: 'OIDC with empty config', + config: { + ...basicConfig, + authentication: { + type: 'OPENID_CONNECT', + config: {}, + }, + }, + }, + { + name: 'OIDC with invalid config', + config: { + ...basicConfig, + authentication: { + type: 'OPENID_CONNECT', + config: { + issuer: 123, + clientId: 456, + iatTTL: 'foo', + authTTL: 'bar', + }, + }, + }, + }, + { + name: 'Lambda with missing config', + config: { + ...basicConfig, + authentication: { + type: 'AWS_LAMBDA', + }, + }, + }, + { + name: 'Lambda with empty config', + config: { + ...basicConfig, + authentication: { + type: 'AWS_LAMBDA', + config: {}, + }, + }, + }, + { + name: 'Lambda with invalid functionName and functionVersion', + config: { + ...basicConfig, + authentication: { + type: 'AWS_LAMBDA', + config: { + functionName: 123, + functionVersion: 123, + identityValidationExpression: 456, + authorizerResultTtlInSeconds: 'foo', + }, + }, + }, + }, + { + name: 'Lambda with invalid config functionnArn', + config: { + ...basicConfig, + authentication: { + type: 'AWS_LAMBDA', + config: { + functionArn: 123, + identityValidationExpression: 456, + authorizerResultTtlInSeconds: 'foo', + }, + }, + }, + }, + { + name: 'Lambda with invalid config: both functionName and functionnArn are set', + config: { + ...basicConfig, + authentication: { + type: 'AWS_LAMBDA', + config: { + functionName: 'myFunction', + functionArn: 'arn:lambda:', + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(function () { + validateConfig(config.config) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/base.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/base.test.js new file mode 100644 index 000000000..922c9e0f5 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/base.test.js @@ -0,0 +1,463 @@ +import { validateConfig } from '../../../../../../../lib/plugins/aws/appsync/validation.js' +import { basicConfig } from '../basicConfig.js' + +describe('Validation', () => { + it('should validate', () => { + expect( + validateConfig({ + ...basicConfig, + visibility: 'GLOBAL', + introspection: true, + queryDepthLimit: 10, + resolverCountLimit: 10, + xrayEnabled: true, + environment: { + MY_TABLE: 'my-table', + MY_OTHER_TABLE: { Ref: 'MyOtherTable' }, + }, + tags: { + foo: 'bar', + }, + esbuild: { + target: 'es2020', + sourcemap: false, + treeShaking: false, + }, + }), + ).toBe(true) + + expect(function () { + validateConfig({ + visibility: 'FOO', + introspection: 10, + queryDepthLimit: 'foo', + resolverCountLimit: 'bar', + xrayEnabled: 'BAR', + unknownPorp: 'foo', + esbuild: 'bad', + environment: 'Bad', + }) + }).toThrowErrorMatchingSnapshot() + + expect(function () { + validateConfig({ + ...basicConfig, + queryDepthLimit: 76, + resolverCountLimit: 1001, + }) + }).toThrowErrorMatchingSnapshot() + }) + + describe('Log', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Minimum', + config: { + ...basicConfig, + logging: { + level: 'ALL', + }, + }, + }, + { + name: 'Full', + config: { + ...basicConfig, + logging: { + level: 'ALL', + retentionInDays: 14, + excludeVerboseContent: true, + loggingRoleArn: { Ref: 'MyLogGorupArn' }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig(config.config)).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Invalid', + config: { + ...basicConfig, + logging: { + level: 'FOO', + retentionInDays: 'bar', + excludeVerboseContent: 'buzz', + loggingRoleArn: 123, + visibility: 'FOO', + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(function () { + validateConfig(config.config) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) + }) + + describe('Waf', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Minimum', + config: { + ...basicConfig, + waf: { + rules: [], + }, + }, + }, + { + name: 'Full', + config: { + ...basicConfig, + waf: { + enabled: true, + name: 'MyWaf', + defaultAction: 'Allow', + description: 'My Waf rules', + visibilityConfig: { + name: 'myRule', + cloudWatchMetricsEnabled: true, + sampledRequestsEnabled: true, + }, + rules: [ + 'throttle', + { throttle: 100 }, + { + throttle: { + name: 'Throttle', + action: 'Block', + limit: 200, + priority: 200, + aggregateKeyType: 'IP', + forwardedIPConfig: { + headerName: 'X-Forwarded-For', + fallbackBehavior: 'MATCH', + }, + visibilityConfig: { + name: 'throttle200', + cloudWatchMetricsEnabled: true, + sampledRequestsEnabled: true, + }, + }, + }, + 'disableIntrospection', + { + disableIntrospection: { + name: 'Disable Intorspection', + priority: 100, + visibilityConfig: { + name: 'DisableIntrospection', + cloudWatchMetricsEnabled: true, + sampledRequestsEnabled: true, + }, + }, + }, + { + name: 'Custom Rule', + action: 'Count', + priority: 500, + statement: { + NotStatement: { + Statement: { + GeoMatchStatement: { + CountryCodes: ['US'], + }, + }, + }, + }, + visibilityConfig: { + name: 'myRule', + cloudWatchMetricsEnabled: true, + sampledRequestsEnabled: true, + }, + }, + ], + }, + }, + }, + { + name: 'Using arn', + config: { + ...basicConfig, + waf: { + enabled: true, + arn: 'arn:aws:', + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig(config.config)).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Invalid', + config: { + ...basicConfig, + waf: { + enabled: 'foo', + name: 123, + defaultAction: 'Buzz', + visibilityConfig: { + name: 123, + cloudWatchMetricsEnabled: 456, + sampledRequestsEnabled: 789, + }, + rules: [ + 'fake', + { invalid: 100 }, + { + name: 123, + statement: 456, + }, + ], + }, + }, + }, + { + name: 'Invalid arn', + config: { + ...basicConfig, + waf: { + arn: 123, + }, + }, + }, + { + name: 'Throttle limit', + config: { + ...basicConfig, + waf: { + rules: [ + { throttle: 99 }, + { + throttle: { + name: 'Throttle', + limit: 99, + }, + }, + ], + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(function () { + validateConfig(config.config) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) + }) + + describe('Domain', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Minimum', + config: { + ...basicConfig, + domain: { + name: 'api.example.com', + certificateArn: 'arn:aws:', + }, + }, + }, + { + name: 'Full', + config: { + ...basicConfig, + domain: { + enabled: true, + certificateArn: 'arn:aws:', + name: 'api.example.com', + hostedZoneId: 'Z111111QQQQQQQ', + hostedZoneName: 'example.com.', + route53: true, + }, + }, + }, + { + name: 'useCloudFormation: false, missing certificateArn', + config: { + ...basicConfig, + domain: { + name: 'api.example.com', + useCloudFormation: false, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig(config.config)).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Invalid', + config: { + ...basicConfig, + domain: { + enabled: 'foo', + name: 'bar', + certificateArn: 123, + route53: 123, + }, + }, + }, + { + name: 'useCloudFormation: true, certificateArn or hostedZoneId is required', + config: { + ...basicConfig, + domain: { + name: 'api.example.com', + useCloudFormation: true, + }, + }, + }, + { + name: 'useCloudFormation: not present, certificateArn or hostedZoneId is required', + config: { + ...basicConfig, + domain: { + name: 'api.example.com', + }, + }, + }, + { + name: 'Invalid Route 53', + config: { + ...basicConfig, + domain: { + name: 'bar', + certificateArn: 'arn:aws:', + route53: { + hostedZoneId: 456, + hostedZoneName: 789, + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(function () { + validateConfig(config.config) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) + }) + + describe('Caching', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Minimum', + config: { + ...basicConfig, + caching: { + behavior: 'PER_RESOLVER_CACHING', + }, + }, + }, + { + name: 'Full', + config: { + ...basicConfig, + caching: { + enabled: true, + behavior: 'PER_RESOLVER_CACHING', + type: 'SMALL', + ttl: 3600, + atRestEncryption: true, + transitEncryption: true, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig(config.config)).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Invalid', + config: { + ...basicConfig, + caching: { + enabled: 'foo', + behavior: 'bar', + type: 'INVALID', + ttl: 'bizz', + atRestEncryption: 'bizz', + transitEncryption: 'bazz', + }, + }, + }, + { + name: 'Ttl min value', + config: { + ...basicConfig, + caching: { + behavior: 'PER_RESOLVER_CACHING', + ttl: 0, + }, + }, + }, + { + name: 'Ttl max value', + config: { + ...basicConfig, + caching: { + behavior: 'PER_RESOLVER_CACHING', + ttl: 3601, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(function () { + validateConfig(config.config) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/datasources.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/datasources.test.js new file mode 100644 index 000000000..c94e7d748 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/datasources.test.js @@ -0,0 +1,881 @@ +import { validateConfig } from '../../../../../../../lib/plugins/aws/appsync/validation.js' +import { basicConfig } from '../basicConfig.js' + +describe('Basic', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + dataSources: { + myDynamoSource1: { + type: 'AMAZON_DYNAMODB', + description: 'My Dynamo Datasource', + config: { + tableName: 'myTable', + }, + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + dataSources: [ + { + myDynamoSource1: { + type: 'AMAZON_DYNAMODB', + description: 'My Dynamo Datasource', + config: { + tableName: 'myTable', + }, + }, + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Invalid Datasource', + config: { + dataSources: { + myDynamoSource1: { + type: 'Foo', + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) + +describe('DynamoDB', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + dataSources: { + myDynamoSource1: { + type: 'AMAZON_DYNAMODB', + config: { + tableName: 'myTable', + }, + }, + myDynamoSource2: { + type: 'AMAZON_DYNAMODB', + config: { + tableName: { Ref: 'MyTable' }, + region: { 'Fn::Sub': '${AWS::Region}' }, + serviceRoleArn: { 'Fn::GetAtt': 'MyRole.Arn' }, + }, + }, + myDynamoSource3: { + type: 'AMAZON_DYNAMODB', + config: { + tableName: 'myTable', + useCallerCredentials: true, + region: 'us-east-2', + serviceRoleArn: 'arn:', + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['DynamoDB:PutItem'], + Resource: ['arn:dynamodb:'], + }, + ], + versioned: true, + deltaSyncConfig: { + deltaSyncTableName: 'deltaSyncTable', + baseTableTTL: 60, + deltaSyncTableTTL: 60, + }, + }, + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + dataSources: [ + { + myDynamoSource1: { + type: 'AMAZON_DYNAMODB', + config: { + tableName: 'myTable', + }, + }, + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Missing config', + config: { + dataSources: { + myDynamoSource1: { + type: 'AMAZON_DYNAMODB', + }, + }, + }, + }, + { + name: 'Empty config', + config: { + dataSources: { + myDynamoSource1: { + type: 'AMAZON_DYNAMODB', + config: {}, + }, + }, + }, + }, + { + name: 'Invalid config', + config: { + dataSources: { + myDynamoSource1: { + type: 'AMAZON_DYNAMODB', + config: { + tableName: 123, + useCallerCredentials: 'foo', + region: 123, + serviceRoleArn: 456, + iamRoleStatements: [{}], + versioned: 'bar', + deltaSyncConfig: { + baseTableTTL: '123', + deltaSyncTableTTL: '456', + }, + }, + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) + +describe('EventBridge', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + dataSources: { + myDynamoSource1: { + type: 'AMAZON_EVENTBRIDGE', + config: { + eventBusArn: + 'arn:aws:events:us-east-1:123456789012:event-bus/my-event-bus', + }, + }, + }, + }, + }, + { + name: 'EventBusArn as Ref', + config: { + dataSources: { + myDynamoSource1: { + type: 'AMAZON_EVENTBRIDGE', + config: { + eventBusArn: { + 'Fn::GetAtt': ['MyEventBus', 'Arn'], + }, + }, + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + dataSources: [ + { + myDynamoSource1: { + type: 'AMAZON_EVENTBRIDGE', + config: { + eventBusArn: + 'arn:aws:events:us-east-1:123456789012:event-bus/my-event-bus', + }, + }, + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Missing config', + config: { + dataSources: { + myEventBridgeSource1: { + type: 'AMAZON_EVENTBRIDGE', + }, + }, + }, + }, + { + name: 'Empty config', + config: { + dataSources: { + myEventBridgeSource1: { + type: 'AMAZON_EVENTBRIDGE', + config: {}, + }, + }, + }, + }, + { + name: 'Invalid config', + config: { + dataSources: { + myEventBridgeSource1: { + type: 'AMAZON_EVENTBRIDGE', + config: { + eventBusArn: 1234, + }, + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should not validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) + +describe('Lambda', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + dataSources: { + myLambda1: { + type: 'AWS_LAMBDA', + config: { + functionName: 'myTable', + }, + }, + myLambda2: { + type: 'AWS_LAMBDA', + config: { + functionArn: 'arn:lambda', + serviceRoleArn: 'arn:iam', + }, + }, + myLambda3: { + type: 'AWS_LAMBDA', + config: { + functionArn: { Ref: 'MyLambda' }, + serviceRoleArn: { Ref: 'MyRole' }, + }, + }, + myLambda4: { + type: 'AWS_LAMBDA', + config: { + functionName: 'myLambda', + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['lambda:invokeFunction'], + Resource: ['arn:lambda:'], + }, + ], + }, + }, + myLambda5: { + type: 'AWS_LAMBDA', + config: { + function: { + handler: 'index.handler', + timeout: 30, + }, + }, + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + dataSources: [ + { + myLambda1: { + type: 'AWS_LAMBDA', + config: { + functionName: 'myTable', + }, + }, + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + describe('Invalid', () => { + const assertions = [ + { + name: 'Missing config', + config: { + dataSources: { + myLambda1: { + type: 'AWS_LAMBDA', + }, + }, + }, + }, + { + name: 'Empty config', + config: { + dataSources: { + myLambda1: { + type: 'AWS_LAMBDA', + config: {}, + }, + }, + }, + }, + { + name: 'Invalid config', + config: { + dataSources: { + myLambda1: { + type: 'AWS_LAMBDA', + config: { + tableName: 123, + }, + }, + }, + }, + }, + { + name: 'Invalid functionName', + config: { + dataSources: { + myLambda1: { + type: 'AWS_LAMBDA', + config: { + functionName: 123, + }, + }, + }, + }, + }, + { + name: 'Invalid functionArn', + config: { + dataSources: { + myLambda1: { + type: 'AWS_LAMBDA', + config: { + functionArn: 123, + }, + }, + }, + }, + }, + { + name: 'Invalid embedded function', + config: { + dataSources: { + myLambda1: { + type: 'AWS_LAMBDA', + config: { + function: 'myFunction', + }, + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) + +describe('RelationalDb', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + dataSources: { + rds1: { + type: 'RELATIONAL_DATABASE', + config: { + dbClusterIdentifier: 'myClisterId', + awsSecretStoreArn: 'aws:arn:', + }, + }, + rds2: { + type: 'RELATIONAL_DATABASE', + config: { + dbClusterIdentifier: 'myClisterId', + relationalDatabaseSourceType: 'RDS_HTTP_ENDPOINT', + region: 'us-east-1', + dataBaseName: 'myDatabase', + schema: '', + awsSecretStoreArn: { Reg: 'MySecretStore' }, + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['rds-data:GetItems'], + Resource: ['aws:arn:rds:'], + }, + ], + }, + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + dataSources: [ + { + rds1: { + type: 'RELATIONAL_DATABASE', + config: { + dbClusterIdentifier: 'myClisterId', + awsSecretStoreArn: 'aws:arn:', + }, + }, + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Missing config', + config: { + dataSources: { + http1: { + type: 'HTTP', + }, + }, + }, + }, + { + name: 'Empty config', + config: { + dataSources: { + http1: { + type: 'HTTP', + config: {}, + }, + }, + }, + }, + { + name: 'Invalid config', + config: { + dataSources: { + http1: { + type: 'HTTP', + config: { + endpoint: 123, + authorizationConfig: { + authorizationType: 'FOO', + awsIamConfig: { + signingRegion: 123, + signingServiceName: 456, + }, + }, + }, + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) + +describe('HTTP', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + dataSources: { + http1: { + type: 'HTTP', + config: { + endpoint: 'https://api.example.com', + }, + }, + http2: { + type: 'HTTP', + config: { + endpoint: { 'Fn::GetAtt': ['MyEndpoint', 'Arn'] }, + authorizationConfig: { + authorizationType: 'AWS_IAM', + awsIamConfig: { + signingRegion: 'us-east-1', + signingServiceName: 'AppSync', + }, + }, + }, + }, + http3: { + type: 'HTTP', + config: { + endpoint: 'https://api.example.com', + serviceRoleArn: { Ref: 'MyRole' }, + }, + }, + http5: { + type: 'HTTP', + config: { + endpoint: 'https://api.example.com', + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['lambda:invokeFunction'], + Resource: ['arn:lambda:'], + }, + ], + }, + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + dataSources: [ + { + http1: { + type: 'HTTP', + config: { + endpoint: 'https://api.example.com', + }, + }, + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + describe('Invalid', () => { + const assertions = [ + { + name: 'Missing config', + config: { + dataSources: { + http1: { + type: 'HTTP', + }, + }, + }, + }, + { + name: 'Empty config', + config: { + dataSources: { + http1: { + type: 'HTTP', + config: {}, + }, + }, + }, + }, + { + name: 'Invalid config', + config: { + dataSources: { + http1: { + type: 'HTTP', + config: { + endpoint: 123, + authorizationConfig: { + authorizationType: 'FOO', + awsIamConfig: { + signingRegion: 123, + signingServiceName: 456, + }, + }, + }, + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) + +describe('OpenSearch', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + dataSources: { + openSearch1: { + type: 'AMAZON_OPENSEARCH_SERVICE', + config: { + endpoint: 'https://api.example.com', + }, + }, + openSearch2: { + type: 'AMAZON_OPENSEARCH_SERVICE', + config: { + endpoint: { 'Fn::GetAtt': ['MyEndpoint', 'Arn'] }, + }, + }, + openSearch3: { + type: 'AMAZON_OPENSEARCH_SERVICE', + config: { + domain: '123', + }, + }, + openSearch4: { + type: 'AMAZON_OPENSEARCH_SERVICE', + config: { + endpoint: 'https://api.example.com', + region: 'us-east-1', + serviceRoleArn: 'aws:arn:iam', + }, + }, + openSearch5: { + type: 'AMAZON_OPENSEARCH_SERVICE', + config: { + endpoint: 'https://api.example.com', + iamRoleStatements: [ + { + Effect: 'Allow', + Action: ['lambda:invokeFunction'], + Resource: ['arn:lambda:'], + }, + ], + }, + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + dataSources: [ + { + openSearch1: { + type: 'AMAZON_OPENSEARCH_SERVICE', + config: { + endpoint: 'https://api.example.com', + }, + }, + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + describe('Invalid', () => { + const assertions = [ + { + name: 'Missing config', + config: { + dataSources: { + openSearch1: { + type: 'AMAZON_OPENSEARCH_SERVICE', + }, + }, + }, + }, + { + name: 'Empty config', + config: { + dataSources: { + openSearch1: { + type: 'AMAZON_OPENSEARCH_SERVICE', + config: {}, + }, + }, + }, + }, + { + name: 'Invalid config', + config: { + dataSources: { + openSearch1: { + type: 'AMAZON_OPENSEARCH_SERVICE', + config: { + endpoint: 123, + region: 456, + }, + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) + +describe('None', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + dataSources: { + none: { + type: 'NONE', + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + dataSources: [ + { + openSearch1: { + type: 'NONE', + }, + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/pipelineFunctions.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/pipelineFunctions.test.js new file mode 100644 index 000000000..819f933d4 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/pipelineFunctions.test.js @@ -0,0 +1,130 @@ +import { validateConfig } from '../../../../../../../lib/plugins/aws/appsync/validation.js' +import { basicConfig } from '../basicConfig.js' + +describe('Basic', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + pipelineFunctions: { + function1: { + dataSource: 'ds1', + }, + function2: { + description: 'My Function', + dataSource: 'ds1', + maxBatchSize: 200, + request: 'request.vtl', + response: 'response.vtl', + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + pipelineFunctions: [ + { + function1: { + dataSource: 'ds1', + }, + function3: { + dataSource: { + type: 'AWS_LAMBDA', + config: { + function: { + handler: 'index.handler', + }, + }, + }, + }, + }, + { + function2: { + name: 'myFunction1', + description: 'My Function', + dataSource: 'ds1', + request: 'request.vtl', + response: 'response.vtl', + }, + function4: 'ds1', + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Invalid', + config: { + pipelineFunctions: { + function1: { + description: 456, + dataSource: 789, + request: 123, + response: 456, + maxBatchSize: 5000, + }, + }, + }, + }, + { + name: 'Missing datasource', + config: { + pipelineFunctions: { + function1: { + datasource: { + type: 'AWS_LAMBDA', + config: { + handler: 'index.handler', + }, + }, + }, + }, + }, + }, + { + name: 'Invalid inline datasource', + config: { + pipelineFunctions: { + function1: 123, + }, + }, + }, + { + name: 'Invalid embedded datasource', + config: { + pipelineFunctions: { + function1: { + dataSource: { + type: 'AWS_LAMBDA', + config: {}, + }, + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/resolvers.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/resolvers.test.js new file mode 100644 index 000000000..4a5c89ec3 --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/validation/resolvers.test.js @@ -0,0 +1,228 @@ +import { validateConfig } from '../../../../../../../lib/plugins/aws/appsync/validation.js' +import { basicConfig } from '../basicConfig.js' + +describe('Basic', () => { + describe('Valid', () => { + const assertions = [ + { + name: 'Valid config', + config: { + resolvers: { + 'Query.getUser': { + kind: 'UNIT', + dataSource: 'myDs', + }, + 'Query.getPost': { + kind: 'PIPELINE', + functions: [ + 'function1', + { + dataSource: 'function2', + code: 'function2.js', + }, + ], + }, + 'Query.getBlog': { + kind: 'UNIT', + dataSource: 'myDs', + }, + getUsers: { + type: 'Query', + field: 'getUsers', + kind: 'UNIT', + dataSource: 'myDs', + sync: { + conflictDetection: 'VERSION', + conflictHandler: 'LAMBDA', + function: { handler: 'index.handler' }, + }, + maxBatchSize: 200, + }, + getPosts: { + type: 'Query', + field: 'getPosts', + functions: [ + 'function1', + { + dataSource: { + type: 'AWS_LAMBDA', + config: { + functionName: 'function3', + }, + }, + code: 'function2.js', + }, + ], + }, + getBlogs: { + kind: 'UNIT', + type: 'Query', + field: 'getUsers', + dataSource: 'myDs', + }, + getComments: { + kind: 'UNIT', + type: 'Query', + field: 'getComments', + dataSource: { + type: 'AWS_LAMBDA', + name: 'getComments', + config: { + functionName: 'getComments', + }, + }, + }, + }, + }, + }, + { + name: 'Valid config, as array of maps', + config: { + resolvers: [ + { + 'Query.getUser': { + kind: 'UNIT', + dataSource: 'myDs', + }, + 'Query.getPost': { + kind: 'PIPELINE', + functions: ['function1', 'function2'], + }, + 'Query.getBlog': { + kind: 'UNIT', + dataSource: 'myDs', + }, + }, + { + getUsers: { + type: 'Query', + field: 'getUsers', + kind: 'UNIT', + dataSource: 'myDs', + sync: { + conflictDetection: 'VERSION', + conflictHandler: 'OPTIMISTIC_CONCURRENCY', + }, + }, + getPosts: { + type: 'Query', + field: 'getPosts', + kind: 'PIPELINE', + functions: ['function1', 'function2'], + }, + 'Query.getComment': { + kind: 'UNIT', + dataSource: { + type: 'AWS_LAMBDA', + name: 'getComment', + config: { + functionName: 'getComment', + }, + }, + }, + }, + ], + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate a ${config.name}`, () => { + expect(validateConfig({ ...basicConfig, ...config.config })).toBe(true) + }) + }) + }) + + describe('Invalid', () => { + const assertions = [ + { + name: 'Invalid', + config: { + resolvers: { + myResolver: { + kind: 'FOO', + functions: 999, + type: 123, + field: 456, + request: 123, + response: 456, + maxBatchSize: 5000, + }, + }, + }, + }, + { + name: 'Missing datasource', + config: { + resolvers: { + 'Query.user': { + kind: 'UNIT', + }, + }, + }, + }, + { + name: 'Missing functions', + config: { + resolvers: { + 'Query.user': { + kind: 'PIPELINE', + }, + }, + }, + }, + { + name: 'Missing type and field', + config: { + resolvers: { + myResolver: { + kind: 'UNIT', + dataSource: 'myDs', + }, + }, + }, + }, + { + name: 'Missing type and field inline', + config: { + resolvers: { + myResolver: 'dataSource', + }, + }, + }, + { + name: 'Invalid datasource', + config: { + resolvers: { + 'Query.getUser': 'foo', + }, + }, + }, + { + name: 'Invalid embedded datasource', + config: { + resolvers: { + 'Query.getUser': { + kind: 'UNIT', + dataSource: { + type: 'AWS_LAMBDA', + config: {}, + }, + }, + }, + }, + }, + ] + + assertions.forEach((config) => { + it(`should validate: ${config.name}`, () => { + expect(function () { + validateConfig({ + ...basicConfig, + ...config.config, + }) + }).toThrowErrorMatchingSnapshot() + }) + }) + }) +}) diff --git a/packages/serverless/test/unit/lib/plugins/aws/appsync/waf.test.js b/packages/serverless/test/unit/lib/plugins/aws/appsync/waf.test.js new file mode 100644 index 000000000..028f3959d --- /dev/null +++ b/packages/serverless/test/unit/lib/plugins/aws/appsync/waf.test.js @@ -0,0 +1,246 @@ +import _ from 'lodash' +const { each } = _ +import { Api } from '../../../../../../lib/plugins/aws/appsync/resources/Api.js' +import { Waf } from '../../../../../../lib/plugins/aws/appsync/resources/Waf.js' +import * as given from './given.js' + +const plugin = given.plugin() + +describe('Waf', () => { + describe('Base Resources', () => { + it('should generate waf Resources', () => { + const api = new Api(given.appSyncConfig(), plugin) + const waf = new Waf(api, { + enabled: true, + name: 'Waf', + defaultAction: 'Allow', + description: 'My Waf ACL', + visibilityConfig: { + cloudWatchMetricsEnabled: true, + name: 'MyVisibilityConfig', + sampledRequestsEnabled: true, + }, + rules: [], + }) + expect(waf.compile()).toMatchSnapshot() + }) + + it('should generate waf Resources without tags', () => { + const api = new Api( + given.appSyncConfig({ + tags: undefined, + }), + plugin, + ) + const waf = new Waf(api, { + enabled: true, + name: 'Waf', + defaultAction: 'Allow', + description: 'My Waf ACL', + visibilityConfig: { + cloudWatchMetricsEnabled: true, + name: 'MyVisibilityConfig', + sampledRequestsEnabled: true, + }, + rules: [], + }) + expect(waf.compile()).toMatchSnapshot() + }) + + it('should not generate waf Resources if disabled', () => { + const api = new Api( + given.appSyncConfig({ + waf: { + enabled: false, + name: 'Waf', + rules: [], + }, + }), + plugin, + ) + expect(api.compileWafRules()).toEqual({}) + }) + + it('should generate only the waf association', () => { + const api = new Api(given.appSyncConfig(), plugin) + const waf = new Waf(api, { + enabled: true, + arn: 'arn:aws:wafv2:us-east-1:123456789012:regional/webacl/my-Waf/d7b694d2-4f7d-4dd6-a9a9-843dd1931330', + }) + expect(waf.compile()).toMatchSnapshot() + }) + }) + + describe('Throttle rules', () => { + const api = new Api(given.appSyncConfig(), plugin) + const waf = new Waf(api, { + name: 'Waf', + rules: [], + }) + + it('should generate a preset rule', () => { + expect(waf.buildWafRule('throttle', 'Base')).toMatchSnapshot() + }) + + it('should generate a preset rule with limit', () => { + expect(waf.buildWafRule({ throttle: 500 }, 'Base')).toMatchSnapshot() + }) + + it('should generate a preset rule with config', () => { + expect( + waf.buildWafRule( + { + throttle: { + priority: 300, + limit: 200, + aggregateKeyType: 'FORWARDED_IP', + forwardedIPConfig: { + headerName: 'X-Forwarded-To', + fallbackBehavior: 'MATCH', + }, + visibilityConfig: { + name: 'ThrottleRule', + cloudWatchMetricsEnabled: false, + sampledRequestsEnabled: false, + }, + }, + }, + + 'Base', + ), + ).toMatchSnapshot() + }) + }) + + describe('Disable introspection', () => { + const api = new Api(given.appSyncConfig(), plugin) + const waf = new Waf(api, { + name: 'Waf', + rules: [], + }) + + it('should generate a preset rule', () => { + expect(waf.buildWafRule('disableIntrospection', 'Base')).toMatchSnapshot() + }) + + it('should generate a preset rule with custon config', () => { + expect( + waf.buildWafRule( + { + disableIntrospection: { + priority: 200, + visibilityConfig: { + name: 'DisableIntrospection', + sampledRequestsEnabled: false, + cloudWatchMetricsEnabled: false, + }, + }, + }, + 'Base', + ), + ).toMatchSnapshot() + }) + }) + + describe('Custom rules', () => { + const api = new Api(given.appSyncConfig(), plugin) + const waf = new Waf(api, { + name: 'Waf', + rules: [], + }) + + it('should generate a custom rule', () => { + expect( + waf.buildWafRule( + { + name: 'disable US', + priority: 200, + action: 'Block', + statement: { + GeoMatchStatement: { + CountryCodes: ['US'], + }, + }, + }, + 'Base', + ), + ).toMatchSnapshot() + }) + + it('should generate a custom rule with ManagedRuleGroup', () => { + expect( + waf.buildWafRule( + { + name: 'MyRule1', + priority: 200, + overrideAction: { + None: {}, + }, + statement: { + ManagedRuleGroupStatement: { + Name: 'AWSManagedRulesCommonRuleSet', + VendorName: 'AWS', + }, + }, + }, + 'Base', + ), + ).toMatchSnapshot() + }) + }) + + describe('ApiKey rules', () => { + const configs = { + throttle: 'throttle', + disableIntrospection: 'disableIntrospection', + customRule: { + name: 'MyCustomRule', + statement: { + GeoMatchStatement: { + CountryCodes: ['US'], + }, + }, + }, + throttleWithStatements: { + throttle: { + name: 'Throttle rule with custom ScopeDownStatement', + limit: 100, + scopeDownStatement: { + ByteMatchStatement: { + FieldToMatch: { + SingleHeader: { Name: 'X-Foo' }, + }, + PositionalConstraint: 'EXACTLY', + SearchString: 'Bar', + TextTransformations: [ + { + Type: 'LOWERCASE', + Priority: 0, + }, + ], + }, + }, + }, + }, + emptyStatements: { + name: 'rulesWithoutStatements', + statement: {}, + }, + } + const api = new Api(given.appSyncConfig(), plugin) + const waf = new Waf(api, { + name: 'Waf', + rules: [], + }) + + each(configs, (rule, name) => { + it(`should generate a rule for ${name}`, () => { + const apiConfig = { + name: 'MyKey', + wafRules: [rule], + } + expect(waf.buildApiKeyRules(apiConfig)).toMatchSnapshot() + }) + }) + }) +})