diff --git a/packages/nodes-base/credentials/Aws.credentials.ts b/packages/nodes-base/credentials/Aws.credentials.ts index 5138b0c9a6..efb5fe59aa 100644 --- a/packages/nodes-base/credentials/Aws.credentials.ts +++ b/packages/nodes-base/credentials/Aws.credentials.ts @@ -17,7 +17,7 @@ export class Aws implements ICredentialType { default: 'us-east-1', }, { - displayName: 'Access Key Id', + displayName: 'Access Key ID', name: 'accessKeyId', type: 'string', default: '', diff --git a/packages/nodes-base/credentials/S3.credentials.ts b/packages/nodes-base/credentials/S3.credentials.ts index 861ef19aea..976f5910ed 100644 --- a/packages/nodes-base/credentials/S3.credentials.ts +++ b/packages/nodes-base/credentials/S3.credentials.ts @@ -22,7 +22,7 @@ export class S3 implements ICredentialType { default: 'us-east-1', }, { - displayName: 'Access Key Id', + displayName: 'Access Key ID', name: 'accessKeyId', type: 'string', default: '', diff --git a/packages/nodes-base/nodes/Aws/Textract/AwsTextract.node.ts b/packages/nodes-base/nodes/Aws/Textract/AwsTextract.node.ts new file mode 100644 index 0000000000..64e7c1476b --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Textract/AwsTextract.node.ts @@ -0,0 +1,163 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IBinaryKeyData, + ICredentialDataDecryptedObject, + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeCredentialTestResult, + NodeOperationError, +} from 'n8n-workflow'; + +import { + awsApiRequestREST, + IExpenseDocument, + simplify, + validateCrendetials, +} from './GenericFunctions'; + +export class AwsTextract implements INodeType { + description: INodeTypeDescription = { + displayName: 'AWS Textract', + name: 'awsTextract', + icon: 'file:textract.svg', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"]}}', + description: 'Sends data to Amazon Textract', + defaults: { + name: 'AWS Textract', + color: '#5aa08d', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'aws', + required: true, + testedBy: 'awsTextractApiCredentialTest', + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Analyze Receipt or Invoice', + value: 'analyzeExpense', + }, + ], + default: 'analyzeExpense', + description: '', + }, + { + displayName: 'Input Data Field Name', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + displayOptions: { + show: { + operation: [ + 'analyzeExpense', + ], + }, + }, + required: true, + description: 'The name of the input field containing the binary file data to be uploaded. Supported file types: PNG, JPEG', + }, + { + displayName: 'Simplify Response', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'analyzeExpense', + ], + }, + }, + default: true, + description: 'Return a simplified version of the response instead of the raw data.', + }, + ], + }; + + methods = { + credentialTest: { + async awsTextractApiCredentialTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise { + try { + await validateCrendetials.call(this, credential.data as ICredentialDataDecryptedObject, 'sts'); + } catch (error) { + return { + status: 'Error', + message: 'The security token included in the request is invalid', + }; + } + + return { + status: 'OK', + message: 'Connection successful!', + }; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + let responseData; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < items.length; i++) { + try { + //https://docs.aws.amazon.com/textract/latest/dg/API_AnalyzeExpense.html + if (operation === 'analyzeExpense') { + const binaryProperty = this.getNodeParameter('binaryPropertyName', i) as string; + const simple = this.getNodeParameter('simple', i) as boolean; + + if (items[i].binary === undefined) { + throw new NodeOperationError(this.getNode(), 'No binary data exists on item!'); + } + + if ((items[i].binary as IBinaryKeyData)[binaryProperty] === undefined) { + throw new NodeOperationError(this.getNode(), `No binary data property "${binaryProperty}" does not exists on item!`); + } + + const binaryPropertyData = (items[i].binary as IBinaryKeyData)[binaryProperty]; + + const body: IDataObject = { + Document: { + Bytes: binaryPropertyData.data, + }, + }; + + const action = 'Textract.AnalyzeExpense'; + responseData = await awsApiRequestREST.call(this, 'textract', 'POST', '', JSON.stringify(body), { 'x-amz-target': action, 'Content-Type': 'application/x-amz-json-1.1' }) as IExpenseDocument; + if (simple) { + responseData = simplify(responseData); + } + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as unknown as IDataObject); + } + } 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/Textract/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/Textract/GenericFunctions.ts new file mode 100644 index 0000000000..9758552cf4 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Textract/GenericFunctions.ts @@ -0,0 +1,156 @@ +import { + URL, +} from 'url'; + +import { + Request, + sign, +} from 'aws4'; + +import { + OptionsWithUri, +} from 'request'; + +import { + parseString, +} from 'xml2js'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + ICredentialDataDecryptedObject, + ICredentialTestFunctions, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +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?: string, headers?: object): Promise { // tslint:disable-line:no-any + const credentials = await this.getCredentials('aws'); + if (credentials === undefined) { + throw new NodeOperationError(this.getNode(), 'No credentials got returned!'); + } + + // Concatenate path and instantiate URL object so it parses correctly query strings + const endpoint = new URL(getEndpointForService(service, credentials) + path); + + // Sign AWS API request with the user credentials + const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body } as Request; + sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); + + + const options: OptionsWithUri = { + headers: signOpts.headers, + method, + uri: endpoint.href, + body: signOpts.body, + }; + + try { + return await this.helpers.request!(options); + } catch (error) { + if (error?.response?.data || error?.response?.body) { + const errorMessage = error?.response?.data || error?.response?.body; + if (errorMessage.includes('AccessDeniedException')) { + const user = JSON.parse(errorMessage).Message.split(' ')[1]; + throw new NodeApiError(this.getNode(), error, { + message: 'Unauthorized — please check your AWS policy configuration', + description: `Make sure an identity-based policy allows user ${user} to perform textract:AnalyzeExpense` }); + } + } + + throw new NodeApiError(this.getNode(), error); // no XML parsing needed + } +} + +export async function awsApiRequestREST(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, service: string, method: string, path: string, body?: string, headers?: object): Promise { // tslint:disable-line:no-any + const response = await awsApiRequest.call(this, service, method, path, body, headers); + try { + return JSON.parse(response); + } catch (error) { + return response; + } +} + +export async function awsApiRequestSOAP(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string, headers?: object): Promise { // tslint:disable-line:no-any + const response = await awsApiRequest.call(this, service, method, path, body, headers); + try { + return await new Promise((resolve, reject) => { + parseString(response, { explicitArray: false }, (err, data) => { + if (err) { + return reject(err); + } + resolve(data); + }); + }); + } catch (error) { + return response; + } +} + +export function simplify(data: IExpenseDocument) { + const result: { [key: string]: string } = {}; + for (const document of data.ExpenseDocuments) { + for (const field of document.SummaryFields) { + result[field?.Type?.Text || field?.LabelDetection?.Text] = field.ValueDetection.Text; + } + } + return result; +} + +export interface IExpenseDocument { + ExpenseDocuments: [ + { + SummaryFields: [ + { + LabelDetection: { Text: string }, + ValueDetection: { Text: string }, + Type: { Text: string } + }] + }]; +} + +export async function validateCrendetials(this: ICredentialTestFunctions, decryptedCredentials: ICredentialDataDecryptedObject, service: string): Promise { // tslint:disable-line:no-any + const credentials = decryptedCredentials; + + // Concatenate path and instantiate URL object so it parses correctly query strings + const endpoint = new URL(getEndpointForService(service, credentials) + `?Action=GetCallerIdentity&Version=2011-06-15`); + + // Sign AWS API request with the user credentials + const signOpts = { host: endpoint.host, method: 'POST', path: '?Action=GetCallerIdentity&Version=2011-06-15' } as Request; + sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); + + const options: OptionsWithUri = { + headers: signOpts.headers, + method: 'POST', + uri: endpoint.href, + body: signOpts.body, + }; + + const response = await this.helpers.request!(options); + + return await new Promise((resolve, reject) => { + parseString(response, { explicitArray: false }, (err, data) => { + if (err) { + return reject(err); + } + resolve(data); + }); + }); +} diff --git a/packages/nodes-base/nodes/Aws/Textract/textract.svg b/packages/nodes-base/nodes/Aws/Textract/textract.svg new file mode 100644 index 0000000000..fa00d88c01 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/Textract/textract.svg @@ -0,0 +1,18 @@ + + + + Icon-Architecture/64/Arch_AWS-Textract_64 + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 6233219a5f..ea8db2359d 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -330,6 +330,7 @@ "dist/nodes/Aws/S3/AwsS3.node.js", "dist/nodes/Aws/SES/AwsSes.node.js", "dist/nodes/Aws/SQS/AwsSqs.node.js", + "dist/nodes/Aws/Textract/AwsTextract.node.js", "dist/nodes/Aws/Transcribe/AwsTranscribe.node.js", "dist/nodes/Aws/AwsSns.node.js", "dist/nodes/Aws/AwsSnsTrigger.node.js",