├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── jest-dynalite-config.json ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── DynamoDBCache.ts ├── DynamoDBDataSource.ts ├── __tests__ │ ├── DynamoDBCache.spec.ts │ ├── DynamoDBDataSource.spec.ts │ ├── DynamoDBDataSourceWithClient.spec.ts │ └── utils.spec.ts ├── index.ts ├── types.ts └── utils.ts ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/typescript"], 3 | "plugins": [ 4 | "@babel/proposal-class-properties", 5 | "@babel/proposal-object-rest-spread", 6 | "@babel/plugin-transform-regenerator", 7 | "@babel/plugin-transform-runtime", 8 | "@babel/plugin-transform-destructuring", 9 | [ 10 | "babel-plugin-transform-builtin-extend", 11 | { 12 | "globals": ["Error", "Array"] 13 | } 14 | ] 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | extends: ['prettier', 'plugin:@typescript-eslint/recommended'], 5 | env: { 6 | es6: true, 7 | node: true, 8 | browser: true, 9 | 'jest/globals': true, 10 | }, 11 | plugins: ['jest', 'prettier'], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | project: path.resolve(__dirname, './tsconfig.json'), 15 | tsconfigRootDir: __dirname, 16 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 17 | sourceType: 'module', // Allows for the use of imports 18 | }, 19 | rules: { 20 | 'comma-dangle': 0, 21 | 'no-cond-assign': 2, 22 | 'no-console': 0, 23 | 'no-constant-condition': 2, 24 | 'no-control-regex': 2, 25 | 'no-debugger': 2, 26 | 'no-dupe-args': 2, 27 | 'no-dupe-keys': 2, 28 | 'no-duplicate-case': 2, 29 | 'no-empty-character-class': 2, 30 | 'no-empty': 2, 31 | 'no-ex-assign': 2, 32 | 'no-extra-boolean-cast': 2, 33 | 'no-extra-parens': 0, 34 | 'no-extra-semi': 2, 35 | 'no-func-assign': 2, 36 | 'no-inner-declarations': 2, 37 | 'no-invalid-regexp': 2, 38 | 'no-irregular-whitespace': 2, 39 | 'no-negated-in-lhs': 2, 40 | 'no-obj-calls': 2, 41 | 'no-regex-spaces': 2, 42 | 'no-sparse-arrays': 2, 43 | 'no-unexpected-multiline': 2, 44 | 'no-unreachable': 2, 45 | 'use-isnan': 2, 46 | 'valid-jsdoc': 0, 47 | 'valid-typeof': 2, 48 | 49 | 'accessor-pairs': 0, 50 | 'block-scoped-var': 2, 51 | complexity: 0, 52 | 'consistent-return': 2, 53 | curly: [2, 'all'], 54 | 'default-case': 2, 55 | 'dot-notation': 2, 56 | 'dot-location': [2, 'property'], 57 | eqeqeq: [2, 'always', { null: 'ignore' }], 58 | 'guard-for-in': 0, 59 | 'no-alert': 2, 60 | 'no-caller': 2, 61 | 'no-div-regex': 2, 62 | 'no-else-return': 2, 63 | 'no-eq-null': 0, 64 | 'no-eval': 2, 65 | 'no-extend-native': 2, 66 | 'no-extra-bind': 2, 67 | 'no-fallthrough': 2, 68 | 'no-floating-decimal': 2, 69 | 'no-implicit-coercion': 2, 70 | 'no-implied-eval': 2, 71 | 'no-invalid-this': 0, 72 | 'no-iterator': 2, 73 | 'no-labels': 2, 74 | 'no-lone-blocks': 2, 75 | 'no-loop-func': 2, 76 | 'no-multi-spaces': 2, 77 | 'no-multi-str': 2, 78 | 'no-native-reassign': 2, 79 | 'no-new-func': 2, 80 | 'no-new-wrappers': 2, 81 | 'no-new': 2, 82 | 'no-octal-escape': 2, 83 | 'no-octal': 2, 84 | 'no-param-reassign': 2, 85 | 'no-process-env': 0, 86 | 'no-proto': 2, 87 | 'no-redeclare': 2, 88 | 'no-return-assign': 2, 89 | 'no-script-url': 2, 90 | 'no-self-compare': 2, 91 | 'no-sequences': 2, 92 | 'no-throw-literal': 2, 93 | 'no-unused-expressions': 2, 94 | 'no-useless-call': 2, 95 | 'no-void': 2, 96 | 'no-warning-comments': 2, 97 | 'no-with': 2, 98 | radix: 2, 99 | 'vars-on-top': 2, 100 | 'wrap-iife': 2, 101 | yoda: 2, 102 | 103 | strict: [2, 'never'], 104 | 105 | 'init-declarations': 0, 106 | 'no-catch-shadow': 0, 107 | 'no-delete-var': 2, 108 | 'no-label-var': 2, 109 | 'no-shadow-restricted-names': 2, 110 | 'no-shadow': 2, 111 | 'no-undef-init': 2, 112 | 'no-undef': 2, 113 | 'no-undefined': 0, 114 | 'no-unused-vars': 0, 115 | 'no-use-before-define': 2, 116 | 117 | 'callback-return': 0, 118 | 'handle-callback-err': 2, 119 | 'no-mixed-requires': 0, 120 | 'no-new-require': 2, 121 | 'no-path-concat': 2, 122 | 'no-process-exit': 2, 123 | 'no-restricted-modules': 2, 124 | 'no-restricted-globals': [2, 'event'], 125 | 'no-sync': 2, 126 | 127 | 'block-spacing': [2, 'always'], 128 | 'brace-style': [2, '1tbs'], 129 | 'comma-spacing': [2, { before: false, after: true }], 130 | 'comma-style': [2, 'last'], 131 | 'eol-last': 2, 132 | 'func-style': 0, // expressions vs declrations? 133 | indent: 0, 134 | 'key-spacing': [2, { beforeColon: false, afterColon: true }], 135 | 'keyword-spacing': 2, 136 | 'linebreak-style': [2, 'unix'], 137 | 'new-cap': 2, 138 | 'new-parens': 2, 139 | 'no-lonely-if': 2, 140 | 'no-mixed-spaces-and-tabs': 2, 141 | 'no-multiple-empty-lines': [2, { max: 1 }], 142 | 'no-nested-ternary': 2, 143 | 'no-spaced-func': 2, 144 | 'no-trailing-spaces': 2, 145 | 'no-unneeded-ternary': 2, 146 | 'object-curly-spacing': [2, 'always'], 147 | 'operator-linebreak': 0, 148 | 'padded-blocks': [2, 'never'], 149 | quotes: [2, 'single'], 150 | 'semi-spacing': [2, { before: false, after: true }], 151 | semi: [2, 'always'], 152 | 'space-before-blocks': [2, 'always'], 153 | 'space-before-function-paren': [ 154 | 'error', 155 | { 156 | anonymous: 'never', 157 | named: 'never', 158 | asyncArrow: 'always', 159 | }, 160 | ], 161 | 'space-in-parens': [2, 'never'], 162 | 'space-infix-ops': 2, 163 | 'spaced-comment': [2, 'always'], 164 | 165 | 'arrow-parens': [2, 'as-needed'], 166 | 'arrow-spacing': [2, { before: true, after: true }], 167 | 'constructor-super': 2, 168 | 'generator-star-spacing': [2, { before: false, after: true }], 169 | 'no-class-assign': 2, 170 | 'no-const-assign': 2, 171 | 'no-dupe-class-members': 2, 172 | 'no-this-before-super': 2, 173 | 'no-var': 2, 174 | 'object-shorthand': 2, 175 | 'prefer-arrow-callback': 0, // enable with babel-plugin-closure-elimination for optimization 176 | 'prefer-const': 2, 177 | 'prefer-spread': 2, 178 | 'prefer-reflect': 0, 179 | 'prefer-template': 2, 180 | 'require-yield': 2, 181 | 'jest/no-disabled-tests': 'warn', 182 | 'jest/no-focused-tests': 'error', 183 | 'jest/no-identical-title': 'error', 184 | 'jest/valid-expect': 'error', 185 | 'prettier/prettier': [ 186 | 'error', 187 | { 188 | singleQuote: true, 189 | }, 190 | ], 191 | }, 192 | }; 193 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | lib-esm/ 4 | .vscode/ 5 | coverage/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | **/tsconfig.json 3 | **/webpack.config.js 4 | node_modules 5 | src -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 The GraphQL Guide 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apollo DynamoDB Data Source 2 | 3 | This package exports a ([`DynamoDBDataSource`](https://github.com/cmwhited/apollo-datasource-dynamodb/blob/master/src/DynamoDBDataSource.ts)) class which is used for fetching data from a DynamoDB Table and exposing it via GraphQL within Apollo Server. 4 | 5 | ## Documentation 6 | 7 | View the [Apollo Server documentation for data sources](https://www.apollographql.com/docs/apollo-server/features/data-sources/) for more details. 8 | 9 | ## Usage 10 | 11 | To get started, install the `apollo-datasource-dynamodb` package 12 | 13 | ```bash 14 | # with npm 15 | npm install --save apollo-datasource-dynamodb 16 | # with yarn 17 | yarn add apollo-datasource-dynamodb 18 | ``` 19 | 20 | To define a data source, extend the `DynamoDBDataSource` class and pass in the name of the table, the Key schema, and the `ClientConfiguration` that allows the lib to connect to the `DynamoDB.DocumentClient` to interact with data in the table. Creating an instance of this class then allows you to utilize the API methods. 21 | 22 | ### Example 23 | 24 | Say you have a DynamoDB table called `test_data` which has a `Schema`: 25 | 26 | ```ts 27 | { 28 | TableName: 'test_hash_only', 29 | KeySchema: [{ AttributeName: 'id', KeyType: 'HASH' }], 30 | AttributeDefinitions: [{ AttributeName: 'id', AttributeType: 'S' }], 31 | ProvisionedThroughput: { 32 | ReadCapacityUnits: 1, 33 | WriteCapacityUnits: 1, 34 | }, 35 | } 36 | ``` 37 | 38 | Here is an example interface of the items that will be returned from this table: 39 | 40 | ```ts 41 | interface TestHashOnlyItem { 42 | id: string; 43 | test: string; 44 | } 45 | ``` 46 | 47 | To use the `DynamoDBDataSource` we create a class that subclasses this Data Source and can then implement API: 48 | 49 | ```ts 50 | // ./src/data-sources/test-hash-only.datasource.ts 51 | 52 | import { DynamoDBDataSource } from 'apollo-datasource-dynamodb'; 53 | import { DocumentClient } from 'aws-sdk/clients/dynamodb'; 54 | 55 | class TestHashOnly extends DynamoDBDataSource { 56 | private readonly tableName = 'test_hash_only'; 57 | private readonly tableKeySchema: DocumentClient.KeySchema = [ 58 | { 59 | AttributeName: 'id', 60 | KeyType: 'HASH', 61 | }, 62 | ]; 63 | private readonly ttl = 30 * 60; // 30minutes 64 | 65 | constructor(config?: ClientConfiguration) { 66 | super(this.tableName, this.tableKeySchema, config); 67 | } 68 | 69 | async getTestHashOnlyItem(id: string): Promise { 70 | const getItemInput: DocumentClient.GetItemInput = { 71 | TableName: this.tableName, 72 | ConsistentRead: true, 73 | Key: { id }, 74 | }; 75 | return this.getItem(getItemInput, this.ttl); 76 | } 77 | 78 | async scanForTestHashOnlyItems(): Promise { 79 | const scanInput: DocumentClient.ScanInput = { 80 | TableName: this.tableName, 81 | ConsistentRead: true, 82 | }; 83 | return this.scan(scanInput, this.ttl); 84 | } 85 | } 86 | ``` 87 | 88 | And then to utilize this instance as a data source in the `ApolloServer` instance: 89 | 90 | ```ts 91 | // ./src/server.ts 92 | 93 | import { ApolloServer } from 'apollo-server-lambda'; 94 | import { TestHashOnly } from './data-sources/test-hash-only.datasource'; 95 | 96 | const server = new ApolloServer({ 97 | typeDefs, 98 | resolvers, 99 | dataSources: () => ({ 100 | testHashOnly: new TestHashOnly(), 101 | }), 102 | }); 103 | ``` 104 | 105 | The to use the use the `TestHashOnly` data source in the resolvers: 106 | 107 | ```ts 108 | // ./src/schema.ts 109 | 110 | import { gql, IResolvers } from 'apollo-server-lambda'; 111 | import { DocumentNode } from 'graphql'; 112 | 113 | export const typeDefs: DocumentNode = gql` 114 | type TestHashOnlyItem { 115 | id: String! 116 | test: String! 117 | } 118 | 119 | type Query { 120 | getTestHashOnlyItem(id: String!): TestHashOnlyItem 121 | scanHashOnlyItems: [TestHashOnlyItem] 122 | } 123 | `; 124 | 125 | export const resolvers: IResolvers = { 126 | Query: { 127 | getTestHashOnlyItem: async (_source, { id }, { dataSources }) => dataSources.testHashOnly.getTestHashOnlyItem(id), 128 | scanHashOnlyItems: async (_source, _params, { dataSources }) => dataSources.testHashOnly.scanForTestHashOnlyItems(), 129 | }, 130 | }; 131 | ``` 132 | 133 | #### v1.1.0+ Example With Initialized Client 134 | 135 | As of `v1.1.0+`, another optional parameter was added to the `DynamoDBDataSource` class constructor that accepts an intialized `DynamoDB.DocumentClient` instance and uses this instance in the class instead of initializing a new one. 136 | 137 | Here is an example of how to use this param with a class that extends the `DynamoDBDataSource`: 138 | 139 | ```ts 140 | // ./src/data-sources/test-with-client.datasource.ts 141 | 142 | import { DynamoDBDataSource } from 'apollo-datasource-dynamodb'; 143 | import { DocumentClient } from 'aws-sdk/clients/dynamodb'; 144 | 145 | class TestWithClient extends DynamoDBDataSource { 146 | private readonly tableName = 'test_with_client'; 147 | private readonly tableKeySchema: DocumentClient.KeySchema = [ 148 | { 149 | AttributeName: 'id', 150 | KeyType: 'HASH', 151 | }, 152 | ]; 153 | private readonly ttl = 30 * 60; // 30minutes 154 | 155 | constructor(client: DocumentClient) { 156 | super(this.tableName, this.tableKeySchema, null, client); 157 | } 158 | 159 | async getTestHashOnlyItem(id: string): Promise { 160 | const getItemInput: DocumentClient.GetItemInput = { 161 | TableName: this.tableName, 162 | ConsistentRead: true, 163 | Key: { id }, 164 | }; 165 | return this.getItem(getItemInput, this.ttl); 166 | } 167 | 168 | async scanForTestHashOnlyItems(): Promise { 169 | const scanInput: DocumentClient.ScanInput = { 170 | TableName: this.tableName, 171 | ConsistentRead: true, 172 | }; 173 | return this.scan(scanInput, this.ttl); 174 | } 175 | } 176 | ``` 177 | 178 | And then to utilize this instance as a data source in the `ApolloServer` instance: 179 | 180 | ```ts 181 | // ./src/server.ts 182 | 183 | import { ApolloServer } from 'apollo-server-lambda'; 184 | import { DocumentClient } from 'aws-sdk/clients/dynamodb'; 185 | 186 | import { TestWithClient } from './data-sources/test-with-client.datasource'; 187 | 188 | const client: DocumentClient = new DocumentClient({ 189 | apiVersion: 'latest', 190 | region: 'us-east-1', 191 | }); 192 | 193 | const testWithClient = new TestWithClient(client); 194 | 195 | const server = new ApolloServer({ 196 | typeDefs, 197 | resolvers, 198 | dataSources: () => ({ 199 | testWithClient, 200 | }), 201 | }); 202 | ``` 203 | 204 | This paramater was added to allow for use of the library with already initialized `DynamoDB.DocumentClient` instances for use with dependency injection, etc. 205 | 206 | ## API 207 | 208 | ### getItem 209 | 210 | `this.getItem(getItemInput, 180)` 211 | 212 | Returns a single instance of the item being retrieved from the table by the key value. It checks the cache for the record, if the value is found in the cache, it returns the item, otherwise it uses the `DynamoDB.DocumentClient.get` method to retrieve the item from the table; if a record is found in the table, it is then added to the cache with the passed in `ttl`. 213 | 214 | [DynamoDB.DocumentClient.get](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#get-property) 215 | 216 | #### getItem Example 217 | 218 | ```ts 219 | const getItemInput: DocumentClient.GetItemInput = { 220 | TableName: 'test_hash_only', 221 | ConsistentRead: true, 222 | Key: { 223 | id: 'testId', 224 | }, 225 | }; 226 | const ttl = 30 * 60; // 30minutes 227 | 228 | const item: TestHashOnlyItem = await this.getItem(getItemInput, ttl); 229 | ``` 230 | 231 | ### query 232 | 233 | `this.query(queryInput, 180)` 234 | 235 | Returns all records from the table found by the query. If the `ttl` is provided, it adds all of the items to the cache. 236 | 237 | [DynamoDB.DocumentClient.query](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#query-property) 238 | 239 | #### query Example 240 | 241 | ```ts 242 | const queryInput: DynamoDB.DocumentClient.QueryInput = { 243 | TableName: 'test_hash_only', 244 | ConsistentRead: true, 245 | KeyConditionExpression: 'id = :id', 246 | ExpressionAttributeValues: { 247 | ':id': 'testId', 248 | }, 249 | }; 250 | const ttl = 30 * 60; // 30minutes 251 | 252 | const items: TestHashOnlyItem[] = await this.query(queryInput, ttl); 253 | ``` 254 | 255 | ### scan 256 | 257 | `this.scan(scanInput, 180)` 258 | 259 | Returns all scanned records from the table by the `scanInput`. A scan is different from a query because in a `query` a portion of the key schema on the table must be provided. A `scan` allows you to retrieve all items from the table, it also lets you paginate. 260 | 261 | [DynamoDB.DocumentClient.scan](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#scan-property) 262 | 263 | #### scan Example 264 | 265 | ```ts 266 | const scanInput: DynamoDB.DocumentClient.ScanInput = { 267 | TableName: 'test_hash_only', 268 | ConsistentRead: true, 269 | }; 270 | const ttl = 30 * 60; // 30minutes 271 | 272 | const items: TestHashOnlyItem[] = await this.scan(scanInput, ttl); 273 | ``` 274 | 275 | ### put 276 | 277 | `this.put(item, 180)` 278 | 279 | Saves the given item to the table. If a `ttl` value is provided it will also add the item to the cache 280 | 281 | [DynamoDB.DocumentClient.put](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#put-property) 282 | 283 | #### put Example 284 | 285 | ```ts 286 | const item: TestHashOnlyItem = { 287 | id: 'testId2', 288 | test: 'testing2', 289 | }; 290 | const ttl = 30 * 60; // 30minutes 291 | 292 | const created: TestHashOnlyItem = await this.put(item, ttl); 293 | ``` 294 | 295 | ### update 296 | 297 | `this.update(key, updateExpression, expressionAttributeNames, expressionAttributeValues, 180)` 298 | 299 | Updates the item in the table found by the given key and then uses the update expressions to update the record in the table. These input values are used to build a `DocumentClient.UpdateItemInput` instance to tells DynamoDB how to update the record. 300 | 301 | [DynamoDB.DocumentClient.update](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#update-property) 302 | 303 | #### update Example 304 | 305 | ```ts 306 | const key: DocumentClient.Key = { 307 | id: 'testId', 308 | }; 309 | const updateExpression: DocumentClient.UpdateExpression = 'SET #test = :test'; 310 | const expressionAttributeNames: DocumentClient.ExpressionAttributeNameMap = { '#test': 'test' }; 311 | const expressionAttributeValues: DynamoDB.DocumentClient.ExpressionAttributeValueMap = { 312 | ':test': 'testing_updated', 313 | }; 314 | const ttl = 30 * 60; // 30minutes 315 | 316 | const updated: TestHashOnlyItem = await this.update( 317 | key, 318 | updateExpression, 319 | expressionAttributeNames, 320 | expressionAttributeValues, 321 | ttl 322 | ); 323 | ``` 324 | 325 | ### delete 326 | 327 | `this.delete(key)` 328 | 329 | Deletes the item found by the key from the table. It also evicts the item from the cache. 330 | 331 | [DynamoDB.DocumentClient.delete](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#delete-property) 332 | 333 | #### delete Example 334 | 335 | ```ts 336 | const key: DocumentClient.Key = { 337 | id: 'testId', 338 | }; 339 | 340 | await this.delete(key); 341 | ``` 342 | -------------------------------------------------------------------------------- /jest-dynalite-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "tables": [ 3 | { 4 | "TableName": "test_hash_only", 5 | "KeySchema": [{ "AttributeName": "id", "KeyType": "HASH" }], 6 | "AttributeDefinitions": [{ "AttributeName": "id", "AttributeType": "S" }], 7 | "ProvisionedThroughput": { 8 | "ReadCapacityUnits": 1, 9 | "WriteCapacityUnits": 1 10 | } 11 | }, 12 | { 13 | "TableName": "test_with_client", 14 | "KeySchema": [{ "AttributeName": "id", "KeyType": "HASH" }], 15 | "AttributeDefinitions": [{ "AttributeName": "id", "AttributeType": "S" }], 16 | "ProvisionedThroughput": { 17 | "ReadCapacityUnits": 1, 18 | "WriteCapacityUnits": 1 19 | } 20 | }, 21 | { 22 | "TableName": "test_composite", 23 | "KeySchema": [ 24 | { "AttributeName": "id", "KeyType": "HASH" }, 25 | { "AttributeName": "timestamp", "KeyType": "RANGE" } 26 | ], 27 | "AttributeDefinitions": [ 28 | { "AttributeName": "id", "AttributeType": "S" }, 29 | { "AttributeName": "timestamp", "AttributeType": "S" } 30 | ], 31 | "ProvisionedThroughput": { 32 | "ReadCapacityUnits": 1, 33 | "WriteCapacityUnits": 1 34 | } 35 | } 36 | ], 37 | "basePort": 8000 38 | } 39 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | transform: { 4 | '^.+\\.ts?$': 'ts-jest', 5 | }, 6 | preset: 'jest-dynalite', 7 | testRegex: '/__tests__/.*\\.spec\\.ts$', 8 | testPathIgnorePatterns: ['/node_modules/', '/lib/'], 9 | collectCoverage: true, 10 | collectCoverageFrom: ['**/src/**/*', '!**/node_modules/', '!**/src/index.ts'], 11 | coverageThreshold: { 12 | global: { 13 | branches: 95, 14 | functions: 95, 15 | lines: 95, 16 | statements: 97, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-datasource-dynamodb", 3 | "version": "1.1.0", 4 | "description": "Apollo DataSource framework for AWS DynamoDB", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "check-types": "tsc", 9 | "lint:eslint": "eslint --ext .ts . --ignore-path .gitignore", 10 | "format:eslint": "eslint --ext .ts . --fix --ignore-path .gitignore", 11 | "lint:prettier": "prettier \"**/*.ts\" --list-different --ignore-path .gitignore || (echo '↑↑ these files are not prettier formatted ↑↑' && exit 1)", 12 | "format:prettier": "prettier \"**/*.ts\" --write --ignore-path .gitignore", 13 | "lint": "npm run lint:eslint && npm run lint:prettier", 14 | "clean-report": "rimraf ./coverage", 15 | "test": "npm run clean-report && jest -c jest.config.js --runInBand --detectOpenHandles", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "prebuild": "rimraf ./lib ./lib-esm", 19 | "build": "tsc && tsc -m es6 --outDir lib-esm", 20 | "prepare": "npm run build", 21 | "prepublishOnly": "npm test && npm run lint", 22 | "preversion": "npm run lint", 23 | "version": "npm run format && git add -A src", 24 | "postversion": "git push && git push --tags" 25 | }, 26 | "homepage": "https://github.com/cmwhited/apollo-datasource-dynamodb", 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/cmwhited/apollo-datasource-dynamodb.git" 30 | }, 31 | "bugs": { 32 | "url": "https://github.com/cmwhited/apollo-datasource-dynamodb" 33 | }, 34 | "author": "Chris Whited ", 35 | "license": "MIT", 36 | "private": false, 37 | "keywords": [ 38 | "apollo", 39 | "graphql", 40 | "dynamodb", 41 | "datasource", 42 | "data source" 43 | ], 44 | "files": [ 45 | "/lib" 46 | ], 47 | "engines": { 48 | "node": ">=10" 49 | }, 50 | "dependencies": { 51 | "@babel/plugin-proposal-optional-chaining": "^7.8.3", 52 | "apollo-datasource": "^0.7.0", 53 | "apollo-server-caching": "^0.5.1", 54 | "apollo-server-errors": "^2.4.0", 55 | "aws-sdk": "^2.642.0", 56 | "dataloader": "^2.0.0", 57 | "graphql": "^14.6.0" 58 | }, 59 | "devDependencies": { 60 | "@babel/core": "^7.8.7", 61 | "@babel/plugin-proposal-class-properties": "^7.8.3", 62 | "@babel/plugin-proposal-object-rest-spread": "^7.8.3", 63 | "@babel/plugin-transform-destructuring": "^7.8.8", 64 | "@babel/plugin-transform-regenerator": "^7.8.7", 65 | "@babel/plugin-transform-runtime": "^7.8.3", 66 | "@babel/preset-env": "^7.8.7", 67 | "@babel/preset-typescript": "^7.8.3", 68 | "@babel/runtime": "^7.8.7", 69 | "@types/jest": "^25.1.4", 70 | "@types/node": "^13.9.2", 71 | "@typescript-eslint/eslint-plugin": "^2.24.0", 72 | "@typescript-eslint/parser": "^2.24.0", 73 | "babel-eslint": "^10.1.0", 74 | "babel-loader": "^8.0.6", 75 | "babel-plugin-transform-builtin-extend": "^1.1.2", 76 | "eslint": "^6.8.0", 77 | "eslint-config-prettier": "^6.10.0", 78 | "eslint-config-standard": "^14.1.0", 79 | "eslint-plugin-import": "^2.20.1", 80 | "eslint-plugin-jest": "^23.8.2", 81 | "eslint-plugin-prettier": "^3.1.2", 82 | "fork-ts-checker-webpack-plugin": "^4.1.0", 83 | "jest": "^25.1.0", 84 | "jest-dynalite": "^1.1.6", 85 | "prettier": "^1.19.1", 86 | "rimraf": "^3.0.2", 87 | "ts-jest": "^26.4.4", 88 | "ts-loader": "^6.2.1", 89 | "typescript": "^3.8.3", 90 | "webpack": "^4.42.0", 91 | "webpack-node-externals": "^1.7.2" 92 | }, 93 | "directories": { 94 | "lib": "lib" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/DynamoDBCache.ts: -------------------------------------------------------------------------------- 1 | import { KeyValueCache, InMemoryLRUCache, PrefixingKeyValueCache } from 'apollo-server-caching'; 2 | import { ApolloError } from 'apollo-server-errors'; 3 | import { DynamoDB } from 'aws-sdk'; 4 | 5 | import { buildCacheKey } from './utils'; 6 | import { CacheKeyItemMap } from './types'; 7 | 8 | export const CACHE_PREFIX_KEY = 'dynamodbcache:'; 9 | export const TTL_SEC = 30 * 60; // the default time-to-live value for the cache in seconds 10 | 11 | export interface DynamoDBCache { 12 | getItem: (getItemInput: DynamoDB.DocumentClient.GetItemInput, ttl?: number) => Promise; 13 | setInCache: (key: string, item: T, ttl: number) => Promise; 14 | setItemsInCache: (items: CacheKeyItemMap, ttl: number) => Promise; 15 | removeItemFromCache: (tableName: string, key: DynamoDB.DocumentClient.Key) => Promise; 16 | } 17 | 18 | export class DynamoDBCacheImpl implements DynamoDBCache { 19 | readonly keyValueCache: KeyValueCache; 20 | readonly docClient: DynamoDB.DocumentClient; 21 | 22 | /** 23 | * Construct a new instance of `DynamoDBCache` with the given configuration 24 | * @param docClient the DynamoDB.DocumentClient instance 25 | * @param keyValueCache the key value caching client used to cache and retrieve records 26 | */ 27 | constructor(docClient: DynamoDB.DocumentClient, keyValueCache: KeyValueCache = new InMemoryLRUCache()) { 28 | this.keyValueCache = new PrefixingKeyValueCache(keyValueCache, CACHE_PREFIX_KEY); 29 | this.docClient = docClient; 30 | } 31 | 32 | /** 33 | * Attempt to retrieve the item from the KeyValueCache instance 34 | * @param key the key of item in the cache 35 | */ 36 | async retrieveFromCache(key: string): Promise { 37 | try { 38 | const itemFromCache: string | undefined = await this.keyValueCache.get(key); 39 | if (itemFromCache) { 40 | return JSON.parse(itemFromCache) as T; 41 | } 42 | } catch (err) { 43 | return undefined; 44 | } 45 | return undefined; 46 | } 47 | 48 | /** 49 | * Set the found item in the cache instance 50 | * @param key the key of the item to set in the cache 51 | * @param item the item to store in the cache 52 | * @param ttl cache time to live of the item 53 | */ 54 | async setInCache(key: string, item: T, ttl: number = TTL_SEC): Promise { 55 | return await this.keyValueCache.set(key, JSON.stringify(item), { ttl }); 56 | } 57 | 58 | /** 59 | * Store all of the given items in the cache 60 | * @param items the items to store in the cache. the object key value is the cache key 61 | * @param ttl cache time to live of the item 62 | */ 63 | async setItemsInCache(items: CacheKeyItemMap, ttl: number = TTL_SEC): Promise { 64 | return Object.entries(items).forEach(async (item: [string, T]) => { 65 | return await this.setInCache(item[0], item[1], ttl); 66 | }); 67 | } 68 | 69 | /** 70 | * Retrieve the item with the given `GetItemInput`. 71 | * - Attempt to retrieve the item from the cache. 72 | * - If the item does not exist in the cache, retrieve the item from the table, then add the item to the cache 73 | * @param getItemInput the input that provides information about which record to retrieve from the cache/dynamodb table 74 | * @param ttl the time-to-live value of the item in the cache. determines how long the item persists in the cache 75 | */ 76 | async getItem(getItemInput: DynamoDB.DocumentClient.GetItemInput, ttl?: number): Promise { 77 | try { 78 | const cacheKey = buildCacheKey(CACHE_PREFIX_KEY, getItemInput.TableName, getItemInput.Key); 79 | const itemFromCache: T | undefined = await this.retrieveFromCache(cacheKey); 80 | if (itemFromCache) { 81 | return itemFromCache; 82 | } 83 | 84 | // item is not in cache, retrieve from DynamoDB, if found, set in cache, otherwise throw ApolloError 85 | const output: DynamoDB.DocumentClient.GetItemOutput = await this.docClient.get(getItemInput).promise(); 86 | const item: T | undefined = output.Item as T; 87 | 88 | await this.setInCache(cacheKey, item, ttl); 89 | 90 | return item; 91 | } catch (err) { 92 | throw new ApolloError(err?.message || 'An error occurred attempting to retrieve the item'); 93 | } 94 | } 95 | 96 | /** 97 | * Remove an item from the cache. 98 | * @param tableName the table name the item belong in. used to build the cache key 99 | * @param key the dynamodb key value of the record. used to build the cache key 100 | */ 101 | async removeItemFromCache(tableName: string, key: DynamoDB.DocumentClient.Key): Promise { 102 | try { 103 | const cacheKey = buildCacheKey(CACHE_PREFIX_KEY, tableName, key); 104 | return await this.keyValueCache.delete(cacheKey); 105 | } catch (err) { 106 | throw new ApolloError(err?.message || 'An error occurred trying to evict the item from the cache'); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/DynamoDBDataSource.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, DataSourceConfig } from 'apollo-datasource'; 2 | import { DynamoDB } from 'aws-sdk'; 3 | import { ClientConfiguration } from 'aws-sdk/clients/dynamodb'; 4 | 5 | import { DynamoDBCache, DynamoDBCacheImpl, CACHE_PREFIX_KEY } from './DynamoDBCache'; 6 | import { buildItemsCacheMap, buildCacheKey, buildKey } from './utils'; 7 | import { CacheKeyItemMap } from './types'; 8 | 9 | /** 10 | * Data Source to interact with DynamoDB. 11 | * @param ITEM the type of the item to retrieve from the DynamoDB table 12 | */ 13 | export abstract class DynamoDBDataSource extends DataSource { 14 | readonly dynamoDbDocClient: DynamoDB.DocumentClient; 15 | readonly tableName!: string; 16 | readonly tableKeySchema!: DynamoDB.DocumentClient.KeySchema; 17 | dynamodbCache!: DynamoDBCache; 18 | context!: TContext; 19 | 20 | /** 21 | * Create a `DynamoDBDataSource` instance with the supplied params 22 | * @param tableName the name of the DynamoDB table the class will be interacting with 23 | * @param tableKeySchema the key structure schema of the table 24 | * @param config an optional ClientConfiguration object to use in building the DynamoDB.DocumentClient 25 | * @param client an optional initialized DynamoDB.Document client instance to use to set the client in the class instance 26 | */ 27 | constructor( 28 | tableName: string, 29 | tableKeySchema: DynamoDB.DocumentClient.KeySchema, 30 | config?: ClientConfiguration, 31 | client?: DynamoDB.DocumentClient 32 | ) { 33 | super(); 34 | this.tableName = tableName; 35 | this.tableKeySchema = tableKeySchema; 36 | this.dynamoDbDocClient = 37 | client != null 38 | ? client 39 | : new DynamoDB.DocumentClient({ 40 | apiVersion: 'latest', 41 | ...config, 42 | }); 43 | } 44 | 45 | initialize({ context, cache }: DataSourceConfig): void { 46 | this.context = context; 47 | this.dynamodbCache = new DynamoDBCacheImpl(this.dynamoDbDocClient, cache); 48 | } 49 | 50 | /** 51 | * Retrieve the item with the given `GetItemInput`. 52 | * - Attempt to retrieve the item from the cache. 53 | * - If the item does not exist in the cache, retrieve the item from the table, then add the item to the cache 54 | * @param getItemInput the input that provides information about which record to retrieve from the cache/dynamodb table 55 | * @param ttl the time-to-live value of the item in the cache. determines how long the item persists in the cache 56 | */ 57 | async getItem(getItemInput: DynamoDB.DocumentClient.GetItemInput, ttl?: number): Promise { 58 | return await this.dynamodbCache.getItem(getItemInput, ttl); 59 | } 60 | 61 | /** 62 | * Query for a list of records by the given query input. 63 | * If the ttl has a value, and items are returned, store the items in the cache 64 | * @param queryInput the defined query that tells the document client which records to retrieve from the table 65 | * @param ttl the time-to-live value of the item in the cache. determines how long the item persists in the cache 66 | */ 67 | async query(queryInput: DynamoDB.DocumentClient.QueryInput, ttl?: number): Promise { 68 | const output = await this.dynamoDbDocClient.query(queryInput).promise(); 69 | const items: ITEM[] = output.Items as ITEM[]; 70 | 71 | // store the items in the cache 72 | if (items.length && ttl) { 73 | const cacheKeyItemMap: CacheKeyItemMap = buildItemsCacheMap( 74 | CACHE_PREFIX_KEY, 75 | this.tableName, 76 | this.tableKeySchema, 77 | items 78 | ); 79 | await this.dynamodbCache.setItemsInCache(cacheKeyItemMap, ttl); 80 | } 81 | 82 | return items; 83 | } 84 | 85 | /** 86 | * Scan for a list of records by the given scan input. 87 | * If the ttl has a value, and items are returned, store the items in the cache 88 | * @param scanInput the scan input that tell the document client how to scan for records in the table 89 | * @param ttl the time-to-live value of the item in the cache. determines how long the item persists in the cache 90 | */ 91 | async scan(scanInput: DynamoDB.DocumentClient.ScanInput, ttl?: number): Promise { 92 | const output = await this.dynamoDbDocClient.scan(scanInput).promise(); 93 | const items: ITEM[] = output.Items as ITEM[]; 94 | 95 | // store the items in the cache 96 | if (items.length && ttl) { 97 | const cacheKeyItemMap: CacheKeyItemMap = buildItemsCacheMap( 98 | CACHE_PREFIX_KEY, 99 | this.tableName, 100 | this.tableKeySchema, 101 | items 102 | ); 103 | await this.dynamodbCache.setItemsInCache(cacheKeyItemMap, ttl); 104 | } 105 | 106 | return items; 107 | } 108 | 109 | /** 110 | * Store the item in the table and add the item to the cache 111 | * @param item the item to store in the table 112 | * @param ttl the time-to-live value of how long to persist the item in the cache 113 | */ 114 | async put(item: ITEM, ttl?: number): Promise { 115 | const putItemInput: DynamoDB.DocumentClient.PutItemInput = { 116 | TableName: this.tableName, 117 | Item: item, 118 | }; 119 | await this.dynamoDbDocClient.put(putItemInput).promise(); 120 | 121 | if (ttl) { 122 | const key: DynamoDB.DocumentClient.Key = buildKey(this.tableKeySchema, item); 123 | const cacheKey: string = buildCacheKey(CACHE_PREFIX_KEY, this.tableName, key); 124 | await this.dynamodbCache.setInCache(cacheKey, item, ttl); 125 | } 126 | 127 | return item; 128 | } 129 | 130 | /** 131 | * Update the item in the table and reset the item in the cache 132 | * @param key the key of the item in the table to update 133 | * @param ttl the time-to-live value of how long to persist the item in the cache 134 | */ 135 | async update( 136 | key: DynamoDB.DocumentClient.Key, 137 | updateExpression: DynamoDB.DocumentClient.UpdateExpression, 138 | expressionAttributeNames: DynamoDB.DocumentClient.ExpressionAttributeNameMap, 139 | expressionAttributeValues: DynamoDB.DocumentClient.ExpressionAttributeValueMap, 140 | ttl?: number 141 | ): Promise { 142 | const updateItemInput: DynamoDB.DocumentClient.UpdateItemInput = { 143 | TableName: this.tableName, 144 | Key: key, 145 | ReturnValues: 'ALL_NEW', 146 | UpdateExpression: updateExpression, 147 | ExpressionAttributeNames: expressionAttributeNames, 148 | ExpressionAttributeValues: expressionAttributeValues, 149 | }; 150 | const output = await this.dynamoDbDocClient.update(updateItemInput).promise(); 151 | const updated: ITEM = output.Attributes as ITEM; 152 | 153 | if (updated && ttl) { 154 | const cacheKey: string = buildCacheKey(CACHE_PREFIX_KEY, this.tableName, key); 155 | await this.dynamodbCache.setInCache(cacheKey, updated, ttl); 156 | } 157 | 158 | return updated; 159 | } 160 | 161 | /** 162 | * Delete the given item from the table 163 | * @param key the key of the item to delete from the table 164 | */ 165 | async delete(key: DynamoDB.DocumentClient.Key): Promise { 166 | const deleteItemInput: DynamoDB.DocumentClient.DeleteItemInput = { 167 | TableName: this.tableName, 168 | Key: key, 169 | }; 170 | 171 | await this.dynamoDbDocClient.delete(deleteItemInput).promise(); 172 | 173 | await this.dynamodbCache.removeItemFromCache(this.tableName, key); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/__tests__/DynamoDBCache.spec.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from 'apollo-server-errors'; 2 | import { DynamoDB } from 'aws-sdk'; 3 | 4 | import { DynamoDBCacheImpl, CACHE_PREFIX_KEY, TTL_SEC } from '../DynamoDBCache'; 5 | import { buildCacheKey } from '../utils'; 6 | 7 | const { MOCK_DYNAMODB_ENDPOINT } = process.env; 8 | 9 | interface TestHashOnlyItem { 10 | id: string; 11 | test: string; 12 | } 13 | 14 | describe('DynamoDBCache', () => { 15 | const docClient = new DynamoDB.DocumentClient({ 16 | region: 'local', 17 | endpoint: MOCK_DYNAMODB_ENDPOINT, 18 | sslEnabled: false, 19 | }); 20 | describe('retrieveFromCache', () => { 21 | const dynamodbCache = new DynamoDBCacheImpl(docClient); 22 | const getFromCacheMock = jest.spyOn(dynamodbCache.keyValueCache, 'get'); 23 | 24 | afterEach(() => { 25 | getFromCacheMock.mockReset(); 26 | }); 27 | afterAll(() => { 28 | getFromCacheMock.mockRestore(); 29 | }); 30 | 31 | it('should return undefined if no item is found in the cache', async () => { 32 | const given = `${CACHE_PREFIX_KEY}test:id-testId`; 33 | 34 | getFromCacheMock.mockResolvedValueOnce(undefined); 35 | 36 | const actual = await dynamodbCache.retrieveFromCache(given); 37 | 38 | expect(actual).toEqual(undefined); 39 | expect(getFromCacheMock).toBeCalledWith(given); 40 | }); 41 | 42 | it('should return undefined if an error is thrown retrieving an item from the cache', async () => { 43 | const given = `${CACHE_PREFIX_KEY}test:id-testId`; 44 | 45 | getFromCacheMock.mockRejectedValueOnce(new Error('Error retrieving item from cache')); 46 | 47 | const actual = await dynamodbCache.retrieveFromCache(given); 48 | 49 | expect(actual).toEqual(undefined); 50 | expect(getFromCacheMock).toBeCalledWith(given); 51 | }); 52 | 53 | it('should return the parsed item from the cache', async () => { 54 | const given = `${CACHE_PREFIX_KEY}test:id-testId`; 55 | const expected = { 56 | id: 'testId', 57 | test: 'test', 58 | }; 59 | const itemFromCache = JSON.stringify(expected); 60 | 61 | getFromCacheMock.mockResolvedValueOnce(itemFromCache); 62 | 63 | const actual = await dynamodbCache.retrieveFromCache(given); 64 | 65 | expect(actual).toEqual(expected); 66 | expect(getFromCacheMock).toBeCalledWith(given); 67 | }); 68 | }); 69 | 70 | describe('setInCache', () => { 71 | const dynamodbCache = new DynamoDBCacheImpl(docClient); 72 | const setInCacheMock = jest.spyOn(dynamodbCache.keyValueCache, 'set'); 73 | 74 | afterEach(() => { 75 | setInCacheMock.mockReset(); 76 | }); 77 | afterAll(() => { 78 | setInCacheMock.mockRestore(); 79 | }); 80 | 81 | it('should set the item in the cache with default TTL', async () => { 82 | const givenKey = `${CACHE_PREFIX_KEY}test:id-testId`; 83 | const givenItem = { 84 | id: 'testId', 85 | test: 'test', 86 | }; 87 | 88 | setInCacheMock.mockResolvedValueOnce(); 89 | 90 | await dynamodbCache.setInCache(givenKey, givenItem); 91 | 92 | expect(setInCacheMock).toBeCalledWith(givenKey, JSON.stringify(givenItem), { ttl: TTL_SEC }); 93 | }); 94 | }); 95 | 96 | describe('setItemsInCache', () => { 97 | const dynamodbCache = new DynamoDBCacheImpl(docClient); 98 | const setInCacheMock = jest.spyOn(dynamodbCache, 'setInCache'); 99 | 100 | afterEach(() => { 101 | setInCacheMock.mockReset(); 102 | }); 103 | afterAll(() => { 104 | setInCacheMock.mockRestore(); 105 | }); 106 | 107 | it('should set the item in the cache with default TTL', async () => { 108 | const givenKey = `${CACHE_PREFIX_KEY}test:id-testId`; 109 | const givenItem = { 110 | id: 'testId', 111 | test: 'test', 112 | }; 113 | const given: { [cacheKey: string]: TestHashOnlyItem } = { 114 | [givenKey]: givenItem, 115 | }; 116 | 117 | setInCacheMock.mockResolvedValueOnce(); 118 | 119 | await dynamodbCache.setItemsInCache(given); 120 | 121 | expect(setInCacheMock).toBeCalledTimes(Object.keys(given).length); 122 | expect(setInCacheMock).toBeCalledWith(givenKey, givenItem, TTL_SEC); 123 | }); 124 | }); 125 | 126 | describe('getItem', () => { 127 | const dynamodbCache = new DynamoDBCacheImpl(docClient); 128 | const testHashOnlyTableName = 'test_hash_only'; 129 | const testHashOnlyItem: TestHashOnlyItem = { id: 'testId', test: 'testing' }; 130 | const retrieveFromCacheMock = jest.spyOn(dynamodbCache, 'retrieveFromCache'); 131 | const setInCacheMock = jest.spyOn(dynamodbCache, 'setInCache'); 132 | 133 | afterEach(() => { 134 | retrieveFromCacheMock.mockReset(); 135 | setInCacheMock.mockReset(); 136 | }); 137 | afterAll(async () => { 138 | retrieveFromCacheMock.mockRestore(); 139 | setInCacheMock.mockRestore(); 140 | }); 141 | 142 | it('should return the item retrieved from the cache', async () => { 143 | const givenGetItemInput: DynamoDB.DocumentClient.GetItemInput = { 144 | TableName: testHashOnlyTableName, 145 | ConsistentRead: true, 146 | Key: { id: 'testId' }, 147 | }; 148 | const givenTtl = TTL_SEC; 149 | const expected = testHashOnlyItem; 150 | const cacheKey = buildCacheKey(CACHE_PREFIX_KEY, testHashOnlyTableName, { id: 'testId' }); 151 | 152 | retrieveFromCacheMock.mockResolvedValueOnce(expected); 153 | 154 | const actual: TestHashOnlyItem = await dynamodbCache.getItem(givenGetItemInput, givenTtl); 155 | 156 | expect(actual).toEqual(expected); 157 | expect(retrieveFromCacheMock).toBeCalledWith(cacheKey); 158 | expect(setInCacheMock).not.toBeCalled(); 159 | }); 160 | 161 | it('should return the item retrieved from the DynamoDB table and set in the cache', async () => { 162 | const givenGetItemInput: DynamoDB.DocumentClient.GetItemInput = { 163 | TableName: testHashOnlyTableName, 164 | ConsistentRead: true, 165 | Key: { id: 'testId' }, 166 | }; 167 | const givenTtl = TTL_SEC; 168 | await dynamodbCache.docClient 169 | .put({ 170 | TableName: testHashOnlyTableName, 171 | Item: testHashOnlyItem, 172 | }) 173 | .promise(); 174 | const cacheKey = buildCacheKey(CACHE_PREFIX_KEY, testHashOnlyTableName, { id: 'testId' }); 175 | 176 | retrieveFromCacheMock.mockResolvedValueOnce(undefined); 177 | setInCacheMock.mockResolvedValueOnce(); 178 | 179 | const { Item } = await dynamodbCache.docClient.get(givenGetItemInput).promise(); 180 | const actual: TestHashOnlyItem = await dynamodbCache.getItem(givenGetItemInput, givenTtl); 181 | 182 | expect(actual).toBeDefined(); 183 | expect(actual).toEqual(Item); 184 | expect(actual).toEqual(testHashOnlyItem); 185 | expect(retrieveFromCacheMock).toBeCalledWith(cacheKey); 186 | expect(setInCacheMock).toBeCalledWith(cacheKey, actual, givenTtl); 187 | 188 | await dynamodbCache.docClient 189 | .delete({ 190 | TableName: testHashOnlyTableName, 191 | Key: { id: 'testId' }, 192 | }) 193 | .promise(); 194 | }); 195 | 196 | it('should return an ApolloError if an error is thrown retrieving the record', async () => { 197 | const givenGetItemInput: DynamoDB.DocumentClient.GetItemInput = { 198 | TableName: testHashOnlyTableName, 199 | ConsistentRead: true, 200 | Key: { id: 'testId' }, 201 | }; 202 | const givenTtl = TTL_SEC; 203 | const cacheKey = buildCacheKey(CACHE_PREFIX_KEY, testHashOnlyTableName, { id: 'testId' }); 204 | const error = new Error('Error setting item in cache'); 205 | 206 | retrieveFromCacheMock.mockRejectedValueOnce(error); 207 | 208 | await expect(dynamodbCache.getItem(givenGetItemInput, givenTtl)).rejects.toThrowError( 209 | new ApolloError('Error setting item in cache') 210 | ); 211 | expect(retrieveFromCacheMock).toBeCalledWith(cacheKey); 212 | expect(setInCacheMock).not.toBeCalled(); 213 | }); 214 | 215 | it('should return an ApolloError if no record is found', async () => { 216 | const givenGetItemInput: DynamoDB.DocumentClient.GetItemInput = { 217 | TableName: testHashOnlyTableName, 218 | ConsistentRead: true, 219 | Key: { id: 'does_not_exist' }, 220 | }; 221 | const givenTtl = TTL_SEC; 222 | const cacheKey = buildCacheKey(CACHE_PREFIX_KEY, testHashOnlyTableName, { id: 'does_not_exist' }); 223 | 224 | retrieveFromCacheMock.mockRejectedValueOnce(undefined); 225 | 226 | await expect(dynamodbCache.getItem(givenGetItemInput, givenTtl)).rejects.toThrowError( 227 | new ApolloError('An error occurred attempting to retrieve the item') 228 | ); 229 | expect(retrieveFromCacheMock).toBeCalledWith(cacheKey); 230 | expect(setInCacheMock).not.toBeCalled(); 231 | }); 232 | }); 233 | 234 | describe('removeItemFromCache', () => { 235 | const dynamodbCache = new DynamoDBCacheImpl(docClient); 236 | const deleteFromCacheMock = jest.spyOn(dynamodbCache.keyValueCache, 'delete'); 237 | 238 | afterEach(() => { 239 | deleteFromCacheMock.mockReset(); 240 | }); 241 | afterAll(() => { 242 | deleteFromCacheMock.mockRestore(); 243 | }); 244 | 245 | it('should remove the item from the cache and return true', async () => { 246 | const givenTableName = 'test'; 247 | const givenKey: DynamoDB.DocumentClient.Key = { id: 'testId' }; 248 | const givenCacheKey = `${CACHE_PREFIX_KEY}test:id-testId`; 249 | 250 | deleteFromCacheMock.mockResolvedValueOnce(true); 251 | 252 | const actual = await dynamodbCache.removeItemFromCache(givenTableName, givenKey); 253 | 254 | expect(actual).toEqual(true); 255 | expect(deleteFromCacheMock).toBeCalledWith(givenCacheKey); 256 | }); 257 | 258 | it('should throw an ApolloError if the dynamodbCache.keyValueCache.delete throws an error - with given error message', async () => { 259 | const givenTableName = 'test'; 260 | const givenKey: DynamoDB.DocumentClient.Key = { id: 'testId' }; 261 | const givenCacheKey = `${CACHE_PREFIX_KEY}test:id-testId`; 262 | 263 | deleteFromCacheMock.mockRejectedValueOnce(new Error('Error')); 264 | 265 | await expect(dynamodbCache.removeItemFromCache(givenTableName, givenKey)).rejects.toThrowError( 266 | new ApolloError('Error') 267 | ); 268 | expect(deleteFromCacheMock).toBeCalledWith(givenCacheKey); 269 | }); 270 | 271 | it('should throw an ApolloError if the dynamodbCache.keyValueCache.delete throws an error - with default error message', async () => { 272 | const givenTableName = 'test'; 273 | const givenKey: DynamoDB.DocumentClient.Key = { id: 'testId' }; 274 | const givenCacheKey = `${CACHE_PREFIX_KEY}test:id-testId`; 275 | 276 | deleteFromCacheMock.mockRejectedValueOnce(null); 277 | 278 | await expect(dynamodbCache.removeItemFromCache(givenTableName, givenKey)).rejects.toThrowError( 279 | new ApolloError('An error occurred trying to evict the item from the cache') 280 | ); 281 | expect(deleteFromCacheMock).toBeCalledWith(givenCacheKey); 282 | }); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /src/__tests__/DynamoDBDataSource.spec.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from 'apollo-server-errors'; 2 | import { DataSourceConfig } from 'apollo-datasource'; 3 | import { DynamoDB } from 'aws-sdk'; 4 | import { ClientConfiguration } from 'aws-sdk/clients/dynamodb'; 5 | 6 | import { DynamoDBDataSource } from '../DynamoDBDataSource'; 7 | import { CACHE_PREFIX_KEY } from '../DynamoDBCache'; 8 | import { buildItemsCacheMap } from '../utils'; 9 | import { CacheKeyItemMap } from '../types'; 10 | 11 | const { MOCK_DYNAMODB_ENDPOINT } = process.env; 12 | 13 | interface TestHashOnlyItem { 14 | id: string; 15 | test: string; 16 | } 17 | 18 | class TestHashOnly extends DynamoDBDataSource { 19 | constructor(tableName: string, tableKeySchema: DynamoDB.DocumentClient.KeySchema, config?: ClientConfiguration) { 20 | super(tableName, tableKeySchema, config); 21 | } 22 | 23 | initialize(config: DataSourceConfig<{}>): void { 24 | super.initialize(config); 25 | } 26 | } 27 | 28 | const keySchema: DynamoDB.DocumentClient.KeySchema = [ 29 | { 30 | AttributeName: 'id', 31 | KeyType: 'HASH', 32 | }, 33 | ]; 34 | const testHashOnly = new TestHashOnly('test_hash_only', keySchema, { 35 | region: 'local', 36 | endpoint: MOCK_DYNAMODB_ENDPOINT, 37 | sslEnabled: false, 38 | }); 39 | testHashOnly.initialize({ context: {}, cache: null }); 40 | 41 | const testHashOnlyItem: TestHashOnlyItem = { 42 | id: 'testId', 43 | test: 'testing', 44 | }; 45 | const items: TestHashOnlyItem[] = [testHashOnlyItem]; 46 | 47 | beforeAll(async () => { 48 | await testHashOnly.dynamoDbDocClient 49 | .put({ 50 | TableName: testHashOnly.tableName, 51 | Item: testHashOnlyItem, 52 | }) 53 | .promise(); 54 | }); 55 | 56 | afterAll(async () => { 57 | await testHashOnly.dynamoDbDocClient 58 | .delete({ 59 | TableName: testHashOnly.tableName, 60 | Key: { id: 'testId' }, 61 | }) 62 | .promise(); 63 | }); 64 | 65 | describe('DynamoDBDataSource', () => { 66 | it('initializes a new TestHashOnly and instantiates props', () => { 67 | expect(testHashOnly.dynamoDbDocClient).toBeDefined(); 68 | expect(testHashOnly.tableName).toBeDefined(); 69 | expect(testHashOnly.tableKeySchema).toBeDefined(); 70 | expect(testHashOnly.dynamodbCache).toBeDefined(); 71 | }); 72 | 73 | describe('getItem', () => { 74 | const dynamodbCacheGetItemMock = jest.spyOn(testHashOnly.dynamodbCache, 'getItem'); 75 | 76 | afterEach(() => { 77 | dynamodbCacheGetItemMock.mockReset(); 78 | }); 79 | afterAll(() => { 80 | dynamodbCacheGetItemMock.mockRestore(); 81 | }); 82 | 83 | it('should return a TestHashOnly item', async () => { 84 | const getItemInput: DynamoDB.DocumentClient.GetItemInput = { 85 | TableName: testHashOnly.tableName, 86 | ConsistentRead: true, 87 | Key: { 88 | id: 'testId', 89 | }, 90 | }; 91 | 92 | dynamodbCacheGetItemMock.mockResolvedValueOnce(testHashOnlyItem); 93 | 94 | const actual = await testHashOnly.getItem(getItemInput); 95 | 96 | expect(actual).toEqual(testHashOnlyItem); 97 | expect(dynamodbCacheGetItemMock).toBeCalledWith(getItemInput, undefined); 98 | }); 99 | 100 | it('should throw an ApolloError if an issue occurs retrieving the record', async () => { 101 | const getItemInput: DynamoDB.DocumentClient.GetItemInput = { 102 | TableName: testHashOnly.tableName, 103 | ConsistentRead: true, 104 | Key: { 105 | id: 'testId', 106 | }, 107 | }; 108 | 109 | dynamodbCacheGetItemMock.mockRejectedValueOnce(new ApolloError('Error')); 110 | 111 | await expect(testHashOnly.getItem(getItemInput)).rejects.toThrowError(new ApolloError('Error')); 112 | expect(dynamodbCacheGetItemMock).toBeCalledWith(getItemInput, undefined); 113 | }); 114 | }); 115 | 116 | const dynamodbCacheSetItemsInCacheMock = jest.spyOn(testHashOnly.dynamodbCache, 'setItemsInCache'); 117 | const dynamodbCacheSetInCacheMock = jest.spyOn(testHashOnly.dynamodbCache, 'setInCache'); 118 | 119 | afterEach(() => { 120 | dynamodbCacheSetItemsInCacheMock.mockReset(); 121 | dynamodbCacheSetInCacheMock.mockReset(); 122 | }); 123 | afterAll(() => { 124 | dynamodbCacheSetItemsInCacheMock.mockRestore(); 125 | dynamodbCacheSetInCacheMock.mockRestore(); 126 | }); 127 | 128 | it('query should return a list of TestHashOnlyItem records and add items to the cache', async () => { 129 | const queryInput: DynamoDB.DocumentClient.QueryInput = { 130 | TableName: testHashOnly.tableName, 131 | ConsistentRead: true, 132 | KeyConditionExpression: 'id = :id', 133 | ExpressionAttributeValues: { 134 | ':id': 'testId', 135 | }, 136 | }; 137 | const ttl = 30; 138 | 139 | await testHashOnly.dynamoDbDocClient 140 | .put({ 141 | TableName: testHashOnly.tableName, 142 | Item: testHashOnlyItem, 143 | }) 144 | .promise(); 145 | 146 | dynamodbCacheSetItemsInCacheMock.mockResolvedValueOnce(); 147 | 148 | const actual: TestHashOnlyItem[] = await testHashOnly.query(queryInput, ttl); 149 | const cacheKeyItemMap: CacheKeyItemMap = buildItemsCacheMap( 150 | CACHE_PREFIX_KEY, 151 | testHashOnly.tableName, 152 | testHashOnly.tableKeySchema, 153 | actual 154 | ); 155 | 156 | expect(actual).toEqual(items); 157 | expect(dynamodbCacheSetItemsInCacheMock).toBeCalledWith(cacheKeyItemMap, ttl); 158 | }); 159 | 160 | it('query should return an empty list. setItemsInCache should not be invoked', async () => { 161 | const queryInput: DynamoDB.DocumentClient.QueryInput = { 162 | TableName: testHashOnly.tableName, 163 | ConsistentRead: true, 164 | KeyConditionExpression: 'id = :id', 165 | ExpressionAttributeValues: { 166 | ':id': 'testId', 167 | }, 168 | }; 169 | const ttl = 30; 170 | 171 | const actual: TestHashOnlyItem[] = await testHashOnly.query(queryInput, ttl); 172 | 173 | expect(actual).toEqual([]); 174 | expect(dynamodbCacheSetItemsInCacheMock).not.toBeCalled(); 175 | }); 176 | 177 | it('query should return a list of TestHashOnlyItem records but not add items to cache because of no ttl', async () => { 178 | const queryInput: DynamoDB.DocumentClient.QueryInput = { 179 | TableName: testHashOnly.tableName, 180 | ConsistentRead: true, 181 | KeyConditionExpression: 'id = :id', 182 | ExpressionAttributeValues: { 183 | ':id': 'testId', 184 | }, 185 | }; 186 | 187 | await testHashOnly.dynamoDbDocClient 188 | .put({ 189 | TableName: testHashOnly.tableName, 190 | Item: testHashOnlyItem, 191 | }) 192 | .promise(); 193 | 194 | const actual: TestHashOnlyItem[] = await testHashOnly.query(queryInput); 195 | 196 | expect(actual).toEqual(items); 197 | expect(dynamodbCacheSetItemsInCacheMock).not.toBeCalled(); 198 | }); 199 | 200 | it('scan should return a list of TestHashOnlyItem records and add items to the cache', async () => { 201 | const scanInput: DynamoDB.DocumentClient.ScanInput = { 202 | TableName: testHashOnly.tableName, 203 | ConsistentRead: true, 204 | }; 205 | const ttl = 30; 206 | 207 | await testHashOnly.dynamoDbDocClient 208 | .put({ 209 | TableName: testHashOnly.tableName, 210 | Item: testHashOnlyItem, 211 | }) 212 | .promise(); 213 | 214 | dynamodbCacheSetItemsInCacheMock.mockResolvedValueOnce(); 215 | 216 | const actual: TestHashOnlyItem[] = await testHashOnly.scan(scanInput, ttl); 217 | const cacheKeyItemMap: CacheKeyItemMap = buildItemsCacheMap( 218 | CACHE_PREFIX_KEY, 219 | testHashOnly.tableName, 220 | testHashOnly.tableKeySchema, 221 | actual 222 | ); 223 | 224 | expect(actual).toEqual(items); 225 | expect(dynamodbCacheSetItemsInCacheMock).toBeCalledWith(cacheKeyItemMap, ttl); 226 | }); 227 | 228 | it('scan should return an empty list. setItemsInCache should not be invoked', async () => { 229 | const scanInput: DynamoDB.DocumentClient.ScanInput = { 230 | TableName: testHashOnly.tableName, 231 | ConsistentRead: true, 232 | }; 233 | const ttl = 30; 234 | 235 | const actual: TestHashOnlyItem[] = await testHashOnly.scan(scanInput, ttl); 236 | 237 | expect(actual).toEqual([]); 238 | expect(dynamodbCacheSetItemsInCacheMock).not.toBeCalled(); 239 | }); 240 | 241 | it('scan should return a list of TestHashOnlyItem records but not add items to cache because of no ttl', async () => { 242 | const scanInput: DynamoDB.DocumentClient.ScanInput = { 243 | TableName: testHashOnly.tableName, 244 | ConsistentRead: true, 245 | }; 246 | 247 | await testHashOnly.dynamoDbDocClient 248 | .put({ 249 | TableName: testHashOnly.tableName, 250 | Item: testHashOnlyItem, 251 | }) 252 | .promise(); 253 | 254 | const actual: TestHashOnlyItem[] = await testHashOnly.scan(scanInput); 255 | 256 | expect(actual).toEqual(items); 257 | expect(dynamodbCacheSetItemsInCacheMock).not.toBeCalled(); 258 | }); 259 | 260 | it('should put the item and store it in the cache', async () => { 261 | const item2: TestHashOnlyItem = { 262 | id: 'testId2', 263 | test: 'testing2', 264 | }; 265 | const ttl = 30; 266 | const cacheKey = `${CACHE_PREFIX_KEY}${testHashOnly.tableName}:id-testId2`; 267 | 268 | dynamodbCacheSetInCacheMock.mockResolvedValueOnce(); 269 | 270 | const actual: TestHashOnlyItem = await testHashOnly.put(item2, ttl); 271 | const { Item } = await testHashOnly.dynamoDbDocClient 272 | .get({ 273 | TableName: testHashOnly.tableName, 274 | ConsistentRead: true, 275 | Key: { 276 | id: 'testId2', 277 | }, 278 | }) 279 | .promise(); 280 | 281 | expect(actual).toEqual(item2); 282 | expect(Item).toBeDefined(); 283 | expect(actual).toEqual(Item); 284 | expect(dynamodbCacheSetInCacheMock).toBeCalledWith(cacheKey, actual, ttl); 285 | 286 | await testHashOnly.dynamoDbDocClient 287 | .delete({ 288 | TableName: testHashOnly.tableName, 289 | Key: { id: 'testId2' }, 290 | }) 291 | .promise(); 292 | }); 293 | 294 | it('should put the item and not store it in the cache because the ttl is null', async () => { 295 | const item3: TestHashOnlyItem = { 296 | id: 'testId3', 297 | test: 'testing3', 298 | }; 299 | 300 | const actual: TestHashOnlyItem = await testHashOnly.put(item3); 301 | const { Item } = await testHashOnly.dynamoDbDocClient 302 | .get({ 303 | TableName: testHashOnly.tableName, 304 | ConsistentRead: true, 305 | Key: { 306 | id: 'testId3', 307 | }, 308 | }) 309 | .promise(); 310 | 311 | expect(actual).toEqual(item3); 312 | expect(Item).toBeDefined(); 313 | expect(actual).toEqual(Item); 314 | expect(dynamodbCacheSetInCacheMock).not.toBeCalled(); 315 | 316 | await testHashOnly.dynamoDbDocClient 317 | .delete({ 318 | TableName: testHashOnly.tableName, 319 | Key: { id: 'testId3' }, 320 | }) 321 | .promise(); 322 | }); 323 | 324 | it('should update the item in the table and store it in the cache', async () => { 325 | const item2: TestHashOnlyItem = { 326 | id: 'testId2', 327 | test: 'testing2', 328 | }; 329 | const itemUpdated: TestHashOnlyItem = { 330 | id: 'testId2', 331 | test: 'testing_updated', 332 | }; 333 | await testHashOnly.dynamoDbDocClient 334 | .put({ 335 | TableName: testHashOnly.tableName, 336 | Item: item2, 337 | }) 338 | .promise(); 339 | 340 | const givenKey: DynamoDB.DocumentClient.Key = { id: 'testId2' }; 341 | const givenUpdateExpression: DynamoDB.DocumentClient.UpdateExpression = 'SET #test = :test'; 342 | const givenExpressionAttributeNames: DynamoDB.DocumentClient.ExpressionAttributeNameMap = { '#test': 'test' }; 343 | const givenExpressionAttributeValues: DynamoDB.DocumentClient.ExpressionAttributeValueMap = { 344 | ':test': 'testing_updated', 345 | }; 346 | const ttl = 30; 347 | const cacheKey = `${CACHE_PREFIX_KEY}${testHashOnly.tableName}:id-testId2`; 348 | 349 | dynamodbCacheSetInCacheMock.mockResolvedValueOnce(); 350 | 351 | const actual = await testHashOnly.update( 352 | givenKey, 353 | givenUpdateExpression, 354 | givenExpressionAttributeNames, 355 | givenExpressionAttributeValues, 356 | ttl 357 | ); 358 | const { Item } = await testHashOnly.dynamoDbDocClient 359 | .get({ 360 | TableName: testHashOnly.tableName, 361 | ConsistentRead: true, 362 | Key: { 363 | id: 'testId2', 364 | }, 365 | }) 366 | .promise(); 367 | 368 | expect(actual).toEqual(itemUpdated); 369 | expect(Item).toBeDefined(); 370 | expect(actual).toEqual(Item); 371 | expect(dynamodbCacheSetInCacheMock).toBeCalledWith(cacheKey, actual, ttl); 372 | 373 | await testHashOnly.dynamoDbDocClient 374 | .delete({ 375 | TableName: testHashOnly.tableName, 376 | Key: { id: 'testId2' }, 377 | }) 378 | .promise(); 379 | }); 380 | 381 | it('should update the item in the table and not set the item in the cache - no ttl passed in', async () => { 382 | const item2: TestHashOnlyItem = { 383 | id: 'testId2', 384 | test: 'testing2', 385 | }; 386 | const itemUpdated: TestHashOnlyItem = { 387 | id: 'testId2', 388 | test: 'testing_updated', 389 | }; 390 | await testHashOnly.dynamoDbDocClient 391 | .put({ 392 | TableName: testHashOnly.tableName, 393 | Item: item2, 394 | }) 395 | .promise(); 396 | 397 | const givenKey: DynamoDB.DocumentClient.Key = { id: 'testId2' }; 398 | const givenUpdateExpression: DynamoDB.DocumentClient.UpdateExpression = 'SET #test = :test'; 399 | const givenExpressionAttributeNames: DynamoDB.DocumentClient.ExpressionAttributeNameMap = { '#test': 'test' }; 400 | const givenExpressionAttributeValues: DynamoDB.DocumentClient.ExpressionAttributeValueMap = { 401 | ':test': 'testing_updated', 402 | }; 403 | 404 | const actual = await testHashOnly.update( 405 | givenKey, 406 | givenUpdateExpression, 407 | givenExpressionAttributeNames, 408 | givenExpressionAttributeValues 409 | ); 410 | const { Item } = await testHashOnly.dynamoDbDocClient 411 | .get({ 412 | TableName: testHashOnly.tableName, 413 | ConsistentRead: true, 414 | Key: { 415 | id: 'testId2', 416 | }, 417 | }) 418 | .promise(); 419 | 420 | expect(actual).toEqual(itemUpdated); 421 | expect(Item).toBeDefined(); 422 | expect(actual).toEqual(Item); 423 | expect(dynamodbCacheSetInCacheMock).not.toBeCalled(); 424 | 425 | await testHashOnly.dynamoDbDocClient 426 | .delete({ 427 | TableName: testHashOnly.tableName, 428 | Key: { id: 'testId2' }, 429 | }) 430 | .promise(); 431 | }); 432 | 433 | it('should delete the item from the table', async () => { 434 | const dynamodbCacheRemoveItemFromCacheMock = jest.spyOn(testHashOnly.dynamodbCache, 'removeItemFromCache'); 435 | 436 | const itemToDelete: TestHashOnlyItem = { 437 | id: 'delete_me', 438 | test: 'gonna be deleted', 439 | }; 440 | await testHashOnly.dynamoDbDocClient 441 | .put({ 442 | TableName: testHashOnly.tableName, 443 | Item: itemToDelete, 444 | }) 445 | .promise(); 446 | 447 | const givenKey: DynamoDB.DocumentClient.Key = { id: 'delete_me' }; 448 | 449 | dynamodbCacheRemoveItemFromCacheMock.mockResolvedValueOnce(); 450 | 451 | await testHashOnly.delete(givenKey); 452 | 453 | const { Item } = await testHashOnly.dynamoDbDocClient 454 | .get({ 455 | TableName: testHashOnly.tableName, 456 | ConsistentRead: true, 457 | Key: { 458 | id: 'delete_me', 459 | }, 460 | }) 461 | .promise(); 462 | 463 | expect(Item).not.toBeDefined(); 464 | expect(dynamodbCacheRemoveItemFromCacheMock).toBeCalledWith(testHashOnly.tableName, givenKey); 465 | }); 466 | }); 467 | -------------------------------------------------------------------------------- /src/__tests__/DynamoDBDataSourceWithClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { DataSourceConfig } from 'apollo-datasource'; 2 | import { DynamoDB } from 'aws-sdk'; 3 | 4 | import { DynamoDBDataSource } from '../DynamoDBDataSource'; 5 | 6 | const { MOCK_DYNAMODB_ENDPOINT } = process.env; 7 | 8 | interface TestItem { 9 | id: string; 10 | item1: string; 11 | item2: string; 12 | } 13 | 14 | class TestWithClient extends DynamoDBDataSource { 15 | constructor(tableName: string, tableKeySchema: DynamoDB.DocumentClient.KeySchema, client: DynamoDB.DocumentClient) { 16 | super(tableName, tableKeySchema, null, client); 17 | } 18 | 19 | initialize(config: DataSourceConfig<{}>): void { 20 | super.initialize(config); 21 | } 22 | } 23 | 24 | const keySchema: DynamoDB.DocumentClient.KeySchema = [ 25 | { 26 | AttributeName: 'id', 27 | KeyType: 'HASH', 28 | }, 29 | ]; 30 | 31 | const client: DynamoDB.DocumentClient = new DynamoDB.DocumentClient({ 32 | apiVersion: 'latest', 33 | region: 'local', 34 | endpoint: MOCK_DYNAMODB_ENDPOINT, 35 | sslEnabled: false, 36 | }); 37 | 38 | const testWithClient = new TestWithClient('test_with_client', keySchema, client); 39 | testWithClient.initialize({ context: {}, cache: null }); 40 | 41 | const testItem: TestItem = { 42 | id: 'testWithClientId', 43 | item1: 'testing1', 44 | item2: 'testing2', 45 | }; 46 | 47 | beforeAll(async () => { 48 | await testWithClient.dynamoDbDocClient 49 | .put({ 50 | TableName: testWithClient.tableName, 51 | Item: testItem, 52 | }) 53 | .promise(); 54 | }); 55 | 56 | afterAll(async () => { 57 | await testWithClient.dynamoDbDocClient 58 | .delete({ 59 | TableName: testWithClient.tableName, 60 | Key: { id: 'testWithClientId' }, 61 | }) 62 | .promise(); 63 | }); 64 | 65 | describe('DynamoDBDataSource With Initialized Client', () => { 66 | it('initializes a new TestHashOnly and instantiates props', () => { 67 | expect(testWithClient.dynamoDbDocClient).toBeDefined(); 68 | expect(testWithClient.dynamoDbDocClient).toEqual(client); 69 | expect(testWithClient.tableName).toBeDefined(); 70 | expect(testWithClient.tableKeySchema).toBeDefined(); 71 | expect(testWithClient.dynamodbCache).toBeDefined(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB } from 'aws-sdk'; 2 | 3 | import { CacheKeyItemMap } from '../types'; 4 | import { buildCacheKey, buildItemsCacheMap, buildKey } from '../utils'; 5 | 6 | interface TestItem { 7 | id: string; 8 | timestamp: string; 9 | test: string; 10 | } 11 | 12 | describe('buildCacheKey', () => { 13 | it('should return build cache key from the given table and single HASH key value', () => { 14 | const givenCachePrefix = 'test:'; 15 | const givenTableName = 'test'; 16 | const givenKey: DynamoDB.DocumentClient.Key = { 17 | id: 'testId', 18 | }; 19 | const expected = 'test:test:id-testId'; 20 | 21 | const actual = buildCacheKey(givenCachePrefix, givenTableName, givenKey); 22 | 23 | expect(actual).toEqual(expected); 24 | }); 25 | it('should return build cache key from the given table and compose HASH and RANGE key values', () => { 26 | const givenCachePrefix = 'test:'; 27 | const givenTableName = 'test'; 28 | const givenKey: DynamoDB.DocumentClient.Key = { 29 | id: 'testId', 30 | timestamp: '2020-01-01 00:00:00', 31 | }; 32 | const expected = 'test:test:id-testId:timestamp-2020-01-01 00:00:00'; 33 | 34 | const actual = buildCacheKey(givenCachePrefix, givenTableName, givenKey); 35 | 36 | expect(actual).toEqual(expected); 37 | }); 38 | }); 39 | 40 | describe('buildItemsCacheMap', () => { 41 | it('should build the CacheItemKeyMap with single HASH key', () => { 42 | const givenCachePrefix = 'test:'; 43 | const givenTableName = 'test'; 44 | const givenKeySchema: DynamoDB.DocumentClient.KeySchema = [ 45 | { 46 | AttributeName: 'id', 47 | KeyType: 'HASH', 48 | }, 49 | ]; 50 | const givenItems: TestItem[] = [ 51 | { 52 | id: 'testId', 53 | timestamp: '2020-01-01 00:00:00', 54 | test: 'testing', 55 | }, 56 | ]; 57 | const expected: CacheKeyItemMap = { 58 | ['test:test:id-testId']: { 59 | id: 'testId', 60 | timestamp: '2020-01-01 00:00:00', 61 | test: 'testing', 62 | }, 63 | }; 64 | 65 | const actual = buildItemsCacheMap(givenCachePrefix, givenTableName, givenKeySchema, givenItems); 66 | 67 | expect(actual).toEqual(expected); 68 | }); 69 | 70 | it('should build the CacheItemKeyMap with a HASH and RANGE composite key', () => { 71 | const givenCachePrefix = 'test:'; 72 | const givenTableName = 'test'; 73 | const givenKeySchema: DynamoDB.DocumentClient.KeySchema = [ 74 | { 75 | AttributeName: 'id', 76 | KeyType: 'HASH', 77 | }, 78 | { 79 | AttributeName: 'timestamp', 80 | KeyType: 'RANGE', 81 | }, 82 | ]; 83 | const givenItems: TestItem[] = [ 84 | { 85 | id: 'testId', 86 | timestamp: '2020-01-01 00:00:00', 87 | test: 'testing', 88 | }, 89 | ]; 90 | const expected: CacheKeyItemMap = { 91 | ['test:test:id-testId:timestamp-2020-01-01 00:00:00']: { 92 | id: 'testId', 93 | timestamp: '2020-01-01 00:00:00', 94 | test: 'testing', 95 | }, 96 | }; 97 | 98 | const actual = buildItemsCacheMap(givenCachePrefix, givenTableName, givenKeySchema, givenItems); 99 | 100 | expect(actual).toEqual(expected); 101 | }); 102 | }); 103 | 104 | describe('buildKey', () => { 105 | it('should build a key with only a HASH key', () => { 106 | const givenKeySchema: DynamoDB.DocumentClient.KeySchema = [ 107 | { 108 | AttributeName: 'id', 109 | KeyType: 'HASH', 110 | }, 111 | ]; 112 | const givenItem: TestItem = { 113 | id: 'testId', 114 | timestamp: '2020-01-01 00:00:00', 115 | test: 'testing', 116 | }; 117 | const expected: DynamoDB.DocumentClient.Key = { 118 | id: 'testId', 119 | }; 120 | 121 | const actual = buildKey(givenKeySchema, givenItem); 122 | 123 | expect(actual).toEqual(expected); 124 | }); 125 | 126 | it('should build a key with a HASH and RANGE key', () => { 127 | const givenKeySchema: DynamoDB.DocumentClient.KeySchema = [ 128 | { 129 | AttributeName: 'id', 130 | KeyType: 'HASH', 131 | }, 132 | { 133 | AttributeName: 'timestamp', 134 | KeyType: 'RANGE', 135 | }, 136 | ]; 137 | const givenItem: TestItem = { 138 | id: 'testId', 139 | timestamp: '2020-01-01 00:00:00', 140 | test: 'testing', 141 | }; 142 | const expected: DynamoDB.DocumentClient.Key = { 143 | id: 'testId', 144 | timestamp: '2020-01-01 00:00:00', 145 | }; 146 | 147 | const actual = buildKey(givenKeySchema, givenItem); 148 | 149 | expect(actual).toEqual(expected); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { DynamoDBCache, DynamoDBCacheImpl } from './DynamoDBCache'; 2 | export { DynamoDBDataSource } from './DynamoDBDataSource'; 3 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface CacheKeyItemMap { 2 | [key: string]: T; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB } from 'aws-sdk'; 2 | 3 | import { CacheKeyItemMap } from './types'; 4 | 5 | /** 6 | * Build the cache key from the table and key info. 7 | * The cache key format is: `${prefix}${tableName}:${...[key-value]}` 8 | * Example: 9 | * - `prefix`: `dynamodbcache:` 10 | * - `tableName`: `test` 11 | * - `key`: { ['id']: 'testId', ['timestamp']: '2020-01-01 00:00:00' } 12 | * Cache Key 13 | * `dynamodbcache:test:id-testId:timestamp-2020-01-01 00:00:00` 14 | * @param cachePrefix the prefix for items in the cache 15 | * @param tableName the name of the DynamoDB table 16 | * @param key the key value for the record 17 | */ 18 | export const buildCacheKey = (cachePrefix: string, tableName: string, key: DynamoDB.DocumentClient.Key): string => { 19 | const keysStr = Object.entries(key).reduce((accum: string, curr: [string, string]) => { 20 | return `${accum}:${curr[0]}-${curr[1]}`; 21 | }, ''); 22 | return `${cachePrefix}${tableName}${keysStr}`; 23 | }; 24 | 25 | /** 26 | * Build the Key from the KeySchema and item 27 | * @param keySchema the tables key schema. defines the HASH, RANGE (optional) schema. 28 | * @param item the item to pull values from the key for 29 | */ 30 | export function buildKey(keySchema: DynamoDB.DocumentClient.KeySchema, item: T): DynamoDB.DocumentClient.Key { 31 | return keySchema.reduce((prevKeys, keyElement: DynamoDB.DocumentClient.KeySchemaElement) => { 32 | return { 33 | ...prevKeys, 34 | [keyElement.AttributeName]: item[keyElement.AttributeName], 35 | }; 36 | }, {}); 37 | } 38 | 39 | /** 40 | * Use the key schema and items to build the Cache Key Item Map instance to use to save the items to the cache. 41 | * Example: 42 | * - keySchema: 43 | * ```js 44 | * [ 45 | * { 46 | * AttributeName: 'id', 47 | * KeyType: 'HASH' 48 | * } 49 | * ] 50 | * ``` 51 | * - items; 52 | * ```js 53 | * [ 54 | * { 55 | * id: 'testId', 56 | * test: 'testing' 57 | * } 58 | * ] 59 | * ``` 60 | * Returns: 61 | * ```js 62 | * { 63 | * [dynamodbcache:test:id-testId]: { 64 | * id: 'testId', 65 | * test: 'testing' 66 | * } 67 | * } 68 | * ``` 69 | * @param cachePrefix the prefix for items in the cache 70 | * @param tableName the DynamoDB table name. used to build the cache key 71 | * @param keySchema the tables key schema. defines the HASH, RANGE (optional) schema. 72 | * @param items the items to set in the cash 73 | */ 74 | export function buildItemsCacheMap( 75 | cachePrefix: string, 76 | tableName: string, 77 | keySchema: DynamoDB.DocumentClient.KeySchema, 78 | items: T[] 79 | ): CacheKeyItemMap { 80 | return items.reduce((accum: {}, curr: T) => { 81 | const key: DynamoDB.DocumentClient.Key = buildKey(keySchema, curr); 82 | const cacheKey = buildCacheKey(cachePrefix, tableName, key); 83 | return { 84 | ...accum, 85 | [cacheKey]: curr, 86 | }; 87 | }, {}); 88 | } 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2017", "dom"], 4 | "removeComments": true, 5 | "moduleResolution": "node", 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "module": "commonjs", 11 | "target": "es2017", 12 | "outDir": "./lib", 13 | "esModuleInterop": true 14 | }, 15 | "include": ["./**/*.ts"], 16 | "exclude": ["node_modules/**/*", "lib/**/*", ".vscode/**/*"] 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | context: __dirname, 7 | entry: './src/index.ts', 8 | resolve: { 9 | extensions: ['.mjs', '.json', '.ts'], 10 | symlinks: false, 11 | cacheWithContext: false, 12 | }, 13 | output: { 14 | libraryTarget: 'commonjs', 15 | path: path.join(__dirname, 'lib'), 16 | filename: '[name].js', 17 | }, 18 | target: 'node', 19 | externals: [nodeExternals()], 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.ts?$/, 24 | loader: 'babel-loader', 25 | exclude: [ 26 | [ 27 | path.resolve(__dirname, 'node_modules'), 28 | path.resolve(__dirname, '.webpack'), 29 | path.resolve(__dirname, '.vscode'), 30 | ], 31 | ], 32 | options: { 33 | // disable type checker - we will use it in fork plugin 34 | transpileOnly: true, 35 | }, 36 | }, 37 | ], 38 | }, 39 | plugins: [new ForkTsCheckerWebpackPlugin()], 40 | }; 41 | --------------------------------------------------------------------------------