From 49113a19888a81f60e9c5462c40edef605ea39b2 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 13 Oct 2020 03:29:47 -0400 Subject: [PATCH] :sparkles: Add AWS-Rekognition Node (#1047) * :sparkles: AWS-Rekognition Node * :zap: Small improvement --- .../Aws/Rekognition/AwsRekognition.node.ts | 382 ++++++++++++++++++ .../nodes/Aws/Rekognition/GenericFunctions.ts | 126 ++++++ .../nodes/Aws/Rekognition/rekognition.png | Bin 0 -> 4374 bytes .../nodes/Aws/Rekognition/rekognition.svg | 1 + packages/nodes-base/package.json | 1 + 5 files changed, 510 insertions(+) create mode 100644 packages/nodes-base/nodes/Aws/Rekognition/AwsRekognition.node.ts create mode 100644 packages/nodes-base/nodes/Aws/Rekognition/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Aws/Rekognition/rekognition.png create mode 100644 packages/nodes-base/nodes/Aws/Rekognition/rekognition.svg diff --git a/packages/nodes-base/nodes/Aws/Rekognition/AwsRekognition.node.ts b/packages/nodes-base/nodes/Aws/Rekognition/AwsRekognition.node.ts new file mode 100644 index 0000000000..5ea04612b7 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Rekognition/AwsRekognition.node.ts @@ -0,0 +1,382 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IBinaryKeyData, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + awsApiRequestREST, +} from './GenericFunctions'; + +export class AwsRekognition implements INodeType { + description: INodeTypeDescription = { + displayName: 'AWS Rekognition', + name: 'awsRekognition', + icon: 'file:rekognition.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Sends data to AWS Rekognition', + defaults: { + name: 'AWS Rekognition', + color: '#305b94', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'aws', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Image', + value: 'image', + }, + ], + default: 'image', + description: 'The operation to perform.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Analyze', + value: 'analyze', + }, + ], + default: 'analyze', + description: 'The operation to perform.', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Detect Faces', + value: 'detectFaces', + }, + { + name: 'Detect Labels', + value: 'detectLabels', + }, + { + name: 'Detect Moderation Labels', + value: 'detectModerationLabels', + }, + { + name: 'Recognize Celebrity', + value: 'recognizeCelebrity', + }, + ], + default: 'detectFaces', + description: 'The operation to perform.', + }, + { + displayName: 'Binary Data', + name: 'binaryData', + type: 'boolean', + default: false, + required: true, + displayOptions: { + show: { + operation: [ + 'analyze' + ], + resource: [ + 'image', + ], + }, + }, + description: 'If the image to analize should be taken from binary field.', + }, + { + displayName: 'Binary Property', + displayOptions: { + show: { + operation: [ + 'analyze' + ], + resource: [ + 'image', + ], + binaryData: [ + true, + ], + }, + }, + name: 'binaryPropertyName', + type: 'string', + default: 'data', + description: 'Object property name which holds binary data.', + required: true, + }, + { + displayName: 'Bucket', + name: 'bucket', + displayOptions: { + show: { + operation: [ + 'analyze' + ], + resource: [ + 'image', + ], + binaryData: [ + false, + ], + }, + }, + type: 'string', + default: '', + required: true, + description: 'Name of the S3 bucket', + }, + { + displayName: 'Name', + name: 'name', + displayOptions: { + show: { + operation: [ + 'analyze' + ], + resource: [ + 'image', + ], + binaryData: [ + false, + ], + }, + }, + type: 'string', + default: '', + required: true, + description: 'S3 object key name', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'analyze', + ], + resource: [ + 'image', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Version', + name: 'version', + displayOptions: { + show: { + '/binaryData': [ + false, + ], + }, + }, + type: 'string', + default: '', + description: 'If the bucket is versioning enabled, you can specify the object version', + }, + { + displayName: 'Max Labels', + name: 'maxLabels', + type: 'number', + displayOptions: { + show: { + '/type': [ + 'detectModerationLabels', + 'detectLabels', + ], + }, + }, + default: 0, + typeOptions: { + minValue: 0, + }, + description: `Maximum number of labels you want the service to return in the response. The service returns the specified number of highest confidence labels.`, + }, + { + displayName: 'Min Confidence', + name: 'minConfidence', + type: 'number', + displayOptions: { + show: { + '/type': [ + 'detectModerationLabels', + 'detectLabels', + ], + }, + }, + default: 0, + typeOptions: { + minValue: 0, + maxValue: 100, + }, + description: `Specifies the minimum confidence level for the labels to return. Amazon Rekognition doesn't return any labels with a confidence level lower than this specified value.`, + }, + { + displayName: 'Attributes', + name: 'attributes', + type: 'multiOptions', + displayOptions: { + show: { + '/type': [ + 'detectFaces', + ], + }, + }, + options: [ + { + name: 'All', + value: 'all', + }, + { + name: 'Default', + value: 'default', + }, + ], + default: [], + description: `An array of facial attributes you want to be returned`, + }, + ], + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const qs: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < items.length; i++) { + if (resource === 'image') { + //https://docs.aws.amazon.com/rekognition/latest/dg/API_DetectModerationLabels.html#API_DetectModerationLabels_RequestSyntax + if (operation === 'analyze') { + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + let action, property = undefined; + + let body: IDataObject = {}; + + const type = this.getNodeParameter('type', 0) as string; + + if (type === 'detectModerationLabels') { + action = 'RekognitionService.DetectModerationLabels'; + + // property = 'ModerationLabels'; + + if (additionalFields.minConfidence) { + body['MinConfidence'] = additionalFields.minConfidence as number; + } + } + + if (type === 'detectFaces') { + action = 'RekognitionService.DetectFaces'; + + property = 'FaceDetails'; + + if (additionalFields.attributes) { + body['Attributes'] = additionalFields.attributes as string; + } + } + + if (type === 'detectLabels') { + action = 'RekognitionService.DetectLabels'; + + if (additionalFields.minConfidence) { + body['MinConfidence'] = additionalFields.minConfidence as number; + } + + if (additionalFields.maxLabels) { + body['MaxLabels'] = additionalFields.maxLabels as number; + } + } + + if (type === 'recognizeCelebrity') { + action = 'RekognitionService.RecognizeCelebrities'; + } + + const binaryData = this.getNodeParameter('binaryData', 0) as boolean; + + if (binaryData) { + + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', 0) as string; + + if (items[i].binary === undefined) { + throw new Error('No binary data exists on item!'); + } + + if ((items[i].binary as IBinaryKeyData)[binaryPropertyName] === undefined) { + throw new Error(`No binary data property "${binaryPropertyName}" does not exists on item!`); + } + + const binaryPropertyData = (items[i].binary as IBinaryKeyData)[binaryPropertyName]; + + body = { + Image: { + Bytes: binaryPropertyData.data, + }, + }; + + } else { + + const bucket = this.getNodeParameter('bucket', i) as string; + + const name = this.getNodeParameter('name', i) as string; + + body = { + Image: { + S3Object: { + Bucket: bucket, + Name: name, + }, + }, + }; + + if (additionalFields.version) { + //@ts-ignore + body.Image.S3Object.Version = additionalFields.version as string; + } + } + + responseData = await awsApiRequestREST.call(this, 'rekognition', 'POST', '', JSON.stringify(body), {}, { 'X-Amz-Target': action, 'Content-Type': 'application/x-amz-json-1.1' }); + + if (property !== undefined) { + responseData = responseData[property as string]; + } + } + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Aws/Rekognition/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/Rekognition/GenericFunctions.ts new file mode 100644 index 0000000000..9b8d1782ba --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Rekognition/GenericFunctions.ts @@ -0,0 +1,126 @@ +import { + sign, +} from 'aws4'; + +import { + get, +} from 'lodash'; + +import { + OptionsWithUri, +} from 'request'; + +import { + parseString, +} from 'xml2js'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + } from 'n8n-workflow'; + +export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string | Buffer | IDataObject, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('aws'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const endpoint = `${service}.${region || credentials.region}.amazonaws.com`; + + // Sign AWS API request with the user credentials + const signOpts = {headers: headers || {}, host: endpoint, method, path, body}; + + sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim()}); + + const options: OptionsWithUri = { + headers: signOpts.headers, + method, + uri: `https://${endpoint}${signOpts.path}`, + body: signOpts.body, + }; + + if (Object.keys(option).length !== 0) { + Object.assign(options, option); + } + try { + return await this.helpers.request!(options); + } catch (error) { + const errorMessage = error.response.body.message || 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 awsApiRequestREST(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers?: object, options: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const response = await awsApiRequest.call(this, service, method, path, body, query, headers, options, region); + try { + return JSON.parse(response); + } catch (e) { + return response; + } +} + +export async function awsApiRequestSOAP(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string | Buffer | IDataObject, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const response = await awsApiRequest.call(this, service, method, path, body, query, headers, option, region); + try { + return await new Promise((resolve, reject) => { + parseString(response, { explicitArray: false }, (err, data) => { + if (err) { + return reject(err); + } + resolve(data); + }); + }); + } catch (e) { + return e; + } +} + +export async function awsApiRequestSOAPAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, propertyName: string, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers: IDataObject = {}, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await awsApiRequestSOAP.call(this, service, method, path, body, query, headers, option, region); + + //https://forums.aws.amazon.com/thread.jspa?threadID=55746 + if (get(responseData, `${propertyName.split('.')[0]}.NextContinuationToken`)) { + query['continuation-token'] = get(responseData, `${propertyName.split('.')[0]}.NextContinuationToken`); + } + if (get(responseData, propertyName)) { + if (Array.isArray(get(responseData, propertyName))) { + returnData.push.apply(returnData, get(responseData, propertyName)); + } else { + returnData.push(get(responseData, propertyName)); + } + } + if (query.limit && query.limit <= returnData.length) { + return returnData; + } + } while ( + get(responseData, `${propertyName.split('.')[0]}.IsTruncated`) !== undefined && + get(responseData, `${propertyName.split('.')[0]}.IsTruncated`) !== 'false' + ); + + return returnData; +} + +function queryToString(params: IDataObject) { + return Object.keys(params).map(key => key + '=' + params[key]).join('&'); +} diff --git a/packages/nodes-base/nodes/Aws/Rekognition/rekognition.png b/packages/nodes-base/nodes/Aws/Rekognition/rekognition.png new file mode 100644 index 0000000000000000000000000000000000000000..da469fed51448d18edf878101c71ede6725fd674 GIT binary patch literal 4374 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 \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 51169cc1bb..989fe87d41 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -205,6 +205,7 @@ "dist/nodes/Affinity/Affinity.node.js", "dist/nodes/Affinity/AffinityTrigger.node.js", "dist/nodes/Aws/AwsLambda.node.js", + "dist/nodes/Aws/Rekognition/AwsRekognition.node.js", "dist/nodes/Aws/S3/AwsS3.node.js", "dist/nodes/Aws/SES/AwsSes.node.js", "dist/nodes/Aws/AwsSns.node.js",