diff --git a/packages/nodes-base/nodes/Aws/DynamoDB/AwsDynamoDB.node.ts b/packages/nodes-base/nodes/Aws/DynamoDB/AwsDynamoDB.node.ts new file mode 100644 index 0000000000..39d170ad4c --- /dev/null +++ b/packages/nodes-base/nodes/Aws/DynamoDB/AwsDynamoDB.node.ts @@ -0,0 +1,374 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeParameterValue, +} from 'n8n-workflow'; + +import { + awsApiRequest, + awsApiRequestAllItems, +} from './GenericFunctions'; + +import { + itemFields, + itemOperations, +} from './ItemDescription'; + +import { + FieldsUiValues, + IAttributeNameUi, + IAttributeValueUi, + IRequestBody, + PutItemUi, +} from './types'; + +import { + adjustExpressionAttributeName, + adjustExpressionAttributeValues, + adjustPutItem, + decodeItem, + simplify, +} from './utils'; + +export class AwsDynamoDB implements INodeType { + description: INodeTypeDescription = { + displayName: 'AWS DynamoDB', + name: 'awsDynamoDb', + icon: 'file:dynamodb.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the AWS DynamoDB API', + defaults: { + name: 'AWS DynamoDB', + color: '#2273b9', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'aws', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Item', + value: 'item', + }, + ], + default: 'item', + }, + ...itemOperations, + ...itemFields, + ], + }; + + methods = { + loadOptions: { + async getTables(this: ILoadOptionsFunctions) { + const headers = { + 'Content-Type': 'application/x-amz-json-1.0', + 'X-Amz-Target': 'DynamoDB_20120810.ListTables', + }; + + const responseData = await awsApiRequest.call(this, 'dynamodb', 'POST', '/', {}, headers); + + return responseData.TableNames.map((table: string) => ({ name: table, value: table })); + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + let responseData; + const returnData: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + + try { + + if (resource === 'item') { + + if (operation === 'upsert') { + + // ---------------------------------- + // upsert + // ---------------------------------- + + // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html + + const eavUi = this.getNodeParameter('additionalFields.eavUi.eavValues', i, []) as IAttributeValueUi[]; + const conditionExpession = this.getNodeParameter('conditionExpression', i, '') as string; + const eanUi = this.getNodeParameter('additionalFields.eanUi.eanValues', i, []) as IAttributeNameUi[]; + + const body: IRequestBody = { + TableName: this.getNodeParameter('tableName', i) as string, + }; + + const expressionAttributeValues = adjustExpressionAttributeValues(eavUi); + + if (Object.keys(expressionAttributeValues).length) { + body.ExpressionAttributeValues = expressionAttributeValues; + } + + const expressionAttributeName = adjustExpressionAttributeName(eanUi); + + if (Object.keys(expressionAttributeName).length) { + body.expressionAttributeNames = expressionAttributeName; + } + + if (conditionExpession) { + body.ConditionExpression = conditionExpession; + } + + const dataToSend = this.getNodeParameter('dataToSend', 0) as 'defineBelow' | 'autoMapInputData'; + const item: { [key: string]: string } = {}; + + if (dataToSend === 'autoMapInputData') { + + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputsToIgnore = rawInputsToIgnore.split(',').map(c => c.trim()); + + for (const key of incomingKeys) { + if (inputsToIgnore.includes(key)) continue; + item[key] = items[i].json[key] as string; + } + + body.Item = adjustPutItem(item as PutItemUi); + + } else { + + const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as FieldsUiValues; + fields.forEach(({ fieldId, fieldValue }) => item[fieldId] = fieldValue); + body.Item = adjustPutItem(item as PutItemUi); + + } + + const headers = { + 'Content-Type': 'application/x-amz-json-1.0', + 'X-Amz-Target': 'DynamoDB_20120810.PutItem', + }; + + responseData = await awsApiRequest.call(this, 'dynamodb', 'POST', '/', body, headers); + responseData = item; + + } else if (operation === 'delete') { + + // ---------------------------------- + // delete + // ---------------------------------- + + // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteItem.html + + // tslint:disable-next-line: no-any + const body: { [key: string]: any } = { + TableName: this.getNodeParameter('tableName', i) as string, + Key: {}, + ReturnValues: this.getNodeParameter('returnValues', 0) as string, + }; + + const eavUi = this.getNodeParameter('additionalFields.eavUi.eavValues', i, []) as IAttributeValueUi[]; + const eanUi = this.getNodeParameter('additionalFields.eanUi.eanValues', i, []) as IAttributeNameUi[]; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const simple = this.getNodeParameter('simple', 0, false) as boolean; + + const items = this.getNodeParameter('keysUi.keyValues', i, []) as [{ key: string, type: string, value: string }]; + + for (const item of items) { + let value = item.value as NodeParameterValue; + // All data has to get send as string even numbers + // @ts-ignore + value = ![null, undefined].includes(value) ? value?.toString() : ''; + body.Key[item.key as string] = { [item.type as string]: value }; + } + + const expressionAttributeValues = adjustExpressionAttributeValues(eavUi); + + if (Object.keys(expressionAttributeValues).length) { + body.ExpressionAttributeValues = expressionAttributeValues; + } + + const expressionAttributeName = adjustExpressionAttributeName(eanUi); + + if (Object.keys(expressionAttributeName).length) { + body.expressionAttributeNames = expressionAttributeName; + } + + const headers = { + 'Content-Type': 'application/x-amz-json-1.0', + 'X-Amz-Target': 'DynamoDB_20120810.DeleteItem', + }; + + if (additionalFields.conditionExpression) { + body.ConditionExpression = additionalFields.conditionExpression as string; + } + + responseData = await awsApiRequest.call(this, 'dynamodb', 'POST', '/', body, headers); + + if (!Object.keys(responseData).length) { + responseData = { success: true }; + } else if (simple === true) { + responseData = decodeItem(responseData.Attributes); + } + + } else if (operation === 'get') { + + // ---------------------------------- + // get + // ---------------------------------- + + // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_GetItem.html + + const tableName = this.getNodeParameter('tableName', 0) as string; + const simple = this.getNodeParameter('simple', 0, false) as boolean; + const select = this.getNodeParameter('select', 0) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const eanUi = this.getNodeParameter('additionalFields.eanUi.eanValues', i, []) as IAttributeNameUi[]; + + // tslint:disable-next-line: no-any + const body: { [key: string]: any } = { + TableName: tableName, + Key: {}, + Select: select, + }; + + Object.assign(body, additionalFields); + + const expressionAttributeName = adjustExpressionAttributeName(eanUi); + + if (Object.keys(expressionAttributeName).length) { + body.expressionAttributeNames = expressionAttributeName; + } + + if (additionalFields.readType) { + body.ConsistentRead = additionalFields.readType === 'stronglyConsistentRead'; + } + + if (additionalFields.projectionExpression) { + body.ProjectionExpression = additionalFields.projectionExpression as string; + } + + const items = this.getNodeParameter('keysUi.keyValues', i, []) as IDataObject[]; + + for (const item of items) { + let value = item.value as NodeParameterValue; + // All data has to get send as string even numbers + // @ts-ignore + value = ![null, undefined].includes(value) ? value?.toString() : ''; + body.Key[item.key as string] = { [item.type as string]: value }; + } + + const headers = { + 'X-Amz-Target': 'DynamoDB_20120810.GetItem', + 'Content-Type': 'application/x-amz-json-1.0', + }; + + responseData = await awsApiRequest.call(this, 'dynamodb', 'POST', '/', body, headers); + + responseData = responseData.Item; + + if (simple && responseData) { + responseData = decodeItem(responseData); + } + + } else if (operation === 'getAll') { + + // ---------------------------------- + // getAll + // ---------------------------------- + + // https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html + + const eavUi = this.getNodeParameter('eavUi.eavValues', i, []) as IAttributeValueUi[]; + const simple = this.getNodeParameter('simple', 0, false) as boolean; + const select = this.getNodeParameter('select', 0) as string; + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const eanUi = this.getNodeParameter('additionalFields.eanUi.eanValues', i, []) as IAttributeNameUi[]; + + const body: IRequestBody = { + TableName: this.getNodeParameter('tableName', i) as string, + KeyConditionExpression: this.getNodeParameter('keyConditionExpression', i) as string, + ExpressionAttributeValues: adjustExpressionAttributeValues(eavUi), + }; + + const { + indexName, + projectionExpression, + } = this.getNodeParameter('options', i) as { + indexName: string; + projectionExpression: string; + }; + + const expressionAttributeName = adjustExpressionAttributeName(eanUi); + + if (Object.keys(expressionAttributeName).length) { + body.expressionAttributeNames = expressionAttributeName; + } + + if (indexName) { + body.IndexName = indexName; + } + + if (projectionExpression && select !== 'COUNT') { + body.ProjectionExpression = projectionExpression; + } + + if (select) { + body.Select = select; + } + + const headers = { + 'Content-Type': 'application/json', + 'X-Amz-Target': 'DynamoDB_20120810.Query', + }; + + if (returnAll === true && select !== 'COUNT') { + responseData = await awsApiRequestAllItems.call(this, 'dynamodb', 'POST', '/', body, headers); + } else { + body.Limit = this.getNodeParameter('limit', 0, 1) as number; + responseData = await awsApiRequest.call(this, 'dynamodb', 'POST', '/', body, headers); + if (select !== 'COUNT') { + responseData = responseData.Items; + } + } + if (simple === true) { + responseData = responseData.map(simplify); + } + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + } + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + + throw error; + } + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Aws/DynamoDB/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/DynamoDB/GenericFunctions.ts new file mode 100644 index 0000000000..e9a5a0316b --- /dev/null +++ b/packages/nodes-base/nodes/Aws/DynamoDB/GenericFunctions.ts @@ -0,0 +1,108 @@ +import { + URL, +} from 'url'; + +import { + sign, +} from 'aws4'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + ICredentialDataDecryptedObject, + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + IRequestBody, +} from './types'; + +function getEndpointForService(service: string, credentials: ICredentialDataDecryptedObject): string { + let endpoint; + if (service === 'lambda' && credentials.lambdaEndpoint) { + endpoint = credentials.lambdaEndpoint; + } else if (service === 'sns' && credentials.snsEndpoint) { + endpoint = credentials.snsEndpoint; + } else { + endpoint = `https://${service}.${credentials.region}.amazonaws.com`; + } + return (endpoint as string).replace('{region}', credentials.region as string); +} + +export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: object | IRequestBody, headers?: object): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('aws'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + // Concatenate path and instantiate URL object so it parses correctly query strings + const endpoint = new URL(getEndpointForService(service, credentials) + path); + + const options = sign({ + uri: endpoint, + service, + region: credentials.region, + method, + path: '/', + headers: { ...headers }, + body: JSON.stringify(body), + }, { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + }); + + try { + return JSON.parse(await this.helpers.request!(options)); + } catch (error) { + const errorMessage = (error.response && error.response.body.message) || (error.response && error.response.body.Message) || error.message; + if (error.statusCode === 403) { + if (errorMessage === 'The security token included in the request is invalid.') { + throw new Error('The AWS credentials are not valid!'); + } else if (errorMessage.startsWith('The request signature we calculated does not match the signature you provided')) { + throw new Error('The AWS credentials are not valid!'); + } + } + + throw new Error(`AWS error response [${error.statusCode}]: ${errorMessage}`); + } +} + + +export async function awsApiRequestAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: IRequestBody, headers?: object): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await awsApiRequest.call(this, service, method, path, body, headers); + if (responseData.LastEvaluatedKey) { + body!.ExclusiveStartKey = responseData.LastEvaluatedKey; + } + returnData.push(...responseData.Items); + } while ( + responseData.LastEvaluatedKey !== undefined + ); + + return returnData; +} + +export function copyInputItem(item: INodeExecutionData, properties: string[]): IDataObject { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject; + newItem = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; +} diff --git a/packages/nodes-base/nodes/Aws/DynamoDB/ItemDescription.ts b/packages/nodes-base/nodes/Aws/DynamoDB/ItemDescription.ts new file mode 100644 index 0000000000..47a7a02599 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/DynamoDB/ItemDescription.ts @@ -0,0 +1,920 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const itemOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'item', + ], + }, + }, + options: [ + { + name: 'Create or Update', + value: 'upsert', + description: 'Create a new record, or update the current one if it already exists (upsert/put)', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an item', + }, + { + name: 'Get', + value: 'get', + description: 'Get an item', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all items', + }, + ], + default: 'upsert', + }, +] as INodeProperties[]; + +export const itemFields = [ + // ---------------------------------- + // all + // ---------------------------------- + { + displayName: 'Table Name', + name: 'tableName', + description: 'Table to operate on', + type: 'options', + required: true, + displayOptions: { + show: { + resource: [ + 'item', + ], + }, + }, + default: [], + typeOptions: { + loadOptionsMethod: 'getTables', + }, + }, + + // ---------------------------------- + // upsert + // ---------------------------------- + { + displayName: 'Data to Send', + name: 'dataToSend', + type: 'options', + options: [ + { + name: 'Auto-map Input Data to Columns', + value: 'autoMapInputData', + description: 'Use when node input properties match destination column names', + }, + { + name: 'Define Below for Each Column', + value: 'defineBelow', + description: 'Set the value for each destination column', + }, + ], + displayOptions: { + show: { + operation: [ + 'upsert', + ], + }, + }, + default: 'defineBelow', + description: 'Whether to insert the input data this node receives in the new row', + }, + { + displayName: 'Inputs to Ignore', + name: 'inputsToIgnore', + type: 'string', + displayOptions: { + show: { + operation: [ + 'upsert', + ], + dataToSend: [ + 'autoMapInputData', + ], + }, + }, + default: '', + required: false, + description: 'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.', + placeholder: 'Enter properties...', + }, + { + displayName: 'Fields to Send', + name: 'fieldsUi', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Field to Send', + multipleValues: true, + }, + displayOptions: { + show: { + operation: [ + 'upsert', + ], + dataToSend: [ + 'defineBelow', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Field', + name: 'fieldValues', + values: [ + { + displayName: 'Field ID', + name: 'fieldId', + type: 'string', + default: '', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'upsert', + ], + }, + }, + options: [ + { + displayName: 'Expression Attribute Values', + name: 'eavUi', + description: 'Substitution tokens for attribute names in an expression.
Only needed when the parameter "condition expression" is set', + placeholder: 'Add Attribute Value', + type: 'fixedCollection', + default: '', + required: true, + typeOptions: { + multipleValues: true, + minValue: 1, + }, + options: [ + { + name: 'eavValues', + displayName: 'Expression Attribute Vaue', + values: [ + { + displayName: 'Attribute', + name: 'attribute', + type: 'string', + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Number', + value: 'N', + }, + { + name: 'String', + value: 'S', + }, + ], + default: 'S', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Condition Expression', + name: 'conditionExpression', + type: 'string', + default: '', + description: 'A condition that must be satisfied in order for a conditional upsert to succeed. View details', + }, + { + displayName: 'Expression Attribute Names', + name: 'eanUi', + placeholder: 'Add Expression', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'eanValues', + displayName: 'Expression', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + description: 'One or more substitution tokens for attribute names in an expression. View details', + }, + ], + }, + + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Return', + name: 'returnValues', + type: 'options', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'delete', + ], + }, + }, + options: [ + { + name: 'Attribute Values', + value: 'ALL_OLD', + description: 'The content of the old item is returned', + }, + { + name: 'Nothing', + value: 'NONE', + description: 'Nothing is returned', + }, + ], + default: 'NONE', + description: 'Use ReturnValues if you want to get the item attributes as they appeared before they were deleted', + }, + { + displayName: 'Keys', + name: 'keysUi', + type: 'fixedCollection', + placeholder: 'Add Key', + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'delete', + ], + }, + }, + options: [ + { + displayName: 'Key', + name: 'keyValues', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Binary', + value: 'B', + }, + { + name: 'Number', + value: 'N', + }, + { + name: 'String', + value: 'S', + }, + ], + default: 'S', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + description: 'Item\'s primary key. For example, with a simple primary key, you only need to provide a value for the partition key.
For a composite primary key, you must provide values for both the partition key and the sort key', + }, + { + displayName: 'Simple', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'delete', + ], + returnValues: [ + 'ALL_OLD', + ], + }, + }, + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'delete', + ], + }, + }, + options: [ + { + displayName: 'Condition Expression', + name: 'conditionExpression', + type: 'string', + default: '', + description: 'A condition that must be satisfied in order for a conditional delete to succeed', + }, + { + displayName: 'Expression Attribute Names', + name: 'eanUi', + placeholder: 'Add Expression', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'eanValues', + displayName: 'Expression', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + description: 'One or more substitution tokens for attribute names in an expression. Check Info', + }, + { + displayName: 'Expression Attribute Values', + name: 'expressionAttributeUi', + description: 'Substitution tokens for attribute names in an expression.
Only needed when the parameter "condition expression" is set', + placeholder: 'Add Attribute Value', + type: 'fixedCollection', + default: '', + required: true, + typeOptions: { + multipleValues: true, + minValue: 1, + }, + options: [ + { + name: 'expressionAttributeValues', + displayName: 'Expression Attribute Vaue', + values: [ + { + displayName: 'Attribute', + name: 'attribute', + type: 'string', + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Number', + value: 'N', + }, + { + name: 'String', + value: 'S', + }, + ], + default: 'S', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + }, + ], + }, + + // ---------------------------------- + // get + // ---------------------------------- + { + displayName: 'Select', + name: 'select', + type: 'options', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'get', + ], + }, + }, + options: [ + { + name: 'All Attributes', + value: 'ALL_ATTRIBUTES', + }, + { + name: 'All Projected Attributes', + value: 'ALL_PROJECTED_ATTRIBUTES', + }, + { + name: 'Specific Attributes', + value: 'SPECIFIC_ATTRIBUTES', + description: 'Select them in Attributes to Select under Additional Fields', + }, + ], + default: 'ALL_ATTRIBUTES', + }, + { + displayName: 'Simple', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'get', + ], + select: [ + 'ALL_PROJECTED_ATTRIBUTES', + 'ALL_ATTRIBUTES', + ], + }, + }, + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'Keys', + name: 'keysUi', + type: 'fixedCollection', + placeholder: 'Add Key', + default: {}, + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'get', + ], + }, + }, + options: [ + { + displayName: 'Key', + name: 'keyValues', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Binary', + value: 'B', + }, + { + name: 'Number', + value: 'N', + }, + { + name: 'String', + value: 'S', + }, + ], + default: 'S', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + description: 'Item\'s primary key. For example, with a simple primary key, you only need to provide a value for the partition key.
For a composite primary key, you must provide values for both the partition key and the sort key', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'get', + ], + }, + }, + options: [ + { + displayName: 'Attributes to Select', + name: 'projectionExpression', + type: 'string', + placeholder: 'id, name', + default: '', + }, + { + displayName: 'Expression Attribute Names', + name: 'eanUi', + placeholder: 'Add Expression', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'eanValues', + displayName: 'Expression', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + description: 'One or more substitution tokens for attribute names in an expression. View details', + }, + { + displayName: 'Read Type', + name: 'readType', + type: 'options', + options: [ + { + name: 'Strongly consistent read', + value: 'stronglyConsistentRead', + }, + { + name: 'Eventually consistent read', + value: 'eventuallyConsistentRead', + }, + ], + default: 'eventuallyConsistentRead', + description: 'Type of read to perform on the table. View details', + }, + ], + }, + + // ---------------------------------- + // Get All + // ---------------------------------- + { + displayName: 'Key Condition Expression', + name: 'keyConditionExpression', + description: 'Condition to determine the items to be retrieved. The condition must perform an equality test
on a single partition key value, in this format: partitionKeyName = :partitionkeyval', + placeholder: 'id = :id', + default: '', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Expression Attribute Values', + name: 'eavUi', + description: 'Substitution tokens for attribute names in an expression', + placeholder: 'Add Attribute Value', + type: 'fixedCollection', + default: '', + required: true, + typeOptions: { + multipleValues: true, + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + name: 'eavValues', + displayName: 'Expression Attribute Vaue', + values: [ + { + displayName: 'Attribute', + name: 'attribute', + type: 'string', + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Number', + value: 'N', + }, + { + name: 'String', + value: 'S', + }, + ], + default: 'S', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return', + }, + { + displayName: 'Select', + name: 'select', + type: 'options', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + name: 'All Attributes', + value: 'ALL_ATTRIBUTES', + }, + { + name: 'All Projected Attributes', + value: 'ALL_PROJECTED_ATTRIBUTES', + }, + { + name: 'Count', + value: 'COUNT', + }, + { + name: 'Specific Attributes', + value: 'SPECIFIC_ATTRIBUTES', + description: 'Select them in Attributes to Select under Additional Fields', + }, + ], + default: 'ALL_ATTRIBUTES', + }, + { + displayName: 'Simple', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'getAll', + ], + select: [ + 'ALL_PROJECTED_ATTRIBUTES', + 'ALL_ATTRIBUTES', + 'SPECIFIC_ATTRIBUTES', + ], + }, + }, + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'item', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Index Name', + name: 'indexName', + description: 'Name of the index to query. It can be any
secondary local or global index on the table.', + type: 'string', + default: '', + }, + { + displayName: 'Attributes to Select', + name: 'projectionExpression', + type: 'string', + default: '', + description: 'Text that identifies one or more attributes to retrieve from the table.
These attributes can include scalars, sets, or elements of a JSON document. The attributes
in the expression must be separated by commas', + }, + { + displayName: 'Filter Expression', + name: 'filterExpression', + type: 'string', + default: '', + description: 'Text that contains conditions that DynamoDB applies after the Query operation,
but before the data is returned. Items that do not satisfy the FilterExpression criteria
are not returned', + }, + { + displayName: 'Expression Attribute Names', + name: 'eanUi', + placeholder: 'Add Expression', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'eanValues', + displayName: 'Expression', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + description: 'One or more substitution tokens for attribute names in an expression. Check Info', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Aws/DynamoDB/dynamodb.svg b/packages/nodes-base/nodes/Aws/DynamoDB/dynamodb.svg new file mode 100644 index 0000000000..a3293dcc7e --- /dev/null +++ b/packages/nodes-base/nodes/Aws/DynamoDB/dynamodb.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/nodes-base/nodes/Aws/DynamoDB/types.d.ts b/packages/nodes-base/nodes/Aws/DynamoDB/types.d.ts new file mode 100644 index 0000000000..99eada32c7 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/DynamoDB/types.d.ts @@ -0,0 +1,79 @@ +export interface IRequestBody { + [key: string]: string | IAttributeValue | undefined | boolean | object | number; + TableName: string; + Key?: object; + IndexName?: string; + ProjectionExpression?: string; + KeyConditionExpression?: string; + ExpressionAttributeValues?: IAttributeValue; + ConsistentRead?: boolean; + FilterExpression?: string; + Limit?: number; + ExclusiveStartKey?: IAttributeValue; +} + +export interface IAttributeValue { + [attribute: string]: IAttributeValueValue; +} + +interface IAttributeValueValue { + [type: string]: string | string[] | IAttributeValue[]; +} + +export interface IAttributeValueUi { + attribute: string; + type: AttributeValueType; + value: string; +} + +export interface IAttributeNameUi { + key: string; + value: string; +} + +type AttributeValueType = + | 'B' // binary + | 'BOOL' // boolean + | 'BS' // binary set + | 'L' // list + | 'M' // map + | 'N' // number + | 'NULL' + | 'NS' // number set + | 'S' // string + | 'SS'; // string set + +export type PartitionKey = { + details: { + name: string; + type: string; + value: string; + }, +}; + +export enum EAttributeValueType { + S = 'S', SS = 'SS', M = 'M', L = 'L', NS = 'NS', N = 'N', BOOL = 'BOOL', B = 'B', BS = 'BS', NULL = 'NULL', +} + +export interface IExpressionAttributeValue { + attribute: string; + type: EAttributeValueType; + value: string; +} + +export type FieldsUiValues = Array<{ + fieldId: string; + fieldValue: string; +}>; + +export type PutItemUi = { + attribute: string; + type: 'S' | 'N'; + value: string; +}; + +export type AdjustedPutItem = { + [attribute: string]: { + [type: string]: string + } +}; diff --git a/packages/nodes-base/nodes/Aws/DynamoDB/utils.ts b/packages/nodes-base/nodes/Aws/DynamoDB/utils.ts new file mode 100644 index 0000000000..876b56c57a --- /dev/null +++ b/packages/nodes-base/nodes/Aws/DynamoDB/utils.ts @@ -0,0 +1,134 @@ +import { + IDataObject, + INodeExecutionData, +} from 'n8n-workflow'; + +import { + AdjustedPutItem, + AttributeValueType, + EAttributeValueType, + IAttributeNameUi, + IAttributeValue, + IAttributeValueUi, + IAttributeValueValue, + PutItemUi, +} from './types'; + +const addColon = (attribute: string) => attribute = attribute.charAt(0) === ':' ? attribute : `:${attribute}`; + +const addPound = (key: string) => key = key.charAt(0) === '#' ? key : `#${key}`; + +export function adjustExpressionAttributeValues(eavUi: IAttributeValueUi[]) { + const eav: IAttributeValue = {}; + + eavUi.forEach(({ attribute, type, value }) => { + eav[addColon(attribute)] = { [type]: value } as IAttributeValueValue; + }); + + return eav; +} + +export function adjustExpressionAttributeName(eanUi: IAttributeNameUi[]) { + + // tslint:disable-next-line: no-any + const ean: { [key: string]: any } = {}; + + eanUi.forEach(({ key, value }) => { + ean[addPound(key)] = { value } as IAttributeValueValue; + }); + + return ean; +} + +export function adjustPutItem(putItemUi: PutItemUi) { + const adjustedPutItem: AdjustedPutItem = {}; + + Object.entries(putItemUi).forEach(([attribute, value]) => { + let type: string; + + if (typeof value === 'boolean') { + type = 'BOOL'; + } else if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + type = 'M'; + // @ts-ignore + } else if (isNaN(value)) { + type = 'S'; + } else { + type = 'N'; + } + + adjustedPutItem[attribute] = { [type]: value.toString() }; + }); + + return adjustedPutItem; +} + +export function simplify(item: IAttributeValue): IDataObject { + const output: IDataObject = {}; + + for (const [attribute, value] of Object.entries(item)) { + const [type, content] = Object.entries(value)[0] as [AttributeValueType, string]; + output[attribute] = decodeAttribute(type, content); + } + + return output; +} + +function decodeAttribute(type: AttributeValueType, attribute: string) { + switch (type) { + case 'BOOL': + return Boolean(attribute); + case 'N': + return Number(attribute); + case 'S': + return String(attribute); + case 'SS': + case 'NS': + return attribute; + default: + return null; + } +} + +// tslint:disable-next-line: no-any +export function validateJSON(input: any): object { + try { + return JSON.parse(input); + } catch (error) { + throw new Error('Items must be a valid JSON'); + } +} + +export function copyInputItem(item: INodeExecutionData, properties: string[]): IDataObject { + // Prepare the data to insert and copy it to be returned + let newItem: IDataObject; + newItem = {}; + for (const property of properties) { + if (item.json[property] === undefined) { + newItem[property] = null; + } else { + newItem[property] = JSON.parse(JSON.stringify(item.json[property])); + } + } + return newItem; +} + +export function mapToAttributeValues(item: IDataObject): void { + for (const key of Object.keys(item)) { + if (!key.startsWith(':')) { + item[`:${key}`] = item[key]; + delete item[key]; + } + } +} + +export function decodeItem(item: IAttributeValue): IDataObject { + const _item: IDataObject = {}; + for (const entry of Object.entries(item)) { + const [attribute, value]: [string, object] = entry; + const [type, content]: [string, object] = Object.entries(value)[0]; + _item[attribute] = decodeAttribute(type as EAttributeValueType, content as unknown as string); + } + + return _item; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index f4aacdc008..56e66497ac 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -305,6 +305,7 @@ "dist/nodes/Autopilot/AutopilotTrigger.node.js", "dist/nodes/Aws/AwsLambda.node.js", "dist/nodes/Aws/Comprehend/AwsComprehend.node.js", + "dist/nodes/Aws/DynamoDB/AwsDynamoDB.node.js", "dist/nodes/Aws/Rekognition/AwsRekognition.node.js", "dist/nodes/Aws/S3/AwsS3.node.js", "dist/nodes/Aws/SES/AwsSes.node.js",