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 0000000000..da469fed51 Binary files /dev/null and b/packages/nodes-base/nodes/Aws/Rekognition/rekognition.png differ diff --git a/packages/nodes-base/nodes/Aws/Rekognition/rekognition.svg b/packages/nodes-base/nodes/Aws/Rekognition/rekognition.svg new file mode 100644 index 0000000000..00d357a0c5 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Rekognition/rekognition.svg @@ -0,0 +1 @@ + \ 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",