From b881f4fc2717cccd962b4c8e00dd4fa3760d7c9e Mon Sep 17 00:00:00 2001 From: eduardocosta Date: Tue, 14 May 2024 17:42:48 -0300 Subject: [PATCH 1/2] feat: :zap: adding query operator with index option Adding query type to get information from dynamo and using index too. Close #69 --- .../service/serverless.yml | 30 +++++++ .../dynamodb/multiple-integrations/tests.js | 34 ++++++++ lib/apiGateway/schema.js | 6 +- lib/apiGateway/validate.test.js | 82 ++++++++++++++++++- .../dynamodb/compileIamRoleToDynamodb.js | 7 +- .../dynamodb/compileIamRoleToDynamodb.test.js | 23 ++++++ .../dynamodb/compileMethodsToDynamodb.js | 80 ++++++++++++++++++ .../dynamodb/compileMethodsToDynamodb.test.js | 78 ++++++++++++++++++ 8 files changed, 333 insertions(+), 7 deletions(-) diff --git a/__tests__/integration/dynamodb/multiple-integrations/service/serverless.yml b/__tests__/integration/dynamodb/multiple-integrations/service/serverless.yml index 39c26c5..d663acc 100644 --- a/__tests__/integration/dynamodb/multiple-integrations/service/serverless.yml +++ b/__tests__/integration/dynamodb/multiple-integrations/service/serverless.yml @@ -37,6 +37,20 @@ custom: queryStringParam: sort attributeType: S cors: true + - dynamodb: + path: /dynamodb/index/{indexRange}/{indexSort} + method: get + indexName: myTestIndex + tableName: + Ref: MyMuTestTable + action: Query + hashKey: + pathParam: indexSort + attributeType: S + rangeKey: + pathParam: indexRange + attributeType: S + cors: true - dynamodb: path: /dynamodb/{id} method: delete @@ -62,11 +76,27 @@ resources: AttributeType: S - AttributeName: sort AttributeType: S + - AttributeName: indexRange + AttributeType: S + - AttributeName: indexSort + AttributeType: S KeySchema: - AttributeName: id KeyType: HASH - AttributeName: sort KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: myTestIndex + KeySchema: + - AttributeName: indexSort + KeyType: HASH + - AttributeName: indexRange + KeyType: RANGE + Projection: + ProjectionType: ALL + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 diff --git a/__tests__/integration/dynamodb/multiple-integrations/tests.js b/__tests__/integration/dynamodb/multiple-integrations/tests.js index 03b1cc9..8a8fd5c 100644 --- a/__tests__/integration/dynamodb/multiple-integrations/tests.js +++ b/__tests__/integration/dynamodb/multiple-integrations/tests.js @@ -89,6 +89,40 @@ describe('Multiple Dynamodb Proxies Integration Test', () => { }) }) + it('should get correct response from dynamodb Query with index', async () => { + await putDynamodbItem( + tableName, + _.merge( + {}, + { [hashKeyAttribute]: hashKey, [rangeKeyAttribute]: sortKey }, + { + message: { S: 'testtest' }, + indexRange: { S: 'rangeTest' }, + indexSort: { S: 'sortTest' } + } + ) + ) + const getEndpoint = `${endpoint}/dynamodb/index/rangeTest/sortTest` + + const getResponse = await fetch(getEndpoint, { + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }) + expect(getResponse.headers.get('access-control-allow-origin')).to.deep.equal('*') + expect(getResponse.status).to.be.equal(200) + + const item = await getResponse.json() + expect(item).to.be.deep.equal([ + { + id: hashKey.S, + sort: sortKey.S, + message: 'testtest', + indexRange: 'rangeTest', + indexSort: 'sortTest' + } + ]) + }) + it('should get correct response from dynamodb DeleteItem action endpoint', async () => { await putDynamodbItem( tableName, diff --git a/lib/apiGateway/schema.js b/lib/apiGateway/schema.js index a682797..5331337 100644 --- a/lib/apiGateway/schema.js +++ b/lib/apiGateway/schema.js @@ -148,12 +148,13 @@ const partitionKey = Joi.alternatives().try([ ) ]) -const allowedDynamodbActions = ['PutItem', 'GetItem', 'DeleteItem'] +const allowedDynamodbActions = ['PutItem', 'GetItem', 'DeleteItem', 'Query'] const dynamodbDefaultKeyScheme = Joi.object() .keys({ pathParam: Joi.string(), queryStringParam: Joi.string(), - attributeType: Joi.string().required() + attributeType: Joi.string().required(), + queryOperator: Joi.string().valid(['=', '<', '<=', '>', '>=']) }) .xor('pathParam', 'queryStringParam') .error( @@ -298,6 +299,7 @@ const proxiesSchemas = { .required(), tableName: stringOrRef.required(), condition: Joi.string(), + indexName: Joi.string(), hashKey: dynamodbDefaultKeyScheme.required(), rangeKey: dynamodbDefaultKeyScheme, requestParameters, diff --git a/lib/apiGateway/validate.test.js b/lib/apiGateway/validate.test.js index 77869c9..4ffb5a9 100644 --- a/lib/apiGateway/validate.test.js +++ b/lib/apiGateway/validate.test.js @@ -2209,7 +2209,7 @@ describe('#validateServiceProxies()', () => { } expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.throw( - 'child "action" fails because ["action" must be one of [PutItem, GetItem, DeleteItem]' + 'child "action" fails because ["action" must be one of [PutItem, GetItem, DeleteItem, Query]' ) }) @@ -2271,6 +2271,86 @@ describe('#validateServiceProxies()', () => { expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.not.throw() }) + + it('should throw error if the "indexName" parameter is not a string', () => { + serverlessApigatewayServiceProxy.serverless.service.custom = { + apiGatewayServiceProxies: [ + { + dynamodb: { + tableName: 'yourTable', + path: 'dynamodb', + method: 'put', + action: 'PutItem', + hashKey: { pathParam: 'id', attributeType: 'S' }, + indexName: { a: 'b' } + } + } + ] + } + + expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.throw( + 'child "indexName" fails because ["indexName" must be a string]' + ) + }) + + it('should not throw error if the "indexName" parameter is a string', () => { + serverlessApigatewayServiceProxy.serverless.service.custom = { + apiGatewayServiceProxies: [ + { + dynamodb: { + tableName: 'yourTable', + path: 'dynamodb', + method: 'put', + action: 'PutItem', + hashKey: { pathParam: 'id', attributeType: 'S' }, + indexName: 'test' + } + } + ] + } + + expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.not.throw() + }) + + it('should not throw error if the "attributeType" parameter is one of the valid ones', () => { + serverlessApigatewayServiceProxy.serverless.service.custom = { + apiGatewayServiceProxies: [ + { + dynamodb: { + tableName: 'yourTable', + path: 'dynamodb', + method: 'put', + action: 'PutItem', + hashKey: { pathParam: 'id', attributeType: 'S', queryOperator: '=' }, + indexName: 'test' + } + } + ] + } + + expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.not.throw() + }) + + it('should throw error if the "attributeType" parameter is not of the valid ones', () => { + serverlessApigatewayServiceProxy.serverless.service.custom = { + apiGatewayServiceProxies: [ + { + dynamodb: { + tableName: 'yourTable', + path: 'dynamodb', + method: 'put', + action: 'PutItem', + hashKey: { pathParam: 'id', attributeType: 'S', queryOperator: '?' }, + indexName: 'test' + } + } + ] + } + + expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.throw( + 'child "dynamodb" fails because [child "hashKey" fails because [child "queryOperator" fails because ["queryOperator" must be one of [=, <, <=, >, >=]]]]' + ) + }) }) describe('eventbridge', () => { diff --git a/lib/package/dynamodb/compileIamRoleToDynamodb.js b/lib/package/dynamodb/compileIamRoleToDynamodb.js index b1a632b..2fe1dc0 100644 --- a/lib/package/dynamodb/compileIamRoleToDynamodb.js +++ b/lib/package/dynamodb/compileIamRoleToDynamodb.js @@ -21,14 +21,13 @@ module.exports = { } const permissions = tableNameActions.map(({ tableName, action }) => { + const baiscArn = + 'arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tableName}' return { Effect: 'Allow', Action: `dynamodb:${action}`, Resource: { - 'Fn::Sub': [ - 'arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tableName}', - { tableName } - ] + 'Fn::Sub': [action === 'Query' ? baiscArn + '/*' : baiscArn, { tableName }] } } }) diff --git a/lib/package/dynamodb/compileIamRoleToDynamodb.test.js b/lib/package/dynamodb/compileIamRoleToDynamodb.test.js index 5c4ee88..e074907 100644 --- a/lib/package/dynamodb/compileIamRoleToDynamodb.test.js +++ b/lib/package/dynamodb/compileIamRoleToDynamodb.test.js @@ -59,6 +59,17 @@ describe('#compileIamRoleToDynamodb()', () => { queryStringParam: 'id' } } + }, + { + dynamodb: { + path: '/dynamodb/v1', + tableName: 'mytable', + method: 'get', + action: 'Query', + hashKey: { + queryStringParam: 'id' + } + } } ] } @@ -126,6 +137,18 @@ describe('#compileIamRoleToDynamodb()', () => { } ] } + }, + { + Effect: 'Allow', + Action: 'dynamodb:Query', + Resource: { + 'Fn::Sub': [ + 'arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tableName}/*', + { + tableName: 'mytable' + } + ] + } } ] } diff --git a/lib/package/dynamodb/compileMethodsToDynamodb.js b/lib/package/dynamodb/compileMethodsToDynamodb.js index ef1b690..bf6a4f1 100644 --- a/lib/package/dynamodb/compileMethodsToDynamodb.js +++ b/lib/package/dynamodb/compileMethodsToDynamodb.js @@ -207,6 +207,11 @@ module.exports = { 'application/json': this.getGetItemDefaultDynamodbResponseTemplate(), 'application/x-www-form-urlencoded': this.getGetItemDefaultDynamodbResponseTemplate() } + } else if (http.action === 'Query') { + return { + 'application/json': this.getQueryItemDefaultDynamodbResponseTemplate(), + 'application/x-www-form-urlencoded': this.getQueryItemDefaultDynamodbResponseTemplate() + } } return {} @@ -220,6 +225,27 @@ module.exports = { ` }, + getQueryItemDefaultDynamodbResponseTemplate() { + return oneLineTrim` + #set($inputRoot = $input.path('$')) + [ + #if($inputRoot.Count > 0) + #foreach($item in $inputRoot.Items) + { + #foreach($key in $item.keySet()) + #set ($value = $item.get($key)) + #foreach( $type in $value.keySet()) + "$key":"$value.get($type)" + #end + #if($foreach.hasNext()) , #end + #end + } #if($foreach.hasNext()) , #end + #end + #end + ] + ` + }, + buildDefaultDynamodbRequestTemplate(http) { switch (http.action) { case 'PutItem': @@ -228,6 +254,8 @@ module.exports = { return this.buildDefaultDynamodbGetItemRequestTemplate(http) case 'DeleteItem': return this.buildDefaultDynamodbDeleteItemRequestTemplate(http) + case 'Query': + return this.buildDefaultDynamodbQueryItemRequestTemplate(http) } }, @@ -279,6 +307,58 @@ module.exports = { } }, + buildDefaultDynamodbQueryItemRequestTemplate(http) { + const fuSubValues = { + TableName: http.tableName + } + + let requestTemplate = '{"TableName": "${TableName}",' + if (_.has(http, 'indexName')) { + requestTemplate += '"IndexName": "${IndexName}",' + Object.assign(fuSubValues, { IndexName: http.indexName }) + } + + let keyConditionExpression = '"KeyConditionExpression": "' + let expressionAttributeValues = '"ExpressionAttributeValues" : {' + let expressionAttributeNames = '"ExpressionAttributeNames" : {' + + if (_.has(http, 'hashKey')) { + if (_.has(http.hashKey, 'queryOperator')) { + keyConditionExpression += '#dynamo_${HashKey} ${QueryOperatorHashKey} :v1' + Object.assign(fuSubValues, { QueryOperatorHashKey: http.hashKey.queryOperator }) + } else { + keyConditionExpression += '#dynamo_${HashKey} = :v1' + } + expressionAttributeValues += '":v1": {"${HashAttributeType}":"${HashAttributeValue}"}' + expressionAttributeNames += '"#dynamo_${HashKey}": "${HashKey}"' + Object.assign(fuSubValues, this.getDynamodbHashkeyFnSubValues(http)) + } + + if (_.has(http, 'rangeKey')) { + if (_.has(http.rangeKey, 'queryOperator')) { + keyConditionExpression += '#dynamo_${RangeKey} ${QueryOperatorRangeKey} :v1' + Object.assign(fuSubValues, { QueryOperatorRangeKey: http.rangeKey.queryOperator }) + } else { + keyConditionExpression += ' and #dynamo_${RangeKey} = :v2' + } + expressionAttributeValues += ',":v2": {"${RangeAttributeType}":"${RangeAttributeValue}"}' + expressionAttributeNames += ', "#dynamo_${RangeKey}": "${RangeKey}"' + + Object.assign(fuSubValues, this.getDynamodbRangekeyFnSubValues(http)) + } + keyConditionExpression += '",' + expressionAttributeValues += '},' + expressionAttributeNames += '}' + + requestTemplate += keyConditionExpression + requestTemplate += expressionAttributeValues + requestTemplate += expressionAttributeNames + requestTemplate += '}' + return { + 'Fn::Sub': [`${requestTemplate}`, fuSubValues] + } + }, + buildDefaultDynamodbPutItemRequestTemplate(http) { const fuSubValues = { TableName: http.tableName diff --git a/lib/package/dynamodb/compileMethodsToDynamodb.test.js b/lib/package/dynamodb/compileMethodsToDynamodb.test.js index c363d5a..1a9de78 100644 --- a/lib/package/dynamodb/compileMethodsToDynamodb.test.js +++ b/lib/package/dynamodb/compileMethodsToDynamodb.test.js @@ -211,6 +211,43 @@ describe('#compileMethodsToDynamodb()', () => { }) } + const testQueryItem = (params, intRequestTemplates, intResponseTemplates) => { + const http = _.merge( + {}, + { + path: 'dynamodb', + method: 'get', + tableName: { + Ref: 'MyTable' + }, + auth: { authorizationType: 'NONE' } + }, + params + ) + + const requestParams = {} + + const uri = { + 'Fn::Sub': [ + 'arn:${AWS::Partition}:apigateway:${AWS::Region}:dynamodb:action/${action}', + { + action: 'Query' + } + ] + } + + testSingleProxy({ + http, + logicalId: `ApiGatewayMethoddynamodb${http.method.substring(0, 1).toUpperCase() + + http.method.substring(1)}`, + method: http.method.toUpperCase(), + requestParams, + intRequestTemplates, + uri, + intResponseTemplates + }) + } + const testDeleteItem = (params, intRequestTemplates, intResponseTemplates) => { const http = _.merge( {}, @@ -570,6 +607,47 @@ describe('#compileMethodsToDynamodb()', () => { }) }) + describe('#query method', () => { + it('should create corresponding resources when indexname is given', () => { + const intRequestTemplate = { + 'Fn::Sub': [ + '{"TableName": "${TableName}","IndexName": "${IndexName}","KeyConditionExpression": "#dynamo_${HashKey} = :v1 and #dynamo_${RangeKey} = :v2","ExpressionAttributeValues" : {":v1": {"${HashAttributeType}":"${HashAttributeValue}"},":v2": {"${RangeAttributeType}":"${RangeAttributeValue}"}},"ExpressionAttributeNames" : {"#dynamo_${HashKey}": "${HashKey}", "#dynamo_${RangeKey}": "${RangeKey}"}}', + { + TableName: { + Ref: 'MyTable' + }, + HashKey: 'id', + HashAttributeType: 'S', + HashAttributeValue: '$input.params().path.id', + RangeKey: 'range', + RangeAttributeType: 'S', + RangeAttributeValue: '$input.params().querystring.range', + IndexName: 'myIndex' + } + ] + } + const intResponseTemplate = + '#set($inputRoot = $input.path(\'$\')) [#if($inputRoot.Count > 0)#foreach($item in $inputRoot.Items){#foreach($key in $item.keySet())#set ($value = $item.get($key))#foreach( $type in $value.keySet())"$key":"$value.get($type)"#end#if($foreach.hasNext()) , #end#end} #if($foreach.hasNext()) , #end#end#end]' + testQueryItem( + { + hashKey: { pathParam: 'id', attributeType: 'S' }, + rangeKey: { queryStringParam: 'range', attributeType: 'S' }, + path: '/dynamodb/{id}', + action: 'Query', + indexName: 'myIndex' + }, + { + 'application/json': intRequestTemplate, + 'application/x-www-form-urlencoded': intRequestTemplate + }, + { + 'application/json': intResponseTemplate, + 'application/x-www-form-urlencoded': intResponseTemplate + } + ) + }) + }) + describe('#delete method', () => { it('should create corresponding resources when hashkey is given with a path parameter', () => { const intRequestTemplate = { From 567075d4a6272b88edbbc61cecb3f23d115ea9b3 Mon Sep 17 00:00:00 2001 From: eduardocosta Date: Tue, 14 May 2024 17:49:09 -0300 Subject: [PATCH 2/2] feat: :zap: adding documentation Adding documentation to explain how to use it Close #69 --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/README.md b/README.md index 612ef17..305646f 100644 --- a/README.md +++ b/README.md @@ -542,6 +542,19 @@ custom: attributeType: S action: GetItem cors: true + - dynamodb: + path: /dynamodb + method: get + tableName: { Ref: 'YourTable' } + indexName: 'myIndex' + hashKey: + queryStringParam: id # use query string parameter + attributeType: S + rangeKey: + queryStringParam: sort + attributeType: S + action: Query + cors: true - dynamodb: path: /dynamodb/{id} method: delete @@ -644,6 +657,35 @@ custom: #set($item = $input.path('$.Item')){ "Item": $item } ``` +#### Using Query with Index(GSI) +If you want to use GSI to get some information, you must use the Query action and add the attribute "indexName" +with the name of the Index that you want to use. +When you use it, the action on Dynamo will transform in Query action and it will use the HashKey and, if provided, the RangeKey to create the query unsing the equal operator. + +You can change the operator for other allowed by AWS, just add the attribute "queryOperator" to the specific +key (Rash or Hange) information: + +```yaml + - dynamodb: + path: /dynamodb + method: query + tableName: { Ref: 'YourTable' } + indexName: 'myIndex' + hashKey: + queryStringParam: id # use query string parameter + attributeType: S + queryOperator: ">" + rangeKey: + queryStringParam: sort + attributeType: S + queryOperator: "<" + action: Query + cors: true + +``` + +If used some reponse template customazitaion, be aware that the response is different from the GetItem +returning an array containing the atributte Items ### EventBridge