first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,24 @@
{
"node": "n8n-nodes-base.awsTextract",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Utility"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/aws/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.awstextract/"
}
],
"generic": [
{
"label": "7 no-code workflow automations for Amazon Web Services",
"url": "https://n8n.io/blog/aws-workflow-automation/"
}
]
}
}
@@ -0,0 +1,157 @@
import {
BINARY_ENCODING,
NodeConnectionTypes,
type ICredentialDataDecryptedObject,
type ICredentialsDecrypted,
type ICredentialTestFunctions,
type IDataObject,
type IExecuteFunctions,
type INodeCredentialTestResult,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import type { IExpenseDocument } from './GenericFunctions';
import { awsApiRequestREST, simplify, validateCredentials } from './GenericFunctions';
import { awsNodeAuthOptions, awsNodeCredentials } from '../utils';
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',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: awsNodeCredentials,
properties: [
awsNodeAuthOptions,
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Analyze Receipt or Invoice',
value: 'analyzeExpense',
},
],
default: 'analyzeExpense',
},
{
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',
name: 'simple',
type: 'boolean',
displayOptions: {
show: {
operation: ['analyzeExpense'],
},
},
default: true,
description:
'Whether to return a simplified version of the response instead of the raw data',
},
],
};
methods = {
credentialTest: {
async awsTextractApiCredentialTest(
this: ICredentialTestFunctions,
credential: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> {
try {
await validateCredentials.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<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: IDataObject[] = [];
let responseData;
const operation = this.getNodeParameter('operation', 0);
for (let i = 0; i < items.length; i++) {
try {
//https://docs.aws.amazon.com/textract/latest/dg/API_AnalyzeExpense.html
if (operation === 'analyzeExpense') {
const simple = this.getNodeParameter('simple', i) as boolean;
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i);
const binaryBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName);
// Convert the binary buffer to a base64 string
const binaryData = Buffer.from(binaryBuffer).toString(BINARY_ENCODING);
const body: IDataObject = {
Document: {
Bytes: binaryData,
},
};
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)];
}
}
@@ -0,0 +1,183 @@
import type { Request } from 'aws4';
import { sign } from 'aws4';
import type {
ICredentialDataDecryptedObject,
ICredentialTestFunctions,
IExecuteFunctions,
IHookFunctions,
ILoadOptionsFunctions,
IWebhookFunctions,
IHttpRequestOptions,
JsonObject,
IHttpRequestMethods,
IRequestOptions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { URL } from 'url';
import { parseString } from 'xml2js';
import { getAwsCredentials } from '../GenericFunctions';
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: IHttpRequestMethods,
path: string,
body?: string,
headers?: object,
): Promise<any> {
const { credentials, credentialsType } = await getAwsCredentials(this);
const requestOptions = {
qs: {
service,
path,
},
method,
body,
url: '',
headers,
region: credentials?.region as string,
} as IHttpRequestOptions;
try {
return await this.helpers.requestWithAuthentication.call(this, credentialsType, requestOptions);
} 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 as string).Message.split(' ')[1];
throw new NodeApiError(this.getNode(), error as JsonObject, {
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 as JsonObject); // no XML parsing needed
}
}
export async function awsApiRequestREST(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
service: string,
method: IHttpRequestMethods,
path: string,
body?: string,
headers?: object,
): Promise<any> {
const response = await awsApiRequest.call(this, service, method, path, body, headers);
try {
return JSON.parse(response as string);
} catch (error) {
return response;
}
}
export async function awsApiRequestSOAP(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
service: string,
method: IHttpRequestMethods,
path: string,
body?: string,
headers?: object,
): Promise<any> {
const response = await awsApiRequest.call(this, service, method, path, body, headers);
try {
return await new Promise((resolve, reject) => {
parseString(response as string, { 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 validateCredentials(
this: ICredentialTestFunctions,
decryptedCredentials: ICredentialDataDecryptedObject,
service: string,
): Promise<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;
const securityHeaders = {
accessKeyId: `${credentials.accessKeyId}`.trim(),
secretAccessKey: `${credentials.secretAccessKey}`.trim(),
sessionToken: credentials.temporaryCredentials
? `${credentials.sessionToken}`.trim()
: undefined,
};
sign(signOpts, securityHeaders);
const options: IRequestOptions = {
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 as string, { explicitArray: false }, (err, data) => {
if (err) {
return reject(err);
}
resolve(data);
});
});
}
@@ -0,0 +1,254 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import nock from 'nock';
import { AwsTextract } from '../AwsTextract.node';
import * as GenericFunctions from '../GenericFunctions';
const mockTextractResponse = {
ExpenseDocuments: [
{
SummaryFields: [
{
Type: {
Text: 'VENDOR_NAME',
},
ValueDetection: {
Text: 'Test Company',
},
},
],
},
],
};
const mockSimplifiedResponse = {
VENDOR_NAME: 'Test Company',
};
describe('AWS Textract Node', () => {
const executeFunctionsMock = mockDeep<IExecuteFunctions>();
const awsApiRequestSpy = jest.spyOn(GenericFunctions, 'awsApiRequestREST');
const simplifySpy = jest.spyOn(GenericFunctions, 'simplify');
const node = new AwsTextract();
beforeEach(() => {
jest.resetAllMocks();
executeFunctionsMock.getCredentials.mockResolvedValue({
accessKeyId: 'test-key',
secretAccessKey: 'test-secret',
region: 'us-east-1',
});
executeFunctionsMock.getNode.mockReturnValue({
typeVersion: 1,
} as INode);
executeFunctionsMock.getInputData.mockReturnValue([{ json: {} }]);
executeFunctionsMock.continueOnFail.mockReturnValue(false);
executeFunctionsMock.helpers.returnJsonArray.mockImplementation((data) =>
Array.isArray(data) ? data.map((item: any) => ({ json: item })) : ([{ json: data }] as any),
);
});
afterEach(() => {
nock.cleanAll();
});
describe('analyzeExpense operation', () => {
beforeEach(() => {
executeFunctionsMock.getNodeParameter.mockImplementation((paramName) => {
switch (paramName) {
case 'operation':
return 'analyzeExpense';
case 'binaryPropertyName':
return 'data';
case 'simple':
return true;
default:
return undefined;
}
});
});
it('should process binary image data and return simplified response', async () => {
const testImageBuffer = Buffer.from('test-image-data');
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(testImageBuffer);
awsApiRequestSpy.mockResolvedValue(mockTextractResponse);
simplifySpy.mockReturnValue(mockSimplifiedResponse);
const result = await node.execute.call(executeFunctionsMock);
expect(executeFunctionsMock.helpers.getBinaryDataBuffer).toHaveBeenCalledWith(0, 'data');
expect(awsApiRequestSpy).toHaveBeenCalledWith(
'textract',
'POST',
'',
JSON.stringify({
Document: {
Bytes: testImageBuffer.toString('base64'),
},
}),
{
'x-amz-target': 'Textract.AnalyzeExpense',
'Content-Type': 'application/x-amz-json-1.1',
},
);
expect(simplifySpy).toHaveBeenCalledWith(mockTextractResponse);
expect(result).toEqual([[{ json: mockSimplifiedResponse }]]);
});
it('should return raw response when simple is false', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((paramName) => {
switch (paramName) {
case 'operation':
return 'analyzeExpense';
case 'binaryPropertyName':
return 'data';
case 'simple':
return false;
default:
return undefined;
}
});
const testImageBuffer = Buffer.from('test-image-data');
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(testImageBuffer);
awsApiRequestSpy.mockResolvedValue(mockTextractResponse);
const result = await node.execute.call(executeFunctionsMock);
expect(simplifySpy).not.toHaveBeenCalled();
expect(result).toEqual([[{ json: mockTextractResponse }]]);
});
it('should handle different binary property names', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((paramName) => {
switch (paramName) {
case 'operation':
return 'analyzeExpense';
case 'binaryPropertyName':
return 'document';
case 'simple':
return true;
default:
return undefined;
}
});
const testImageBuffer = Buffer.from('test-document-data');
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(testImageBuffer);
awsApiRequestSpy.mockResolvedValue(mockTextractResponse);
simplifySpy.mockReturnValue(mockSimplifiedResponse);
const result = await node.execute.call(executeFunctionsMock);
expect(executeFunctionsMock.helpers.getBinaryDataBuffer).toHaveBeenCalledWith(0, 'document');
expect(result).toEqual([[{ json: mockSimplifiedResponse }]]);
});
it('should handle JPEG images', async () => {
const testJpegBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); // JPEG header bytes
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(testJpegBuffer);
awsApiRequestSpy.mockResolvedValue(mockTextractResponse);
simplifySpy.mockReturnValue(mockSimplifiedResponse);
const result = await node.execute.call(executeFunctionsMock);
expect(awsApiRequestSpy).toHaveBeenCalledWith(
'textract',
'POST',
'',
JSON.stringify({
Document: {
Bytes: testJpegBuffer.toString('base64'),
},
}),
{
'x-amz-target': 'Textract.AnalyzeExpense',
'Content-Type': 'application/x-amz-json-1.1',
},
);
expect(result).toEqual([[{ json: mockSimplifiedResponse }]]);
});
it('should handle PNG images', async () => {
const testPngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG header bytes
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(testPngBuffer);
awsApiRequestSpy.mockResolvedValue(mockTextractResponse);
simplifySpy.mockReturnValue(mockSimplifiedResponse);
const result = await node.execute.call(executeFunctionsMock);
expect(result).toEqual([[{ json: mockSimplifiedResponse }]]);
});
it('should handle multiple input items', async () => {
executeFunctionsMock.getInputData.mockReturnValue([{ json: {} }, { json: {} }]);
const testImageBuffer = Buffer.from('test-image-data');
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(testImageBuffer);
awsApiRequestSpy.mockResolvedValue(mockTextractResponse);
simplifySpy.mockReturnValue(mockSimplifiedResponse);
const result = await node.execute.call(executeFunctionsMock);
expect(executeFunctionsMock.helpers.getBinaryDataBuffer).toHaveBeenCalledTimes(2);
expect(awsApiRequestSpy).toHaveBeenCalledTimes(2);
expect(result).toEqual([
[{ json: mockSimplifiedResponse }, { json: mockSimplifiedResponse }],
]);
});
it('should handle errors and continue on fail', async () => {
executeFunctionsMock.continueOnFail.mockReturnValue(true);
executeFunctionsMock.helpers.getBinaryDataBuffer.mockRejectedValue(
new Error('Binary data not found'),
);
const result = await node.execute.call(executeFunctionsMock);
expect(result).toEqual([[{ json: { error: 'Binary data not found' } }]]);
});
it('should throw error when continueOnFail is false', async () => {
executeFunctionsMock.continueOnFail.mockReturnValue(false);
executeFunctionsMock.helpers.getBinaryDataBuffer.mockRejectedValue(
new Error('Binary data not found'),
);
await expect(node.execute.call(executeFunctionsMock)).rejects.toThrow(
'Binary data not found',
);
});
it('should handle empty binary data', async () => {
const emptyBuffer = Buffer.from('');
executeFunctionsMock.helpers.getBinaryDataBuffer.mockResolvedValue(emptyBuffer);
awsApiRequestSpy.mockResolvedValue(mockTextractResponse);
simplifySpy.mockReturnValue(mockSimplifiedResponse);
await node.execute.call(executeFunctionsMock);
expect(awsApiRequestSpy).toHaveBeenCalledWith(
'textract',
'POST',
'',
JSON.stringify({
Document: {
Bytes: '',
},
}),
{
'x-amz-target': 'Textract.AnalyzeExpense',
'Content-Type': 'application/x-amz-json-1.1',
},
);
});
});
});
@@ -0,0 +1,141 @@
import { simplify, type IExpenseDocument } from '../GenericFunctions';
describe('AWS Textract Generic Functions', () => {
describe('simplify function', () => {
it('should simplify expense document response correctly', () => {
const input = {
ExpenseDocuments: [
{
SummaryFields: [
{
Type: {
Text: 'VENDOR_NAME',
},
LabelDetection: {
Text: 'Vendor',
},
ValueDetection: {
Text: 'Acme Corporation',
},
},
{
Type: {
Text: 'INVOICE_RECEIPT_DATE',
},
LabelDetection: {
Text: 'Date',
},
ValueDetection: {
Text: '2023-12-01',
},
},
{
Type: {
Text: 'TOTAL',
},
LabelDetection: {
Text: 'Total',
},
ValueDetection: {
Text: '$125.50',
},
},
],
},
],
} as unknown as IExpenseDocument;
const result = simplify(input);
expect(result).toEqual({
VENDOR_NAME: 'Acme Corporation',
INVOICE_RECEIPT_DATE: '2023-12-01',
TOTAL: '$125.50',
});
});
it('should handle fields without Type but with LabelDetection', () => {
const input = {
ExpenseDocuments: [
{
SummaryFields: [
{
Type: undefined as any,
LabelDetection: {
Text: 'Custom Field',
},
ValueDetection: {
Text: 'Custom Value',
},
},
],
},
],
} as unknown as IExpenseDocument;
const result = simplify(input);
expect(result).toEqual({
'Custom Field': 'Custom Value',
});
});
it('should handle empty expense documents', () => {
const input = {
ExpenseDocuments: [
{
SummaryFields: [],
},
],
} as any;
const result = simplify(input);
expect(result).toEqual({});
});
it('should handle multiple expense documents', () => {
const input = {
ExpenseDocuments: [
{
SummaryFields: [
{
Type: {
Text: 'VENDOR_NAME',
},
LabelDetection: {
Text: 'Vendor',
},
ValueDetection: {
Text: 'First Company',
},
},
],
},
{
SummaryFields: [
{
Type: {
Text: 'TOTAL',
},
LabelDetection: {
Text: 'Total',
},
ValueDetection: {
Text: '$50.00',
},
},
],
},
],
} as any;
const result = simplify(input);
expect(result).toEqual({
VENDOR_NAME: 'First Company',
TOTAL: '$50.00',
});
});
});
});
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><defs><linearGradient id="a" x1="0%" x2="100%" y1="100%" y2="0%"><stop offset="0%" stop-color="#055F4E"/><stop offset="100%" stop-color="#56C0A7"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" d="M0 0h80v80H0z"/><path fill="#FFF" d="M22.062 50c2.314 3.603 6.348 6 10.938 6 7.167 0 13-5.832 13-13s-5.833-13-13-13c-5.397 0-10.034 3.307-11.998 8h2.212c1.825-3.556 5.522-6 9.786-6 6.065 0 11 4.935 11 11s-4.935 11-11 11a10.99 10.99 0 0 1-8.479-4zM37 45v-2h5c0-4.962-4.038-9-9-9-4.963 0-9 4.038-9 9h5v2h-4.77c.913 4.002 4.494 7 8.77 7a8.95 8.95 0 0 0 5.643-2H34v-2h6.478a9 9 0 0 0 1.29-3zm-15.998 3h2.212A10.9 10.9 0 0 1 22 43c0-1.041.155-2.045.426-3h-2.063A13 13 0 0 0 20 43c0 1.771.36 3.46 1.003 5m-1.259 2H17v-2h1.874c-.34-.96-.585-1.962-.725-3h-2.735l1.293 1.293-1.415 1.414-3-3a1 1 0 0 1 0-1.414l3-3 1.415 1.414L15.414 43H18c0-1.027.104-2.03.302-3H17v-2h1.874C20.94 32.184 26.484 28 33 28c4.427 0 8.4 1.939 11.148 5H59v2H45.666A14.9 14.9 0 0 1 48 43h11v2H47.85c-.982 7.327-7.259 13-14.85 13-5.744 0-10.738-3.248-13.257-8M37 39a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v2h2v-1h1v3h-1v2h4v-2h-1v-3h1v1h2zm12 1h10v-2H49zm0 10h10v-2H49zm8-23h3.585L57 23.414zm6.707.293c.187.187.293.442.293.707v35a1 1 0 0 1-1 1H32a1 1 0 0 1-1-1v-4h2v3h29V29h-6a1 1 0 0 1-1-1v-6H33v5h-2v-6a1 1 0 0 1 1-1h24a1 1 0 0 1 .707.293zM68 24.166V61a1 1 0 0 1-1 1h-2v-2h1V24.612L58.617 18H36v1h-2v-2a1 1 0 0 1 1-1h24c.246 0 .483.091.666.255l8 7.165c.212.19.334.461.334.746"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB