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 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 = {