├── .all-contributorsrc ├── .babelrc ├── .editorconfig ├── .eslintrc.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── stale.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── examples └── external-functions │ ├── mapping-templates │ ├── Query.getPosts.request.vtl │ └── Query.getPosts.response.vtl │ ├── package.json │ ├── schema.graphql │ └── serverless.yml ├── package.json ├── src ├── __tests__ │ ├── __snapshots__ │ │ └── getAppSyncConfig.js.snap │ ├── data-loaders │ │ └── ElasticDataLoader.js │ ├── files │ │ ├── mapping-templates │ │ │ ├── default.request.vtl │ │ │ ├── default.response.vtl │ │ │ ├── lambda.request.vtl │ │ │ └── lambda.response.vtl │ │ └── schema.graphql │ └── getAppSyncConfig.js ├── constants │ └── index.js ├── data-loaders │ ├── ElasticDataLoader.js │ ├── HttpDataLoader.js │ ├── NotImplementedDataLoader.js │ └── RelationalDataLoader.js ├── getAppSyncConfig.js ├── index.js └── templates │ ├── direct-lambda.request.vtl │ └── direct-lambda.response.vtl └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "serverless-appsync-simulator", 3 | "projectOwner": "bboure", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "bboure", 15 | "name": "Benoît Bouré", 16 | "avatar_url": "https://avatars0.githubusercontent.com/u/7089997?v=4", 17 | "profile": "https://twitter.com/Benoit_Boure", 18 | "contributions": [ 19 | "code" 20 | ] 21 | }, 22 | { 23 | "login": "FilipPyrek", 24 | "name": "Filip Pýrek", 25 | "avatar_url": "https://avatars1.githubusercontent.com/u/6282843?v=4", 26 | "profile": "http://filip.pyrek.cz/", 27 | "contributions": [ 28 | "code" 29 | ] 30 | }, 31 | { 32 | "login": "marcoreni", 33 | "name": "Marco Reni", 34 | "avatar_url": "https://avatars2.githubusercontent.com/u/2797489?v=4", 35 | "profile": "https://github.com/marcoreni", 36 | "contributions": [ 37 | "code" 38 | ] 39 | }, 40 | { 41 | "login": "EgorDm", 42 | "name": "Egor Dmitriev", 43 | "avatar_url": "https://avatars3.githubusercontent.com/u/4254771?v=4", 44 | "profile": "https://egordmitriev.net/", 45 | "contributions": [ 46 | "code" 47 | ] 48 | }, 49 | { 50 | "login": "stschwark", 51 | "name": "Steffen Schwark", 52 | "avatar_url": "https://avatars3.githubusercontent.com/u/900253?v=4", 53 | "profile": "https://github.com/stschwark", 54 | "contributions": [ 55 | "code" 56 | ] 57 | }, 58 | { 59 | "login": "moelholm", 60 | "name": "Nicky Moelholm", 61 | "avatar_url": "https://avatars2.githubusercontent.com/u/8393156?v=4", 62 | "profile": "https://github.com/moelholm", 63 | "contributions": [ 64 | "code" 65 | ] 66 | }, 67 | { 68 | "login": "daisuke-awaji", 69 | "name": "g-awa", 70 | "avatar_url": "https://avatars0.githubusercontent.com/u/20736455?v=4", 71 | "profile": "https://github.com/daisuke-awaji", 72 | "contributions": [ 73 | "code" 74 | ] 75 | }, 76 | { 77 | "login": "LMulveyCM", 78 | "name": "Lee Mulvey", 79 | "avatar_url": "https://avatars0.githubusercontent.com/u/39565663?v=4", 80 | "profile": "https://github.com/LMulveyCM", 81 | "contributions": [ 82 | "code" 83 | ] 84 | }, 85 | { 86 | "login": "JimmyHurrah", 87 | "name": "Jimmy Hurrah", 88 | "avatar_url": "https://avatars1.githubusercontent.com/u/6367753?v=4", 89 | "profile": "https://github.com/JimmyHurrah", 90 | "contributions": [ 91 | "code" 92 | ] 93 | }, 94 | { 95 | "login": "abdala", 96 | "name": "Abdala", 97 | "avatar_url": "https://avatars1.githubusercontent.com/u/219340?v=4", 98 | "profile": "https://abda.la/", 99 | "contributions": [ 100 | "ideas" 101 | ] 102 | }, 103 | { 104 | "login": "alexandrusavin", 105 | "name": "Alexandru Savin", 106 | "avatar_url": "https://avatars2.githubusercontent.com/u/1612455?v=4", 107 | "profile": "https://github.com/alexandrusavin", 108 | "contributions": [ 109 | "doc" 110 | ] 111 | }, 112 | { 113 | "login": "Scale93", 114 | "name": "Scale93", 115 | "avatar_url": "https://avatars.githubusercontent.com/u/36473880?v=4", 116 | "profile": "https://github.com/Scale93", 117 | "contributions": [ 118 | "code", 119 | "doc" 120 | ] 121 | }, 122 | { 123 | "login": "Liooo", 124 | "name": "Ryo Yamada", 125 | "avatar_url": "https://avatars.githubusercontent.com/u/1630378?v=4", 126 | "profile": "https://github.com/Liooo", 127 | "contributions": [ 128 | "code", 129 | "doc" 130 | ] 131 | }, 132 | { 133 | "login": "h-kishi", 134 | "name": "h-kishi", 135 | "avatar_url": "https://avatars.githubusercontent.com/u/8940568?v=4", 136 | "profile": "https://github.com/h-kishi", 137 | "contributions": [ 138 | "code" 139 | ] 140 | }, 141 | { 142 | "login": "louislatreille", 143 | "name": "louislatreille", 144 | "avatar_url": "https://avatars.githubusercontent.com/u/8052355?v=4", 145 | "profile": "https://github.com/louislatreille", 146 | "contributions": [ 147 | "code" 148 | ] 149 | }, 150 | { 151 | "login": "AleksaC", 152 | "name": "Aleksa Cukovic", 153 | "avatar_url": "https://avatars.githubusercontent.com/u/25728391?v=4", 154 | "profile": "http://aleksac.me", 155 | "contributions": [ 156 | "code" 157 | ] 158 | }, 159 | { 160 | "login": "seanvm", 161 | "name": "Sean van Mulligen", 162 | "avatar_url": "https://avatars.githubusercontent.com/u/16951595?v=4", 163 | "profile": "https://vanmulligen.ca", 164 | "contributions": [ 165 | "code" 166 | ] 167 | }, 168 | { 169 | "login": "katesclau", 170 | "name": "Diego Rodrigues Ferreira", 171 | "avatar_url": "https://avatars.githubusercontent.com/u/5067149?v=4", 172 | "profile": "https://github.com/katesclau", 173 | "contributions": [ 174 | "code" 175 | ] 176 | }, 177 | { 178 | "login": "alichherawalla", 179 | "name": "Mohammed Ali Chherawalla", 180 | "avatar_url": "https://avatars.githubusercontent.com/u/4958010?v=4", 181 | "profile": "https://www.wednesday.is/", 182 | "contributions": [ 183 | "code" 184 | ] 185 | }, 186 | { 187 | "login": "adriantodt", 188 | "name": "AdrianTodt", 189 | "avatar_url": "https://avatars.githubusercontent.com/u/6955035?v=4", 190 | "profile": "https://adriantodt.net/", 191 | "contributions": [ 192 | "code" 193 | ] 194 | } 195 | ], 196 | "contributorsPerLine": 7, 197 | "skipCi": true 198 | } 199 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@babel/plugin-transform-modules-commonjs", 4 | ["babel-plugin-inline-import", { 5 | "extensions": [ 6 | ".vtl" 7 | ] 8 | }] 9 | ], 10 | "presets": [ 11 | ["@babel/preset-env", 12 | { 13 | "shippedProposals": true, 14 | "targets": { "node": "8.12" } 15 | } 16 | ] 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.js] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es6: true 4 | extends: 5 | - prettier 6 | - plugin:prettier/recommended 7 | globals: 8 | Atomics: readonly 9 | SharedArrayBuffer: readonly 10 | parserOptions: 11 | ecmaVersion: 2018 12 | sourceType: module 13 | parser: babel-eslint 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [bboure] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - enhancement 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | - name: Install dependencies 15 | run: | 16 | yarn install --frozen-lockfile 17 | - name: Run lint 18 | run: yarn run lint 19 | - name: Run tests 20 | run: yarn run tests 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | persist-credentials: false 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: 'lts/*' 18 | - name: Install dependencies 19 | run: | 20 | yarn install --frozen-lockfile 21 | - name: Run lint 22 | run: yarn run lint 23 | - name: Run tests 24 | run: yarn run tests 25 | - name: Release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | run: npx semantic-release 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Benoît Bouré 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) ![Release](https://serverless-appsync/serverless-appsync-simulator/workflows/Release/badge.svg) 2 | [![All Contributors](https://img.shields.io/badge/all_contributors-20-orange.svg?style=flat-square)](#contributors-) 3 | 4 | 5 | 6 | This serverless plugin is a wrapper for [amplify-appsync-simulator](https://github.com/aws-amplify/amplify-cli/tree/master/packages/amplify-appsync-simulator) made for testing AppSync APIs built with [serverless-appsync-plugin](https://github.com/sid88in/serverless-appsync-plugin). 7 | 8 | # Requires 9 | 10 | - [serverless framework](https://github.com/serverless/serverless) 11 | - [serverless-appsync-plugin](https://github.com/sid88in/serverless-appsync-plugin) 12 | - [serverless-offline](https://github.com/dherault/serverless-offline) 13 | - [serverless-dynamodb-local](https://github.com/99xt/serverless-dynamodb-local) (when using dynamodb resolvers only) 14 | - [watchman](https://facebook.github.io/watchman/docs/install.html) (if Hot-reloading is desactivated it is not required) 15 | 16 | # Install 17 | 18 | ```bash 19 | npm install serverless-appsync-simulator 20 | # or 21 | yarn add serverless-appsync-simulator 22 | ``` 23 | 24 | # Usage 25 | 26 | This plugin relies on your serverless yml file and on the `serverless-offline` plugin. 27 | 28 | ```yml 29 | plugins: 30 | - serverless-dynamodb-local # only if you need dynamodb resolvers and you don't have an external dynamodb 31 | - serverless-appsync-simulator 32 | - serverless-offline 33 | ``` 34 | 35 | **Note:** Order is important `serverless-appsync-simulator` must go **before** `serverless-offline` 36 | 37 | To start the simulator, run the following command: 38 | 39 | ```bash 40 | sls offline start 41 | ``` 42 | 43 | You should see in the logs something like: 44 | 45 | ```bash 46 | ... 47 | Serverless: AppSync endpoint: http://localhost:20002/graphql 48 | Serverless: GraphiQl: http://localhost:20002 49 | ... 50 | ``` 51 | 52 | # Configuration 53 | 54 | Put options under `custom.appsync-simulator` in your `serverless.yml` file 55 | 56 | | option | default | description | 57 | | -------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 58 | | apiKey | `0123456789` | When using `API_KEY` as authentication type, the key to authenticate to the endpoint. | 59 | | port | 20002 | AppSync operations port; if using multiple APIs, the value of this option will be used as a starting point, and each other API will have a port of lastPort + 10 (e.g. 20002, 20012, 20022, etc.) | 60 | | wsPort | 20003 | AppSync subscriptions port; if using multiple APIs, the value of this option will be used as a starting point, and each other API will have a port of lastPort + 10 (e.g. 20003, 20013, 20023, etc.) | 61 | | location | . (base directory) | Location of the lambda functions handlers. | 62 | | refMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `Ref` function | 63 | | getAttMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `GetAtt` function | 64 | | importValueMap | {} | A mapping of [resource resolutions](#resource-cloudformation-functions-resolution) for the `ImportValue` function | 65 | | functions | {} | A mapping of [external functions](#functions) for providing invoke url for external fucntions | 66 | | dynamoDb.endpoint | http://localhost:8000 | Dynamodb endpoint. Specify it if you're not using serverless-dynamodb-local. Otherwise, port is taken from dynamodb-local conf | 67 | | dynamoDb.region | localhost | Dynamodb region. Specify it if you're connecting to a remote Dynamodb intance. | 68 | | dynamoDb.accessKeyId | DEFAULT_ACCESS_KEY | AWS Access Key ID to access DynamoDB | 69 | | dynamoDb.secretAccessKey | DEFAULT_SECRET | AWS Secret Key to access DynamoDB | 70 | | dynamoDb.sessionToken | DEFAULT_ACCESS_TOKEEN | AWS Session Token to access DynamoDB, only if you have temporary security credentials configured on AWS | 71 | | dynamoDb.\* | | You can add every configuration accepted by [DynamoDB SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html#constructor-property) | 72 | | rds.dbName | | Name of the database | 73 | | rds.dbHost | | Database host | 74 | | rds.dbDialect | | Database dialect. Possible values (mysql/postgres) | 75 | | rds.dbUsername | | Database username | 76 | | rds.dbPassword | | Database password | 77 | | rds.dbPort | | Database port | 78 | | openSearch.useSignature | false | Enable signing requests to OpenSearch. The preference for credentials is config > environment variables > local credential file. | 79 | | openSearch.region | | OpenSearch region. Specify it if you're connecting to a remote OpenSearch intance. | 80 | | openSearch.accessKeyId | | AWS Access Key ID to access OpenSearch | 81 | | openSearch.secretAccessKey | | AWS Secret Key to access OpenSearch | 82 | | watch | - \*.graphql
- \*.vtl | Array of glob patterns to watch for hot-reloading. | 83 | 84 | Example: 85 | 86 | ```yml 87 | custom: 88 | appsync-simulator: 89 | location: '.webpack/service' # use webpack build directory 90 | dynamoDb: 91 | endpoint: 'http://my-custom-dynamo:8000' 92 | ``` 93 | 94 | # Hot-reloading 95 | 96 | By default, the simulator will hot-relad when changes to `*.graphql` or `*.vtl` files are detected. 97 | Changes to `*.yml` files are not supported (yet? - this is a Serverless Framework limitation). You will need to restart the simulator each time you change yml files. 98 | 99 | Hot-reloading relies on [watchman](https://facebook.github.io/watchman). Make sure it is [installed](https://facebook.github.io/watchman/docs/install.html) on your system. 100 | 101 | You can change the files being watched with the `watch` option, which is then passed to watchman as [the match expression](https://facebook.github.io/watchman/docs/expr/match.html). 102 | 103 | e.g. 104 | 105 | ``` 106 | custom: 107 | appsync-simulator: 108 | watch: 109 | - ["match", "handlers/**/*.vtl", "wholename"] # => array is interpreted as the literal match expression 110 | - "*.graphql" # => string like this is equivalent to `["match", "*.graphql"]` 111 | ``` 112 | 113 | Or you can opt-out by leaving an empty array or set the option to `false` 114 | 115 | Note: Functions should not require hot-reloading, unless you are using a transpiler or a bundler (such as webpack, babel or typescript), un which case you should delegate hot-reloading to that instead. 116 | 117 | # Resource CloudFormation functions resolution 118 | 119 | This plugin supports _some_ resources resolution from the `Ref`, `Fn::GetAtt` and `Fn::ImportValue` functions 120 | in your yaml file. It also supports _some_ other Cfn functions such as `Fn::Join`, `Fb::Sub`, etc. 121 | 122 | **Note:** Under the hood, this features relies on the [cfn-resolver-lib](https://github.com/robessog/cfn-resolver-lib) package. For more info on supported cfn functions, refer to [the documentation](https://github.com/robessog/cfn-resolver-lib/blob/master/README.md) 123 | 124 | ## Basic usage 125 | 126 | You can reference resources in your functions' environment variables (that will be accessible from your lambda functions) or datasource definitions. 127 | The plugin will automatically resolve them for you. 128 | 129 | ```yaml 130 | provider: 131 | environment: 132 | BUCKET_NAME: 133 | Ref: MyBucket # resolves to `my-bucket-name` 134 | 135 | resources: 136 | Resources: 137 | MyDbTable: 138 | Type: AWS::DynamoDB::Table 139 | Properties: 140 | TableName: myTable 141 | ... 142 | MyBucket: 143 | Type: AWS::S3::Bucket 144 | Properties: 145 | BucketName: my-bucket-name 146 | ... 147 | 148 | # in your appsync config 149 | dataSources: 150 | - type: AMAZON_DYNAMODB 151 | name: dynamosource 152 | config: 153 | tableName: 154 | Ref: MyDbTable # resolves to `myTable` 155 | ``` 156 | 157 | ## Override (or mock) values 158 | 159 | Sometimes, some references **cannot** be resolved, as they come from an _Output_ from Cloudformation; or you might want to use mocked values in your local environment. 160 | 161 | In those cases, you can define (or override) those values using the `refMap`, `getAttMap` and `importValueMap` options. 162 | 163 | - `refMap` takes a mapping of _resource name_ to _value_ pairs 164 | - `getAttMap` takes a mapping of _resource name_ to _attribute/values_ pairs 165 | - `importValueMap` takes a mapping of _import name_ to _values_ pairs 166 | 167 | Example: 168 | 169 | ```yaml 170 | custom: 171 | appsync-simulator: 172 | refMap: 173 | # Override `MyDbTable` resolution from the previous example. 174 | MyDbTable: 'mock-myTable' 175 | getAttMap: 176 | # define ElasticSearchInstance DomainName 177 | ElasticSearchInstance: 178 | DomainEndpoint: 'localhost:9200' 179 | importValueMap: 180 | other-service-api-url: 'https://other.api.url.com/graphql' 181 | 182 | # in your appsync config 183 | dataSources: 184 | - type: AMAZON_ELASTICSEARCH 185 | name: elasticsource 186 | config: 187 | # endpoint resolves as 'http://localhost:9200' 188 | endpoint: 189 | Fn::Join: 190 | - '' 191 | - - https:// 192 | - Fn::GetAtt: 193 | - ElasticSearchInstance 194 | - DomainEndpoint 195 | ``` 196 | 197 | ### Key-value mock notation 198 | 199 | In some special cases you will need to use key-value mock nottation. 200 | Good example can be case when you need to include serverless stage value (`${self:provider.stage}`) in the import name. 201 | 202 | _This notation can be used with all mocks - `refMap`, `getAttMap` and `importValueMap`_ 203 | 204 | ```yaml 205 | provider: 206 | environment: 207 | FINISH_ACTIVITY_FUNCTION_ARN: 208 | Fn::ImportValue: other-service-api-${self:provider.stage}-url 209 | 210 | custom: 211 | serverless-appsync-simulator: 212 | importValueMap: 213 | - key: other-service-api-${self:provider.stage}-url 214 | value: 'https://other.api.url.com/graphql' 215 | ``` 216 | 217 | ## Limitations 218 | 219 | This plugin only tries to resolve the following parts of the yml tree: 220 | 221 | - `provider.environment` 222 | - `functions[*].environment` 223 | - `custom.appSync` 224 | 225 | If you have the need of resolving others, feel free to open an issue and explain your use case. 226 | 227 | For now, the supported resources to be automatically resovled by `Ref:` are: 228 | 229 | - DynamoDb tables 230 | - S3 Buckets 231 | 232 | Feel free to open a PR or an issue to extend them as well. 233 | 234 | # External functions 235 | 236 | When a function is not defined withing the current serverless file you can still call it by providing an invoke url which should point to a REST method. Make sure you specify "get" or "post" for the method. Default is "get", but you probably want "post". 237 | 238 | ```yaml 239 | custom: 240 | appsync-simulator: 241 | functions: 242 | addUser: 243 | url: http://localhost:3016/2015-03-31/functions/addUser/invocations 244 | method: post 245 | addPost: 246 | url: https://jsonplaceholder.typicode.com/posts 247 | method: post 248 | ``` 249 | 250 | # Supported Resolver types 251 | 252 | This plugin supports resolvers implemented by `amplify-appsync-simulator`, as well as custom resolvers. 253 | 254 | **From Aws Amplify:** 255 | 256 | - NONE 257 | - AWS_LAMBDA 258 | - AMAZON_DYNAMODB 259 | - PIPELINE 260 | 261 | **Implemented by this plugin** 262 | 263 | - AMAZON_ELASTICSEARCH 264 | - HTTP 265 | - RELATIONAL_DATABASE 266 | 267 | ## Relational Database 268 | 269 | ### Sample VTL for a create mutation 270 | 271 | ``` 272 | #set( $cols = [] ) 273 | #set( $vals = [] ) 274 | #foreach( $entry in $ctx.args.input.keySet() ) 275 | #set( $regex = "([a-z])([A-Z]+)") 276 | #set( $replacement = "$1_$2") 277 | #set( $toSnake = $entry.replaceAll($regex, $replacement).toLowerCase() ) 278 | #set( $discard = $cols.add("$toSnake") ) 279 | #if( $util.isBoolean($ctx.args.input[$entry]) ) 280 | #if( $ctx.args.input[$entry] ) 281 | #set( $discard = $vals.add("1") ) 282 | #else 283 | #set( $discard = $vals.add("0") ) 284 | #end 285 | #else 286 | #set( $discard = $vals.add("'$ctx.args.input[$entry]'") ) 287 | #end 288 | #end 289 | #set( $valStr = $vals.toString().replace("[","(").replace("]",")") ) 290 | #set( $colStr = $cols.toString().replace("[","(").replace("]",")") ) 291 | #if ( $valStr.substring(0, 1) != '(' ) 292 | #set( $valStr = "($valStr)" ) 293 | #end 294 | #if ( $colStr.substring(0, 1) != '(' ) 295 | #set( $colStr = "($colStr)" ) 296 | #end 297 | { 298 | "version": "2018-05-29", 299 | "statements": ["INSERT INTO $colStr VALUES $valStr", "SELECT * FROM ORDER BY id DESC LIMIT 1"] 300 | } 301 | ``` 302 | 303 | ### Sample VTL for an update mutation 304 | 305 | ``` 306 | #set( $update = "" ) 307 | #set( $equals = "=" ) 308 | #foreach( $entry in $ctx.args.input.keySet() ) 309 | #set( $cur = $ctx.args.input[$entry] ) 310 | #set( $regex = "([a-z])([A-Z]+)") 311 | #set( $replacement = "$1_$2") 312 | #set( $toSnake = $entry.replaceAll($regex, $replacement).toLowerCase() ) 313 | #if( $util.isBoolean($cur) ) 314 | #if( $cur ) 315 | #set ( $cur = "1" ) 316 | #else 317 | #set ( $cur = "0" ) 318 | #end 319 | #end 320 | #if ( $util.isNullOrEmpty($update) ) 321 | #set($update = "$toSnake$equals'$cur'" ) 322 | #else 323 | #set($update = "$update,$toSnake$equals'$cur'" ) 324 | #end 325 | #end 326 | { 327 | "version": "2018-05-29", 328 | "statements": ["UPDATE SET $update WHERE id=$ctx.args.input.id", "SELECT * FROM WHERE id=$ctx.args.input.id"] 329 | } 330 | ``` 331 | 332 | ### Sample resolver for delete mutation 333 | 334 | ``` 335 | { 336 | "version": "2018-05-29", 337 | "statements": ["UPDATE set deleted_at=NOW() WHERE id=$ctx.args.id", "SELECT * FROM WHERE id=$ctx.args.id"] 338 | } 339 | ``` 340 | 341 | ### Sample mutation response VTL with support for handling AWSDateTime 342 | 343 | ``` 344 | #set ( $index = -1) 345 | #set ( $result = $util.parseJson($ctx.result) ) 346 | #set ( $meta = $result.sqlStatementResults[1].columnMetadata) 347 | #foreach ($column in $meta) 348 | #set ($index = $index + 1) 349 | #if ( $column["typeName"] == "timestamptz" ) 350 | #set ($time = $result["sqlStatementResults"][1]["records"][0][$index]["stringValue"] ) 351 | #set ( $nowEpochMillis = $util.time.parseFormattedToEpochMilliSeconds("$time.substring(0,19)+0000", "yyyy-MM-dd HH:mm:ssZ") ) 352 | #set ( $isoDateTime = $util.time.epochMilliSecondsToISO8601($nowEpochMillis) ) 353 | $util.qr( $result["sqlStatementResults"][1]["records"][0][$index].put("stringValue", "$isoDateTime") ) 354 | #end 355 | #end 356 | #set ( $res = $util.parseJson($util.rds.toJsonString($util.toJson($result)))[1][0] ) 357 | #set ( $response = {} ) 358 | #foreach($mapKey in $res.keySet()) 359 | #set ( $s = $mapKey.split("_") ) 360 | #set ( $camelCase="" ) 361 | #set ( $isFirst=true ) 362 | #foreach($entry in $s) 363 | #if ( $isFirst ) 364 | #set ( $first = $entry.substring(0,1) ) 365 | #else 366 | #set ( $first = $entry.substring(0,1).toUpperCase() ) 367 | #end 368 | #set ( $isFirst=false ) 369 | #set ( $stringLength = $entry.length() ) 370 | #set ( $remaining = $entry.substring(1, $stringLength) ) 371 | #set ( $camelCase = "$camelCase$first$remaining" ) 372 | #end 373 | $util.qr( $response.put("$camelCase", $res[$mapKey]) ) 374 | #end 375 | $utils.toJson($response) 376 | ``` 377 | 378 | ### Using Variable Map 379 | 380 | Variable map support is limited and does not differentiate numbers and strings data types, please inject them directly if needed. 381 | 382 | Will be escaped properly: `null`, `true`, and `false` values. 383 | 384 | ``` 385 | { 386 | "version": "2018-05-29", 387 | "statements": [ 388 | "UPDATE set deleted_at=NOW() WHERE id=:ID", 389 | "SELECT * FROM WHERE id=:ID and unix_timestamp > $ctx.args.newerThan" 390 | ], 391 | variableMap: { 392 | ":ID": $ctx.args.id, 393 | ## ":TIMESTAMP": $ctx.args.newerThan -- This will be handled as a string!!! 394 | } 395 | } 396 | ``` 397 | 398 | ## Contributors ✨ 399 | 400 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 |

Benoît Bouré

💻

Filip Pýrek

💻

Marco Reni

💻

Egor Dmitriev

💻

Steffen Schwark

💻

Nicky Moelholm

💻

g-awa

💻

Lee Mulvey

💻

Jimmy Hurrah

💻

Abdala

🤔

Alexandru Savin

📖

Scale93

💻 📖

Ryo Yamada

💻 📖

h-kishi

💻

louislatreille

💻

Aleksa Cukovic

💻

Sean van Mulligen

💻

Diego Rodrigues Ferreira

💻

Mohammed Ali Chherawalla

💻

AdrianTodt

💻
433 | 434 | 435 | 436 | 437 | 438 | 439 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 440 | -------------------------------------------------------------------------------- /examples/external-functions/mapping-templates/Query.getPosts.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2018-05-29", 3 | "operation": "Invoke" 4 | } 5 | -------------------------------------------------------------------------------- /examples/external-functions/mapping-templates/Query.getPosts.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) 2 | -------------------------------------------------------------------------------- /examples/external-functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "external-functions", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "offline": "sls offline start" 8 | }, 9 | "dependencies": { 10 | "serverless-appsync-plugin": "^1.3.1", 11 | "serverless-offline": "^6.7.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/external-functions/schema.graphql: -------------------------------------------------------------------------------- 1 | type Post { 2 | userId: Int! 3 | id: Int! 4 | title: String! 5 | body: String! 6 | } 7 | 8 | type Query { 9 | getPosts: [Post]! 10 | } 11 | 12 | schema { 13 | query: Query 14 | } 15 | -------------------------------------------------------------------------------- /examples/external-functions/serverless.yml: -------------------------------------------------------------------------------- 1 | service: mapping-templates 2 | provider: 3 | name: aws 4 | region: eu-west-1 5 | 6 | plugins: 7 | - serverless-offline 8 | - serverless-appsync-simulator 9 | - serverless-appsync-plugin 10 | 11 | custom: 12 | appsync-simulator: 13 | functions: 14 | myLambda: 15 | url: https://jsonplaceholder.typicode.com/posts 16 | method: GET 17 | appSync: 18 | name: Test 19 | schema: schema.graphql 20 | authenticationType: AWS_IAM 21 | mappingTemplatesLocation: mapping-templates 22 | mappingTemplates: 23 | - dataSource: MyLambda 24 | type: Query 25 | field: getPosts 26 | request: Query.getPosts.request.vtl 27 | response: Query.getPosts.response.vtl 28 | dataSources: 29 | - type: AWS_LAMBDA 30 | name: MyLambda 31 | config: 32 | functionName: myLambda 33 | lambdaFunctionArn: "arn:aws:lambda:${self:provider.region}:*:getPosts-graphql" 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-appsync-simulator", 3 | "version": "0.0.0-development", 4 | "main": "lib/index.js", 5 | "author": "bboure", 6 | "license": "MIT", 7 | "private": false, 8 | "description": "Offline support for serverless-appsync-plugin", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/serverless-appsync/serverless-appsync-simulator.git" 12 | }, 13 | "scripts": { 14 | "lint": "eslint src/*/**.js", 15 | "tests": "jest", 16 | "build": "babel src/ -d lib/ --delete-dir-on-start --ignore '**/__tests__'", 17 | "prepare": "yarn run build", 18 | "start-dev": "yarn run build -w --verbose" 19 | }, 20 | "files": [ 21 | "/lib" 22 | ], 23 | "dependencies": { 24 | "@graphql-tools/merge": "^8.2.1", 25 | "amplify-appsync-simulator": "^1.27.4", 26 | "amplify-nodejs-function-runtime-provider": "^1.1.6", 27 | "aws-sdk": "^2.792.0", 28 | "axios": "^0.21.0", 29 | "babel-jest": "^26.6.3", 30 | "bluebird": "^3.7.2", 31 | "cfn-resolver-lib": "^1.1.7", 32 | "dataloader": "^2.0.0", 33 | "fb-watchman": "^2.0.1", 34 | "globby": "^11.0.3", 35 | "jest": "^26.6.3", 36 | "lodash": "^4.17.20", 37 | "mysql2": "^2.2.5", 38 | "pg": "^8.6.0" 39 | }, 40 | "devDependencies": { 41 | "@babel/cli": "^7.12.1", 42 | "@babel/core": "^7.12.3", 43 | "@babel/plugin-transform-modules-commonjs": "^7.12.1", 44 | "@babel/preset-env": "^7.12.1", 45 | "@semantic-release/git": "^9.0.0", 46 | "all-contributors-cli": "^6.19.0", 47 | "babel-eslint": "^10.1.0", 48 | "babel-plugin-inline-import": "^3.0.0", 49 | "eslint": "^7.13.0", 50 | "eslint-config-prettier": "^7.0.0", 51 | "eslint-plugin-prettier": "^3.2.0", 52 | "prettier": "^2.2.1", 53 | "semantic-release": "19" 54 | }, 55 | "keywords": [ 56 | "serverless", 57 | "serverless framework", 58 | "serverless plugin", 59 | "serverless local", 60 | "serverless offline", 61 | "api gateway", 62 | "lambda", 63 | "dynamodb", 64 | "dynamodb local", 65 | "appsync" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/getAppSyncConfig.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getAppSyncConfig should generate a valid config 1`] = ` 4 | Object { 5 | "additionalAuthenticationProviders": Array [], 6 | "apiKey": "123456789", 7 | "defaultAuthenticationType": Object { 8 | "authenticationType": "API_KEY", 9 | }, 10 | "name": "myAPI", 11 | } 12 | `; 13 | 14 | exports[`getAppSyncConfig should generate a valid config 2`] = ` 15 | Object { 16 | "content": "type Post { 17 | userId: Int! 18 | id: Int! 19 | title: String! 20 | body: String! 21 | } 22 | 23 | type Query { 24 | getPost: Post 25 | getPosts: [Post]! 26 | } 27 | 28 | schema { 29 | query: Query 30 | }", 31 | "path": "schema.graphql", 32 | } 33 | `; 34 | 35 | exports[`getAppSyncConfig should generate a valid config 3`] = ` 36 | Array [ 37 | Object { 38 | "dataSourceName": "lambda", 39 | "fieldName": "templates", 40 | "functions": undefined, 41 | "kind": "UNIT", 42 | "requestMappingTemplate": "{ 43 | \\"version\\": \\"2018-05-29\\", 44 | \\"operation\\": \\"Invoke\\", 45 | \\"payload\\": { 46 | \\"substitution\\": \\"lambda\\", 47 | \\"args\\": $utils.toJson($context.arguments) 48 | } 49 | } 50 | ", 51 | "responseMappingTemplate": "$utils.toJson($context.result.lambda) 52 | ", 53 | "typeName": "Query", 54 | }, 55 | Object { 56 | "dataSourceName": "lambda", 57 | "fieldName": "default", 58 | "functions": undefined, 59 | "kind": "UNIT", 60 | "requestMappingTemplate": "{ 61 | \\"version\\": \\"2018-05-29\\", 62 | \\"operation\\": \\"Invoke\\", 63 | \\"payload\\": { 64 | \\"substitution\\": \\"default\\", 65 | \\"type\\": \\"default\\" 66 | } 67 | } 68 | ", 69 | "responseMappingTemplate": "$utils.toJson($context.result.default) 70 | ", 71 | "typeName": "Query", 72 | }, 73 | Object { 74 | "dataSourceName": "lambda", 75 | "fieldName": "directLambda", 76 | "functions": undefined, 77 | "kind": "UNIT", 78 | "requestMappingTemplate": "## Direct lambda request 79 | { 80 | \\"version\\": \\"2018-05-29\\", 81 | \\"operation\\": \\"Invoke\\", 82 | \\"payload\\": $utils.toJson($context) 83 | } 84 | ", 85 | "responseMappingTemplate": "## Direct lambda response 86 | #if($ctx.error) 87 | $util.error($ctx.error.message, $ctx.error.type, $ctx.result) 88 | #end 89 | $util.toJson($ctx.result) 90 | ", 91 | "typeName": "Query", 92 | }, 93 | Object { 94 | "dataSourceName": undefined, 95 | "fieldName": "pipeline", 96 | "functions": Array [ 97 | "func", 98 | "func-default", 99 | ], 100 | "kind": "PIPELINE", 101 | "requestMappingTemplate": "{ 102 | \\"version\\": \\"2018-05-29\\", 103 | \\"operation\\": \\"Invoke\\", 104 | \\"payload\\": { 105 | \\"substitution\\": \\"pipeline\\", 106 | \\"type\\": \\"default\\" 107 | } 108 | } 109 | ", 110 | "responseMappingTemplate": "$utils.toJson($context.result.default) 111 | ", 112 | "typeName": "Query", 113 | }, 114 | ] 115 | `; 116 | 117 | exports[`getAppSyncConfig should generate a valid config 4`] = ` 118 | Array [ 119 | Object { 120 | "invoke": [Function], 121 | "name": "lambda", 122 | "type": "AWS_LAMBDA", 123 | }, 124 | Object { 125 | "config": Object { 126 | "accessKeyId": "DEFAULT_ACCESS_KEY", 127 | "endpoint": "http://localhost:8000", 128 | "region": "localhost", 129 | "secretAccessKey": "DEFAULT_SECRET", 130 | "sessionToken": "DEFAULT_SESSION_TOKEN", 131 | "tableName": "myTable", 132 | }, 133 | "name": "dynamodb", 134 | "type": "AMAZON_DYNAMODB", 135 | }, 136 | Object { 137 | "endpoint": "http://127.0.0.1", 138 | "name": "http", 139 | "type": "HTTP", 140 | }, 141 | ] 142 | `; 143 | 144 | exports[`getAppSyncConfig should generate a valid config 5`] = ` 145 | Array [ 146 | Object { 147 | "dataSourceName": "lambda", 148 | "name": "func", 149 | "requestMappingTemplate": "{ 150 | \\"version\\": \\"2018-05-29\\", 151 | \\"operation\\": \\"Invoke\\", 152 | \\"payload\\": { 153 | \\"substitution\\": \\"template-function\\", 154 | \\"args\\": $utils.toJson($context.arguments) 155 | } 156 | } 157 | ", 158 | "responseMappingTemplate": "$utils.toJson($context.result.lambda) 159 | ", 160 | }, 161 | Object { 162 | "dataSourceName": "lambda", 163 | "name": "func-default", 164 | "requestMappingTemplate": "{ 165 | \\"version\\": \\"2018-05-29\\", 166 | \\"operation\\": \\"Invoke\\", 167 | \\"payload\\": { 168 | \\"substitution\\": \\"default-function\\", 169 | \\"type\\": \\"default\\" 170 | } 171 | } 172 | ", 173 | "responseMappingTemplate": "$utils.toJson($context.result.default) 174 | ", 175 | }, 176 | Object { 177 | "dataSourceName": "lambda", 178 | "name": "func-direct", 179 | "requestMappingTemplate": "## Direct lambda request 180 | { 181 | \\"version\\": \\"2018-05-29\\", 182 | \\"operation\\": \\"Invoke\\", 183 | \\"payload\\": $utils.toJson($context) 184 | } 185 | ", 186 | "responseMappingTemplate": "## Direct lambda response 187 | #if($ctx.error) 188 | $util.error($ctx.error.message, $ctx.error.type, $ctx.result) 189 | #end 190 | $util.toJson($ctx.result) 191 | ", 192 | }, 193 | ] 194 | `; 195 | -------------------------------------------------------------------------------- /src/__tests__/data-loaders/ElasticDataLoader.js: -------------------------------------------------------------------------------- 1 | import { PassThrough } from 'stream'; 2 | import * as AWS from 'aws-sdk'; 3 | import axios from 'axios'; 4 | import ElasticDataLoader from '../../data-loaders/ElasticDataLoader'; 5 | 6 | describe('data-loaders/ElasticDataLoader', () => { 7 | beforeEach(() => { 8 | jest.spyOn(AWS.HttpClient.prototype, 'handleRequest'); 9 | jest.spyOn(axios, 'request'); 10 | }); 11 | 12 | afterEach(() => { 13 | AWS.HttpClient.prototype.handleRequest.mockClear(); 14 | axios.request.mockClear(); 15 | }); 16 | 17 | it('should send a request', async () => { 18 | const loader = new ElasticDataLoader({ 19 | endpoint: 'https://my-elasticsearch-cluster.region.amazonaws.com', 20 | }); 21 | axios.request.mockImplementation(async () => { 22 | return { data: { hits: {} } }; 23 | }); 24 | const req = { 25 | path: '[index]/_search', 26 | operation: 'GET', 27 | params: { 28 | headers: {}, 29 | body: '{"query": { "match_all": {} }}', 30 | }, 31 | }; 32 | const data = await loader.load(req); 33 | expect(data).toEqual({ hits: {} }); 34 | }); 35 | 36 | it('should send a signed request', async () => { 37 | const loader = new ElasticDataLoader({ 38 | endpoint: 'https://my-elasticsearch-cluster.region.amazonaws.com', 39 | useSignature: true, 40 | accessKeyId: 'fakeAccessKeyId', 41 | secretAccessKey: 'fakeSecretAccessKey', 42 | region: '', 43 | }); 44 | const mockStream = new PassThrough(); 45 | let signedRequest; 46 | AWS.HttpClient.prototype.handleRequest.mockImplementation( 47 | (request, _options, callback) => { 48 | signedRequest = request; 49 | callback(mockStream); 50 | }, 51 | ); 52 | const body = '{"query": { "match_all": {} }}'; 53 | const req = { 54 | path: '[index]/_search', 55 | operation: 'GET', 56 | params: { 57 | headers: {}, 58 | body, 59 | }, 60 | }; 61 | process.nextTick(() => { 62 | mockStream.emit('data', '{ "hits": {} }'); 63 | mockStream.end(); 64 | }); 65 | const data = await loader.load(req); 66 | expect(signedRequest.headers.host).toEqual( 67 | 'my-elasticsearch-cluster.region.amazonaws.com', 68 | ); 69 | expect(signedRequest.headers['Authorization']).toMatch(/^AWS4-HMAC-SHA256/); 70 | expect(signedRequest.body).toEqual(body); 71 | expect(data).toEqual({ hits: {} }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/__tests__/files/mapping-templates/default.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2018-05-29", 3 | "operation": "Invoke", 4 | "payload": { 5 | "substitution": "$mySubVar", 6 | "type": "default" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/__tests__/files/mapping-templates/default.response.vtl: -------------------------------------------------------------------------------- 1 | $utils.toJson($context.result.default) 2 | -------------------------------------------------------------------------------- /src/__tests__/files/mapping-templates/lambda.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2018-05-29", 3 | "operation": "Invoke", 4 | "payload": { 5 | "substitution": "$mySubVar", 6 | "args": $utils.toJson($context.arguments) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/__tests__/files/mapping-templates/lambda.response.vtl: -------------------------------------------------------------------------------- 1 | $utils.toJson($context.result.lambda) 2 | -------------------------------------------------------------------------------- /src/__tests__/files/schema.graphql: -------------------------------------------------------------------------------- 1 | type Post { 2 | userId: Int! 3 | id: Int! 4 | title: String! 5 | body: String! 6 | } 7 | 8 | type Query { 9 | getPost: Post 10 | getPosts: [Post]! 11 | } 12 | 13 | schema { 14 | query: Query 15 | } 16 | -------------------------------------------------------------------------------- /src/__tests__/getAppSyncConfig.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import getAppSyncConfig from '../getAppSyncConfig'; 3 | 4 | describe('getAppSyncConfig', () => { 5 | it('should generate a valid config', () => { 6 | const config = { 7 | name: 'myAPI', 8 | authenticationType: 'API_KEY', 9 | schema: '*.graphql', 10 | defaultMappingTemplates: { 11 | request: 'default.request.vtl', 12 | response: 'default.response.vtl', 13 | }, 14 | mappingTemplates: [ 15 | { 16 | dataSource: 'lambda', 17 | type: 'Query', 18 | field: 'templates', 19 | request: 'lambda.request.vtl', 20 | response: 'lambda.response.vtl', 21 | substitutions: { 22 | mySubVar: 'lambda', 23 | }, 24 | }, 25 | { 26 | dataSource: 'lambda', 27 | type: 'Query', 28 | field: 'default', 29 | substitutions: { 30 | mySubVar: 'default', 31 | }, 32 | }, 33 | { 34 | dataSource: 'lambda', 35 | type: 'Query', 36 | field: 'directLambda', 37 | request: false, 38 | response: false, 39 | }, 40 | { 41 | type: 'Query', 42 | kind: 'PIPELINE', 43 | field: 'pipeline', 44 | functions: ['func', 'func-default'], 45 | substitutions: { 46 | mySubVar: 'pipeline', 47 | }, 48 | }, 49 | ], 50 | functionConfigurations: [ 51 | { 52 | dataSource: 'lambda', 53 | name: 'func', 54 | request: 'lambda.request.vtl', 55 | response: 'lambda.response.vtl', 56 | substitutions: { 57 | mySubVar: 'template-function', 58 | }, 59 | }, 60 | { 61 | dataSource: 'lambda', 62 | name: 'func-default', 63 | substitutions: { 64 | mySubVar: 'default-function', 65 | }, 66 | }, 67 | { 68 | dataSource: 'lambda', 69 | name: 'func-direct', 70 | request: false, 71 | response: false, 72 | }, 73 | ], 74 | dataSources: [ 75 | { 76 | type: 'AWS_LAMBDA', 77 | name: 'lambda', 78 | config: { 79 | functionName: 'getPosts', 80 | }, 81 | }, 82 | { 83 | type: 'AMAZON_DYNAMODB', 84 | name: 'dynamodb', 85 | config: { 86 | tableName: 'myTable', 87 | }, 88 | }, 89 | { 90 | type: 'HTTP', 91 | name: 'http', 92 | config: { 93 | endpoint: 'http://127.0.0.1', 94 | }, 95 | }, 96 | ], 97 | }; 98 | 99 | const result = getAppSyncConfig( 100 | { 101 | options: { 102 | apiKey: '123456789', 103 | dynamoDb: { 104 | endpoint: `http://localhost:8000`, 105 | region: 'localhost', 106 | accessKeyId: 'DEFAULT_ACCESS_KEY', 107 | secretAccessKey: 'DEFAULT_SECRET', 108 | sessionToken: 'DEFAULT_SESSION_TOKEN', 109 | }, 110 | }, 111 | serverless: { 112 | config: { servicePath: path.join(__dirname, 'files') }, 113 | service: { 114 | functions: { 115 | getPost: { 116 | hndler: 'index.handler', 117 | }, 118 | getPosts: { 119 | hndler: 'index.handler', 120 | }, 121 | }, 122 | }, 123 | }, 124 | }, 125 | config, 126 | ); 127 | expect(result.appSync).toMatchSnapshot(); 128 | expect(result.schema).toMatchSnapshot(); 129 | expect(result.resolvers).toMatchSnapshot(); 130 | expect(result.dataSources).toMatchSnapshot(); 131 | expect(result.functions).toMatchSnapshot(); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_MAPPING_TEMPLATE_LOCATION = 'mapping-templates'; 2 | const DEFAULT_ENCODING = 'utf8'; 3 | const DEFAULT_SCHEMA_FILE = 'schema.graphql'; 4 | const DEFAULT_HTTP_METHOD = 'POST'; 5 | const DEFAULT_RESOLVER_TYPE = 'UNIT'; 6 | 7 | const HTTPMessage = { 8 | REQUEST: 'request', 9 | RESPONSE: 'response', 10 | }; 11 | 12 | const MappingTemplateType = { 13 | MAPPING_TEMPLATE: 'mappingTemplate', 14 | FUNCTION_CONFIGURATION: 'functionConfiguration', 15 | }; 16 | 17 | const SourceType = { 18 | AMAZON_DYNAMODB: 'AMAZON_DYNAMODB', 19 | RELATIONAL_DATABASE: 'RELATIONAL_DATABASE', 20 | AWS_LAMBDA: 'AWS_LAMBDA', 21 | AMAZON_ELASTICSEARCH: 'AMAZON_ELASTICSEARCH', 22 | HTTP: 'HTTP', 23 | }; 24 | 25 | export { 26 | DEFAULT_MAPPING_TEMPLATE_LOCATION, 27 | DEFAULT_ENCODING, 28 | DEFAULT_SCHEMA_FILE, 29 | DEFAULT_HTTP_METHOD, 30 | DEFAULT_RESOLVER_TYPE, 31 | HTTPMessage, 32 | MappingTemplateType, 33 | SourceType, 34 | }; 35 | -------------------------------------------------------------------------------- /src/data-loaders/ElasticDataLoader.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as AWS from 'aws-sdk'; 3 | 4 | export default class ElasticDataLoader { 5 | constructor(config) { 6 | this.config = config; 7 | } 8 | 9 | async load(req) { 10 | try { 11 | if (this.config.useSignature) { 12 | const signedRequest = await this.createSignedRequest(req); 13 | const client = new AWS.HttpClient(); 14 | const data = await new Promise((resolve, reject) => { 15 | client.handleRequest( 16 | signedRequest, 17 | null, 18 | (response) => { 19 | let responseBody = ''; 20 | response.on('data', (chunk) => { 21 | responseBody += chunk; 22 | }); 23 | response.on('end', () => { 24 | resolve(responseBody); 25 | }); 26 | }, 27 | (err) => { 28 | reject(err); 29 | }, 30 | ); 31 | }); 32 | return JSON.parse(data); 33 | } else { 34 | const { data } = await axios.request({ 35 | baseURL: this.config.endpoint, 36 | url: req.path, 37 | headers: req.params.headers, 38 | params: req.params.queryString, 39 | method: req.operation.toLowerCase(), 40 | data: req.params.body, 41 | }); 42 | 43 | return data; 44 | } 45 | } catch (err) { 46 | console.log(err); 47 | } 48 | 49 | return null; 50 | } 51 | 52 | async createSignedRequest(req) { 53 | const domain = this.config.endpoint.replace('https://', ''); 54 | const headers = { 55 | ...req.params.headers, 56 | host: domain, 57 | 'Content-Type': 'application/json', 58 | 'Content-Length': Buffer.byteLength(req.params.body), 59 | }; 60 | const endpoint = new AWS.Endpoint(domain); 61 | const httpRequest = new AWS.HttpRequest(endpoint, this.config.region); 62 | httpRequest.headers = headers; 63 | httpRequest.body = req.params.body; 64 | httpRequest.method = req.operation; 65 | httpRequest.path = req.path; 66 | 67 | const credentials = await this.getCredentials(); 68 | const signer = new AWS.Signers.V4(httpRequest, 'es'); 69 | signer.addAuthorization(credentials, new Date()); 70 | 71 | return httpRequest; 72 | } 73 | 74 | async getCredentials() { 75 | const chain = new AWS.CredentialProviderChain([ 76 | () => new AWS.EnvironmentCredentials('AWS'), 77 | () => new AWS.EnvironmentCredentials('AMAZON'), 78 | () => new AWS.SharedIniFileCredentials(), 79 | ]); 80 | if (this.config.accessKeyId && this.config.secretAccessKey) { 81 | chain.providers.unshift( 82 | () => 83 | new AWS.Credentials( 84 | this.config.accessKeyId, 85 | this.config.secretAccessKey, 86 | ), 87 | ); 88 | } 89 | return new Promise((resolve, reject) => 90 | chain.resolve((err, creds) => { 91 | if (err) { 92 | reject(err); 93 | } else { 94 | resolve(creds); 95 | } 96 | }), 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/data-loaders/HttpDataLoader.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { isObject, forEach } from 'lodash'; 3 | 4 | const paramsSerializer = (params) => { 5 | const parts = []; 6 | 7 | forEach(params, (value, key) => { 8 | if (value === null || typeof value === 'undefined') { 9 | return; 10 | } 11 | 12 | let k = key; 13 | let v = value; 14 | if (Array.isArray(v)) { 15 | k += '[]'; 16 | } else { 17 | v = [v]; 18 | } 19 | 20 | forEach(v, (val) => { 21 | let finalValue = val; 22 | if (isObject(finalValue)) { 23 | finalValue = JSON.stringify(finalValue); 24 | } 25 | parts.push(`${k}=${finalValue}`); 26 | }); 27 | }); 28 | 29 | return parts.join('&'); 30 | }; 31 | 32 | export default class HttpDataLoader { 33 | constructor(config) { 34 | this.config = config; 35 | } 36 | 37 | async load(req) { 38 | try { 39 | const { data, status, headers } = await axios.request({ 40 | baseURL: this.config.endpoint, 41 | validateStatus: false, 42 | url: req.resourcePath, 43 | headers: req.params.headers, 44 | params: req.params.query, 45 | paramsSerializer, 46 | method: req.method.toLowerCase(), 47 | data: req.params.body, 48 | }); 49 | 50 | return { 51 | headers, 52 | statusCode: status, 53 | body: JSON.stringify(data), 54 | }; 55 | } catch (err) { 56 | console.log(err); 57 | } 58 | 59 | return null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/data-loaders/NotImplementedDataLoader.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | export default class NotImplementedDataLoader { 3 | constructor(config) { 4 | this.config = config; 5 | } 6 | 7 | async load() { 8 | console.log( 9 | `Data Loader not implemented for ${this.config.type} (${this.config.name})`, 10 | ); 11 | 12 | return null; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/data-loaders/RelationalDataLoader.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import { Client, types as pgTypes } from 'pg'; 3 | import mysql from 'mysql2/promise'; 4 | import { Types } from 'mysql2'; 5 | const FLAGS = { 6 | NOT_NULL: 1, 7 | PRI_KEY: 2, 8 | UNIQUE_KEY: 4, 9 | MULTIPLE_KEY: 8, 10 | BLOB: 16, 11 | UNSIGNED: 32, 12 | ZEROFILL: 64, 13 | BINARY: 128, 14 | ENUM: 256, 15 | AUTO_INCREMENT: 512, 16 | TIMESTAMP: 1024, 17 | SET: 2048, 18 | NO_DEFAULT_VALUE: 4096, 19 | ON_UPDATE_NOW: 8192, 20 | NUM: 32768, 21 | }; 22 | 23 | const decToBin = (dec) => parseInt((dec >>> 0).toString(2), 2); 24 | 25 | const convertMySQLResponseToColumnMetaData = (rows) => { 26 | return rows.map((row) => { 27 | // @TODO: Add for the following fields 28 | // arrayBaseColumnType, 29 | // isCaseSensitive, 30 | // isCurrency, 31 | // currency, 32 | // precision, 33 | // scale, 34 | // schemaName, 35 | return { 36 | isAutoIncrement: 37 | decToBin(row.flags & FLAGS.AUTO_INCREMENT) === FLAGS.AUTO_INCREMENT, 38 | label: row.name, 39 | name: row.name, 40 | nullable: decToBin(row.flags && FLAGS.NOT_NULL) !== FLAGS.NOT_NULL, 41 | type: row.columnType, 42 | typeName: Object.keys(Types) 43 | .find((key) => Types[key] === row.columnType) 44 | .toUpperCase(), 45 | isSigned: decToBin(row.flags & FLAGS.UNSIGNED) !== FLAGS.UNSIGNED, 46 | autoIncrement: 47 | decToBin(row.flags & FLAGS.AUTO_INCREMENT) === FLAGS.AUTO_INCREMENT, 48 | tableName: row._buf 49 | .slice(row._tableStart, row._tableStart + row._tableLength) 50 | .toString(), 51 | }; 52 | }); 53 | }; 54 | const convertSQLResponseToRDSRecords = (rows) => { 55 | const records = []; 56 | 57 | rows.forEach((dbObject) => { 58 | const record = []; 59 | Object.keys(dbObject).forEach((key) => { 60 | record.push( 61 | dbObject[key] === null 62 | ? { isNull: true, null: true } 63 | : typeof dbObject[key] === 'string' 64 | ? { stringValue: dbObject[key] } 65 | : typeof dbObject[key] === 'number' 66 | ? { longValue: dbObject[key] } 67 | : { stringValue: dbObject[key] }, 68 | ); 69 | }); 70 | records.push(record); 71 | }); 72 | return records; 73 | }; 74 | 75 | const convertPostgresSQLResponseToColumnMetaData = (rows) => { 76 | return rows.map((row) => { 77 | const typeName = 78 | Object.keys(pgTypes.builtins).find( 79 | (d) => pgTypes.builtins[d] === row.dataTypeID, 80 | ) ?? 'UNKNOWN'; 81 | // @TODO: Add support for the following fields 82 | // isAutoIncrement, 83 | // nullable, 84 | // isSigned, 85 | // autoIncrement, 86 | // tableName, 87 | // arrayBaseColumnType, 88 | // isCaseSensitive, 89 | // isCurrency, 90 | // currency, 91 | // precision, 92 | // scale, 93 | // schemaName, 94 | return { 95 | label: row.name, 96 | name: row.name, 97 | type: row.dataTypeID, 98 | typeName, 99 | }; 100 | }); 101 | }; 102 | 103 | const injectVariables = (statement, req) => { 104 | const { variableMap } = req; 105 | if (!variableMap) { 106 | return statement; 107 | } 108 | const result = Object.keys(variableMap).reduce((statmnt, key) => { 109 | // Adds 'g' for replaceAll effect 110 | var re = new RegExp(key, 'g'); 111 | if (variableMap[key] === null || typeof variableMap[key] == 'boolean') { 112 | return statmnt.replace(re, `${variableMap[key]}`); 113 | } 114 | // @TODO: Differentiate number from string inputs... 115 | return statmnt.replace(re, `'${variableMap[key]}'`); 116 | }, statement); 117 | return result; 118 | }; 119 | 120 | const executeSqlStatements = async (client, req) => 121 | Promise.mapSeries(req.statements, async (statement) => { 122 | statement = injectVariables(statement, req); 123 | try { 124 | const result = await client.query(statement); 125 | return result; 126 | } catch (error) { 127 | console.log(`RDS_DATALOADER: Failed to execute: `, statement, error); 128 | throw error; 129 | } 130 | }); 131 | 132 | export default class RelationalDataLoader { 133 | constructor(config) { 134 | this.config = config; 135 | this.client = null; 136 | } 137 | 138 | async getClient() { 139 | if (this.client) { 140 | return this.client; 141 | } 142 | 143 | const requiredKeys = [ 144 | 'dbDialect', 145 | 'dbUsername', 146 | 'dbPassword', 147 | 'dbHost', 148 | 'dbName', 149 | 'dbPort', 150 | ]; 151 | if (!this.config.rds) { 152 | throw new Error('RDS configuration not passed'); 153 | } 154 | const missingKey = requiredKeys.find((key) => { 155 | return !this.config.rds[key]; 156 | }); 157 | if (missingKey) { 158 | throw new Error(`${missingKey} is required.`); 159 | } 160 | 161 | const dbConfig = { 162 | host: this.config.rds.dbHost, 163 | user: this.config.rds.dbUsername, 164 | password: this.config.rds.dbPassword, 165 | database: this.config.rds.dbName, 166 | port: this.config.rds.dbPort, 167 | }; 168 | const res = {}; 169 | if (this.config.rds.dbDialect === 'mysql') { 170 | this.client = await mysql.createConnection(dbConfig); 171 | } else if (this.config.rds.dbDialect === 'postgres') { 172 | this.client = new Client(dbConfig); 173 | await this.client.connect(); 174 | } 175 | return this.client; 176 | } 177 | 178 | async load(req) { 179 | try { 180 | const client = await this.getClient(); 181 | const res = {}; 182 | const results = await executeSqlStatements(client, req); 183 | if (this.config.rds.dbDialect === 'mysql') { 184 | res.sqlStatementResults = results.map((result) => { 185 | if (result.length < 2) { 186 | return {}; 187 | } 188 | if (!result[1]) { 189 | // not a select query 190 | return { 191 | numberOfRecordsUpdated: result[0].affectedRows, 192 | generatedFields: [], 193 | }; 194 | } 195 | return { 196 | numberOfRecordsUpdated: result[0].length, 197 | records: convertSQLResponseToRDSRecords(result[0]), 198 | columnMetadata: convertMySQLResponseToColumnMetaData(result[1]), 199 | }; 200 | }); 201 | } else if (this.config.rds.dbDialect === 'postgres') { 202 | res.sqlStatementResults = results.map((result) => { 203 | return { 204 | numberOfRecordsUpdated: result.rowCount, 205 | records: convertSQLResponseToRDSRecords(result.rows), 206 | columnMetadata: convertPostgresSQLResponseToColumnMetaData( 207 | result.fields, 208 | ), 209 | generatedFields: [], 210 | }; 211 | }); 212 | } 213 | return JSON.stringify(res); 214 | } catch (e) { 215 | console.log(e); 216 | return e; 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/getAppSyncConfig.js: -------------------------------------------------------------------------------- 1 | import { AmplifyAppSyncSimulatorAuthenticationType as AuthTypes } from 'amplify-appsync-simulator'; 2 | import axios from 'axios'; 3 | import fs from 'fs'; 4 | import { forEach, isNil, first } from 'lodash'; 5 | import path from 'path'; 6 | import { mergeTypeDefs } from '@graphql-tools/merge'; 7 | import * as globby from 'globby'; 8 | import directLambdaRequest from './templates/direct-lambda.request.vtl'; 9 | import directLambdaResponse from './templates/direct-lambda.response.vtl'; 10 | import { 11 | DEFAULT_MAPPING_TEMPLATE_LOCATION, 12 | DEFAULT_ENCODING, 13 | DEFAULT_SCHEMA_FILE, 14 | DEFAULT_HTTP_METHOD, 15 | DEFAULT_RESOLVER_TYPE, 16 | HTTPMessage, 17 | MappingTemplateType, 18 | SourceType, 19 | } from './constants'; 20 | 21 | const directLambdaMappingTemplates = { 22 | request: directLambdaRequest, 23 | response: directLambdaResponse, 24 | }; 25 | 26 | export default function getAppSyncConfig(context, appSyncConfig) { 27 | // Flattening params 28 | const cfg = { 29 | ...appSyncConfig, 30 | mappingTemplates: (appSyncConfig.mappingTemplates || []).flat(), 31 | functionConfigurations: (appSyncConfig.functionConfigurations || []).flat(), 32 | dataSources: (appSyncConfig.dataSources || []).flat(), 33 | }; 34 | 35 | const mappingTemplatesLocation = path.join( 36 | context.serverless.config.servicePath, 37 | cfg.mappingTemplatesLocation || DEFAULT_MAPPING_TEMPLATE_LOCATION, 38 | ); 39 | 40 | const functionConfigurationsLocation = path.join( 41 | context.serverless.config.servicePath, 42 | cfg.functionConfigurationsLocation || 43 | cfg.mappingTemplatesLocation || 44 | DEFAULT_MAPPING_TEMPLATE_LOCATION, 45 | ); 46 | 47 | const { defaultMappingTemplates = {} } = cfg; 48 | 49 | const getMappingTemplate = (filePath, type) => { 50 | switch (type) { 51 | case MappingTemplateType.MAPPING_TEMPLATE: 52 | return fs.readFileSync(path.join(mappingTemplatesLocation, filePath), { 53 | encoding: DEFAULT_ENCODING, 54 | }); 55 | case MappingTemplateType.FUNCTION_CONFIGURATION: 56 | return fs.readFileSync( 57 | path.join(functionConfigurationsLocation, filePath), 58 | { 59 | encoding: DEFAULT_ENCODING, 60 | }, 61 | ); 62 | default: 63 | return null; 64 | } 65 | }; 66 | 67 | const toAbsolutePosixPath = (basePath, filePath) => 68 | (path.isAbsolute(filePath) 69 | ? filePath 70 | : path.join(basePath, filePath) 71 | ).replace(/\\/g, '/'); 72 | 73 | const globFilePaths = (basePath, filePaths) => { 74 | return filePaths 75 | .map((filePath) => { 76 | const paths = globby.sync(toAbsolutePosixPath(basePath, filePath)); 77 | if (path.isAbsolute(filePath)) { 78 | return paths; 79 | } else { 80 | // For backward compatibility with FileMap, revert to relative path 81 | return paths.map((p) => path.relative(basePath, p)); 82 | } 83 | }) 84 | .flat(); 85 | }; 86 | 87 | const getFileMap = (basePath, filePath) => ({ 88 | path: filePath, 89 | content: fs.readFileSync(toAbsolutePosixPath(basePath, filePath), { 90 | encoding: DEFAULT_ENCODING, 91 | }), 92 | }); 93 | 94 | const makeDataSource = (source) => { 95 | if (source.name === undefined || source.type === undefined) { 96 | return null; 97 | } 98 | 99 | const dataSource = { 100 | name: source.name, 101 | type: source.type, 102 | }; 103 | 104 | switch (source.type) { 105 | case SourceType.AMAZON_DYNAMODB: { 106 | return { 107 | ...dataSource, 108 | config: { 109 | ...context.options.dynamoDb, 110 | tableName: source.config.tableName, 111 | }, 112 | }; 113 | } 114 | case SourceType.RELATIONAL_DATABASE: { 115 | return { 116 | ...dataSource, 117 | rds: context.options.rds, 118 | }; 119 | } 120 | case SourceType.AWS_LAMBDA: { 121 | const { functionName } = source.config; 122 | if (functionName === undefined) { 123 | context.plugin.log(`${source.name} does not have a functionName`, { 124 | color: 'orange', 125 | }); 126 | return null; 127 | } 128 | 129 | const conf = context.options; 130 | const func = 131 | conf.functions?.[functionName] || 132 | context.serverless.service.functions?.[functionName]; 133 | 134 | if (func === undefined) { 135 | context.plugin.log(`The ${functionName} function is not defined`, { 136 | color: 'orange', 137 | }); 138 | return null; 139 | } 140 | 141 | let url, method; 142 | if (func.url) { 143 | url = func.url; 144 | method = func.method; 145 | } else { 146 | url = `http://localhost:${context.options.lambdaPort}/2015-03-31/functions/${func.name}/invocations`; 147 | } 148 | return { 149 | ...dataSource, 150 | invoke: async (payload) => { 151 | const result = await axios.request({ 152 | url, 153 | method: method || DEFAULT_HTTP_METHOD, 154 | data: payload, 155 | headers: payload?.request?.headers, 156 | validateStatus: false, 157 | }); 158 | // When the Lambda returns an error, the status code is 200 OK. 159 | // The presence of an error is indicated by a header in the response. 160 | // 400 and 500-series status codes are reserved for invocation errors: 161 | // https://docs.aws.amazon.com/lambda/latest/dg/API_Invoke.html#API_Invoke_Errors 162 | if (result.status === 200) { 163 | const errorType = 164 | result.headers['x-amz-function-error'] || 165 | result.headers['X-Amz-Function-Error'] || 166 | result.headers['x-amzn-errortype'] || 167 | result.headers['x-amzn-ErrorType']; 168 | if (errorType) { 169 | throw { 170 | type: `Lambda:${errorType}`, 171 | message: result.data.errorMessage, 172 | }; 173 | } 174 | // If the result of a lambda function is null or undefined, it returns as an empty string via HTTP. 175 | // Then, AppSync handles an empty string of the response as null (or undefined). 176 | if (result.data === '') { 177 | return null; 178 | } 179 | return result.data; 180 | } else { 181 | throw new Error( 182 | `Request failed with status code ${result.status}`, 183 | ); 184 | } 185 | }, 186 | }; 187 | } 188 | case SourceType.AMAZON_ELASTICSEARCH: 189 | return { 190 | ...context.options.openSearch, 191 | ...dataSource, 192 | endpoint: source.config.endpoint, 193 | }; 194 | case SourceType.HTTP: { 195 | return { 196 | ...dataSource, 197 | endpoint: source.config.endpoint, 198 | }; 199 | } 200 | default: 201 | return dataSource; 202 | } 203 | }; 204 | 205 | const makeMappingTemplate = (resolver, type, templateType) => { 206 | const { name, type: parent, field, substitutions = {} } = resolver; 207 | 208 | const defaultTemplatePrefix = name || `${parent}.${field}`; 209 | const templatePath = !isNil(resolver?.[type]) 210 | ? resolver?.[type] 211 | : !isNil(defaultMappingTemplates?.[type]) 212 | ? defaultMappingTemplates?.[type] 213 | : `${defaultTemplatePrefix}.${type}.vtl`; 214 | 215 | let mappingTemplate; 216 | // Direct lambda 217 | // For direct lambdas, we use a default mapping template 218 | // See https://amzn.to/3ncV3Dz 219 | if (templatePath === false) { 220 | mappingTemplate = directLambdaMappingTemplates[type]; 221 | } else { 222 | mappingTemplate = getMappingTemplate(templatePath, templateType); 223 | // Substitutions 224 | const allSubstitutions = { ...cfg.substitutions, ...substitutions }; 225 | forEach(allSubstitutions, (value, variable) => { 226 | const regExp = new RegExp(`\\$\{?${variable}}?`, 'g'); 227 | mappingTemplate = mappingTemplate.replace(regExp, value); 228 | }); 229 | } 230 | 231 | return mappingTemplate; 232 | }; 233 | 234 | const makeResolver = (resolver) => { 235 | let templateType = MappingTemplateType.MAPPING_TEMPLATE; 236 | return { 237 | kind: resolver.kind || DEFAULT_RESOLVER_TYPE, 238 | fieldName: resolver.field, 239 | typeName: resolver.type, 240 | dataSourceName: resolver.dataSource, 241 | functions: resolver.functions, 242 | requestMappingTemplate: makeMappingTemplate( 243 | resolver, 244 | HTTPMessage.REQUEST, 245 | templateType, 246 | ), 247 | responseMappingTemplate: makeMappingTemplate( 248 | resolver, 249 | HTTPMessage.RESPONSE, 250 | templateType, 251 | ), 252 | }; 253 | }; 254 | 255 | const makeFunctionConfiguration = (config) => { 256 | let templateType = MappingTemplateType.FUNCTION_CONFIGURATION; 257 | return { 258 | dataSourceName: config.dataSource, 259 | name: config.name, 260 | requestMappingTemplate: makeMappingTemplate( 261 | config, 262 | HTTPMessage.REQUEST, 263 | templateType, 264 | ), 265 | responseMappingTemplate: makeMappingTemplate( 266 | config, 267 | HTTPMessage.RESPONSE, 268 | templateType, 269 | ), 270 | }; 271 | }; 272 | 273 | const makeAuthType = (authType) => { 274 | const auth = { 275 | authenticationType: authType.authenticationType, 276 | }; 277 | 278 | if (auth.authenticationType === AuthTypes.AMAZON_COGNITO_USER_POOLS) { 279 | auth.cognitoUserPoolConfig = { 280 | AppIdClientRegex: authType.userPoolConfig.appIdClientRegex, 281 | }; 282 | } else if (auth.authenticationType === AuthTypes.OPENID_CONNECT) { 283 | auth.openIDConnectConfig = { 284 | Issuer: authType.openIdConnectConfig.issuer, 285 | ClientId: authType.openIdConnectConfig.clientId, 286 | }; 287 | } 288 | 289 | return auth; 290 | }; 291 | 292 | const makeAppSync = (config) => ({ 293 | name: config.name, 294 | apiKey: context.options.apiKey, 295 | defaultAuthenticationType: makeAuthType(config), 296 | additionalAuthenticationProviders: ( 297 | config.additionalAuthenticationProviders || [] 298 | ).map(makeAuthType), 299 | }); 300 | 301 | // Load the schema. If multiple provided, merge them 302 | const schemaPaths = Array.isArray(cfg.schema) 303 | ? cfg.schema 304 | : [cfg.schema || DEFAULT_SCHEMA_FILE]; 305 | const basePath = context.serverless.config.servicePath; 306 | const schemas = globFilePaths(basePath, schemaPaths).map((schemaPath) => 307 | getFileMap(basePath, schemaPath), 308 | ); 309 | const schema = { 310 | path: first(schemas).path, 311 | content: mergeTypeDefs( 312 | schemas.map((s) => s.content), 313 | { 314 | useSchemaDefinition: true, 315 | forceSchemaDefinition: true, 316 | throwOnConflict: true, 317 | commentDescriptions: true, 318 | reverseDirectives: true, 319 | }, 320 | ), 321 | }; 322 | 323 | return { 324 | appSync: makeAppSync(cfg), 325 | schema, 326 | resolvers: cfg.mappingTemplates.map(makeResolver), 327 | dataSources: cfg.dataSources.map(makeDataSource).filter((v) => v !== null), 328 | functions: cfg.functionConfigurations.map(makeFunctionConfiguration), 329 | }; 330 | } 331 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | AmplifyAppSyncSimulator, 3 | addDataLoader, 4 | } from 'amplify-appsync-simulator'; 5 | import { inspect } from 'util'; 6 | import { defaults, get, merge, reduce } from 'lodash'; 7 | import NodeEvaluator from 'cfn-resolver-lib'; 8 | import getAppSyncConfig from './getAppSyncConfig'; 9 | import NotImplementedDataLoader from './data-loaders/NotImplementedDataLoader'; 10 | import ElasticDataLoader from './data-loaders/ElasticDataLoader'; 11 | import HttpDataLoader from './data-loaders/HttpDataLoader'; 12 | import RelationalDataLoader from './data-loaders/RelationalDataLoader'; 13 | import watchman from 'fb-watchman'; 14 | 15 | const resolverPathMap = { 16 | 'AWS::DynamoDB::Table': 'Properties.TableName', 17 | 'AWS::S3::Bucket': 'Properties.BucketName', 18 | }; 19 | 20 | class ServerlessAppSyncSimulator { 21 | constructor(serverless) { 22 | this.serverless = serverless; 23 | this.options = null; 24 | this.log = this.log.bind(this); 25 | this.debugLog = this.debugLog.bind(this); 26 | 27 | this.simulators = null; 28 | 29 | addDataLoader('HTTP', HttpDataLoader); 30 | addDataLoader('AMAZON_ELASTICSEARCH', ElasticDataLoader); 31 | addDataLoader('RELATIONAL_DATABASE', RelationalDataLoader); 32 | 33 | this.hooks = { 34 | 'before:offline:start:init': this.startServers.bind(this), 35 | 'before:offline:start:end': this.endServers.bind(this), 36 | }; 37 | } 38 | 39 | log(message, opts = {}) { 40 | return this.serverless.cli.log(message, 'AppSync Simulator', opts); 41 | } 42 | 43 | debugLog(message, opts = {}) { 44 | if (process.env.SLS_DEBUG) { 45 | this.log(message, opts); 46 | } 47 | } 48 | 49 | getLambdaPort(context) { 50 | // Default serverless-offline lambdaPort is 3002 51 | let port = 3002; 52 | const offlineConfig = context.service.custom['serverless-offline']; 53 | // Check if the user has defined a specific port as part of their serverless.yml 54 | if (offlineConfig != undefined && offlineConfig.lambdaPort != undefined) { 55 | port = offlineConfig.lambdaPort; 56 | } 57 | // Check to see if a port override was specified as part of the CLI arguments 58 | const cliOptions = context.processedInput.options; 59 | if (cliOptions != undefined && cliOptions.lambdaPort != undefined) { 60 | port = cliOptions.lambdaPort; 61 | } 62 | 63 | return port; 64 | } 65 | 66 | async startServers() { 67 | try { 68 | this.buildResolvedOptions(); 69 | this.buildResourceResolvers(); 70 | this.serverless.service.functions = this.resolveResources( 71 | this.serverless.service.functions, 72 | ); 73 | this.serverless.service.provider.environment = this.resolveResources( 74 | this.serverless.service.provider.environment, 75 | ); 76 | this.serverless.service.custom.appSync = this.resolveResources( 77 | this.serverless.service.custom.appSync, 78 | ); 79 | 80 | this.simulators = []; 81 | if (Array.isArray(this.serverless.service.custom.appSync)) { 82 | let port = this.options.port; 83 | let wsPort = this.options.wsPort; 84 | for (let appSyncConfig of this.serverless.service.custom.appSync) { 85 | this.simulators.push({ 86 | amplifySimulator: await this.startIndividualServer(port, wsPort), 87 | name: appSyncConfig.name, 88 | }); 89 | port += 10; 90 | wsPort += 10; 91 | } 92 | } else { 93 | this.simulators.push({ 94 | amplifySimulator: await this.startIndividualServer( 95 | this.options.port, 96 | this.options.wsPort, 97 | ), 98 | name: this.serverless.service.custom.appSync.name, 99 | }); 100 | } 101 | 102 | this.options.lambdaPort = this.getLambdaPort(this.serverless); 103 | 104 | if (Array.isArray(this.options.watch) && this.options.watch.length > 0) { 105 | this.watch(); 106 | } else { 107 | this.initServers(); 108 | } 109 | 110 | for (let sim of this.simulators) { 111 | this.log( 112 | `${sim.name} AppSync endpoint: ${sim.amplifySimulator.url}/graphql`, 113 | ); 114 | this.log(`${sim.name} GraphiQl: ${sim.amplifySimulator.url}`); 115 | } 116 | } catch (error) { 117 | this.log(error, { color: 'red' }); 118 | } 119 | } 120 | 121 | async startIndividualServer(port, wsPort) { 122 | const simulator = new AmplifyAppSyncSimulator({ 123 | port: port, 124 | wsPort: wsPort, 125 | }); 126 | await simulator.start(); 127 | 128 | return simulator; 129 | } 130 | 131 | initServers() { 132 | const appSyncConfig = Array.isArray(this.serverless.service.custom.appSync) 133 | ? this.serverless.service.custom.appSync 134 | : [this.serverless.service.custom.appSync]; 135 | 136 | for (let [i, sim] of this.simulators.entries()) { 137 | this.initIndividualServer(sim, appSyncConfig[i]); 138 | } 139 | } 140 | 141 | initIndividualServer(simulator, appSyncConfig) { 142 | const config = getAppSyncConfig( 143 | { 144 | plugin: this, 145 | serverless: this.serverless, 146 | options: this.options, 147 | }, 148 | appSyncConfig, 149 | ); 150 | 151 | this.debugLog(`AppSync Config ${appSyncConfig.name}`); 152 | this.debugLog(inspect(config, { depth: 4, colors: true })); 153 | 154 | simulator.amplifySimulator.init(config); 155 | } 156 | 157 | watch() { 158 | const client = new watchman.Client(); 159 | const path = this.serverless.config.servicePath; 160 | 161 | // Try to watch for changes in AppSync configuration 162 | client.command(['watch-project', path], (error, resp) => { 163 | if (error) { 164 | console.error('Error initiating watch:', error); 165 | console.log('AppSync Simulator hot-reloading will not be available'); 166 | // init server once 167 | this.initServers(); 168 | return; 169 | } 170 | 171 | if ('warning' in resp) { 172 | console.log('warning: ', resp.warning); 173 | } 174 | 175 | // Watch for changes in vtl and schema files. 176 | const sub = { 177 | expression: [ 178 | 'anyof', 179 | ...this.options.watch.map((glob) => { 180 | if (Array.isArray(glob)) { 181 | return glob; 182 | } 183 | return ['match', glob]; 184 | }), 185 | ], 186 | fields: ['name'], 187 | since: resp.clock, 188 | }; 189 | 190 | const { watch, relative_path } = resp; 191 | if (relative_path) { 192 | sub.relative_root = relative_path; 193 | } 194 | 195 | // init subscription 196 | client.command( 197 | ['subscribe', watch, 'appsync-simulator', sub], 198 | (error) => { 199 | if (error) { 200 | console.error('Failed to subscribe: ', error); 201 | return; 202 | } 203 | }, 204 | ); 205 | }); 206 | 207 | client.on('subscription', async (resp) => { 208 | if (resp.subscription === 'appsync-simulator') { 209 | console.log('Hot-reloading AppSync simulator...'); 210 | this.initServers(); 211 | } 212 | }); 213 | } 214 | 215 | endServers() { 216 | if (this.simulators) { 217 | this.log('Halting AppSync Simulator'); 218 | for (let sim of this.simulators) { 219 | sim.amplifySimulator.stop(); 220 | } 221 | } 222 | } 223 | 224 | buildResourceResolvers() { 225 | const refResolvers = reduce( 226 | get(this.serverless.service, 'resources.Resources', {}), 227 | (acc, res, name) => { 228 | const path = resolverPathMap[res.Type]; 229 | if (path !== undefined) { 230 | return { ...acc, [name]: get(res, path, null) }; 231 | } 232 | 233 | return acc; 234 | }, 235 | {}, 236 | ); 237 | 238 | const keyValueArrayToObject = (mapping) => { 239 | if (Array.isArray(mapping)) { 240 | return mapping.reduce( 241 | (acc, { key, value }) => ({ ...acc, [key]: value }), 242 | {}, 243 | ); 244 | } 245 | return mapping; 246 | }; 247 | 248 | this.resourceResolvers = { 249 | RefResolvers: { 250 | ...refResolvers, 251 | ...keyValueArrayToObject(this.options.refMap), 252 | // Add region for cfn-resolver-lib GetAZs 253 | 'AWS::Region': this.serverless.service.provider.region, 254 | }, 255 | 'Fn::GetAttResolvers': keyValueArrayToObject(this.options.getAttMap), 256 | 'Fn::ImportValueResolvers': keyValueArrayToObject( 257 | this.options.importValueMap, 258 | ), 259 | }; 260 | } 261 | 262 | buildResolvedOptions() { 263 | this.options = merge( 264 | { 265 | apiKey: '0123456789', 266 | port: 20002, 267 | wsPort: 20003, 268 | location: '.', 269 | refMap: {}, 270 | getAttMap: {}, 271 | importValueMap: {}, 272 | rds: {}, 273 | dynamoDb: { 274 | endpoint: `http://localhost:${get( 275 | this.serverless.service, 276 | 'custom.dynamodb.start.port', 277 | 8000, 278 | )}`, 279 | region: 'localhost', 280 | accessKeyId: 'DEFAULT_ACCESS_KEY', 281 | secretAccessKey: 'DEFAULT_SECRET', 282 | }, 283 | openSearch: {}, 284 | }, 285 | get(this.serverless.service, 'custom.appsync-simulator', {}), 286 | ); 287 | 288 | this.options = defaults(this.options, { 289 | watch: ['*.graphql', '*.vtl'], 290 | }); 291 | } 292 | 293 | /** 294 | * Resolves resourses through `Ref:` or `Fn:GetAtt` 295 | */ 296 | resolveResources(toBeResolved) { 297 | // Pass all resources to allow Fn::GetAtt and Conditions resolution 298 | const node = { 299 | ...this.serverless.service.resources, 300 | toBeResolved, 301 | }; 302 | const evaluator = new NodeEvaluator(node, this.resourceResolvers); 303 | const result = evaluator.evaluateNodes(); 304 | if (result && result.toBeResolved) { 305 | return result.toBeResolved; 306 | } 307 | 308 | return toBeResolved; 309 | } 310 | } 311 | 312 | module.exports = ServerlessAppSyncSimulator; 313 | -------------------------------------------------------------------------------- /src/templates/direct-lambda.request.vtl: -------------------------------------------------------------------------------- 1 | ## Direct lambda request 2 | { 3 | "version": "2018-05-29", 4 | "operation": "Invoke", 5 | "payload": $utils.toJson($context) 6 | } 7 | -------------------------------------------------------------------------------- /src/templates/direct-lambda.response.vtl: -------------------------------------------------------------------------------- 1 | ## Direct lambda response 2 | #if($ctx.error) 3 | $util.error($ctx.error.message, $ctx.error.type, $ctx.result) 4 | #end 5 | $util.toJson($ctx.result) 6 | --------------------------------------------------------------------------------