diff --git a/src/N8NPropertiesBuilder.spec.ts b/src/N8NPropertiesBuilder.spec.ts index a67f2ee..5a51abc 100644 --- a/src/N8NPropertiesBuilder.spec.ts +++ b/src/N8NPropertiesBuilder.spec.ts @@ -753,6 +753,7 @@ test('enum schema', () => { }, { displayName: 'Type', + description: undefined, name: 'type', type: 'options', default: 'type1', diff --git a/src/OperationsCollector.ts b/src/OperationsCollector.ts index 616e552..dfc9fcd 100644 --- a/src/OperationsCollector.ts +++ b/src/OperationsCollector.ts @@ -8,6 +8,12 @@ import {OptionsByResourceMap} from "./n8n/OptionsByResourceMap"; import {INodeProperties} from "n8n-workflow"; import {replacePathVarsToParameter} from "./n8n/utils"; import {IResourceParser} from "./ResourceParser"; +import { + BinaryFileType, + INodeExecutionData, INodePropertyRouting, + INodeRequestOutput +} from "n8n-workflow/dist/Interfaces"; +import ResponseObject = OpenAPIV3.ResponseObject; export class BaseOperationsCollector implements OpenAPIVisitor { public readonly _fields: INodeProperties[] @@ -131,24 +137,71 @@ export class BaseOperationsCollector implements OpenAPIVisitor { } protected parseOperation(operation: OpenAPIV3.OperationObject, context: OperationContext) { - const method = context.method + const method = context.method; const uri = context.pattern; - const parser = this.operationParser + const parser = this.operationParser; + let returns_raw_data = operation.responses && + Object.entries(operation.responses) + .filter(([code, data]) => + code.startsWith("2") && 'content' in data && data.content + ) + .map(([_, data]) => (data as ResponseObject).content) + .map((content) => Object.keys(content!)) + .flat() + .filter((contentType) => contentType.match(/^(image|audio|video|text)\/[a-z0-9.+-]+$|^application\/pdf$/i)) + .length > 0; + let output: INodeRequestOutput | undefined = undefined + if (returns_raw_data) { + let file_type = (type: String): BinaryFileType | undefined => { + if (type.startsWith('image/')) return 'image'; + if (type.startsWith('audio/')) return 'audio'; + if (type.startsWith('video/')) return 'video'; + if (type === 'text/html') return 'html'; + if (type.startsWith('text/')) return 'text'; + if (type === 'application/pdf') return 'pdf'; + return undefined; + }; + output = { + postReceive: [async (items, response): Promise => { + let bufferData = Buffer.from(items[0].json as unknown as ArrayBuffer); + const base64Data = bufferData.toString('base64'); + return [{ + binary: { + data: { + data: base64Data, + mimeType: response.headers['content-type'] as string, + fileSize: bufferData.length.toString(), + fileType: file_type(response.headers['content-type'] as string), + }, + }, + json: {}, + }]; + }], + } + } + let routing : INodePropertyRouting = { + request: { + // @ts-ignore + method: method.toUpperCase(), + url: `=${replacePathVarsToParameter(uri)}`, + }, + }; + if (returns_raw_data) { + routing.output = output; + routing.request!.headers = { + Accept: 'image/*', + }; + routing.request!.json = false; + routing.request!.encoding = 'arraybuffer'; + } const option = { name: parser.name(operation, context), value: parser.value(operation, context), action: parser.action(operation, context), description: parser.description(operation, context), - routing: { - request: { - method: method.toUpperCase(), - url: `=${replacePathVarsToParameter(uri)}`, - }, - }, + routing, }; const fields = this.parseFields(operation, context); - - return { option: option, fields: fields, diff --git a/tests/media.spec.ts b/tests/media.spec.ts new file mode 100644 index 0000000..e1558bb --- /dev/null +++ b/tests/media.spec.ts @@ -0,0 +1,369 @@ +import {N8NPropertiesBuilder} from '../src'; +import {INodeProperties} from 'n8n-workflow'; + +test('image.json', async () => { + const doc = require('./samples/image.json'); + const config = {}; + const parser = new N8NPropertiesBuilder(doc, config); + const result = parser.build(); + + // Ensure the post Receive is a function + let postReceive = (result[1].options![0] as INodeProperties).routing!.output!.postReceive![0]; + expect(postReceive).toBeInstanceOf(Function); + + // @ts-ignore + expect((await postReceive([{json: 'test'}], { + headers: { + 'content-type': 'image/png' + } + }))[0].binary?.data.fileType).toBe('image'); + + delete (result[1].options![0] as INodeProperties).routing!.output!.postReceive![0]; + expect(result).toEqual([ + { + "displayName": "Resource", + "name": "resource", + "type": "options", + "noDataExpression": true, + "options": [ + { + "name": "Example", + "value": "Example", + "description": "" + } + ], + "default": "" + }, + { + "displayName": "Operation", + "name": "operation", + "type": "options", + "noDataExpression": true, + "displayOptions": { + "show": { + "resource": [ + "Example" + ] + } + }, + "options": [ + { + "name": "Render Example By Id", + "value": "Render Example By Id", + "action": "Render the example by ID", + "description": "Renders something to a PNG image using the specified parameter and resolution.\nThis endpoint returns the rendered image as a PNG file.", + "routing": { + "request": { + "method": "GET", + "url": "=/example/{{$parameter[\"id\"]}}/render/{{$parameter[\"parameter\"]}}", + "headers": { + "Accept": "image/*" + }, + "json": false, + "encoding": "arraybuffer" + }, + "output": { + "postReceive": [] + } + } + } + ], + "default": "" + }, + { + "displayName": "GET /example/{id}/render/{parameter}", + "name": "operation", + "type": "notice", + "typeOptions": { + "theme": "info" + }, + "default": "", + "displayOptions": { + "show": { + "resource": [ + "Example" + ], + "operation": [ + "Render Example By Id" + ] + } + } + }, + { + "displayName": "Id", + "name": "id", + "required": true, + "description": "The ID of the Example to render", + "default": "", + "type": "string", + "displayOptions": { + "show": { + "resource": [ + "Example" + ], + "operation": [ + "Render Example By Id" + ] + } + } + }, + { + "displayName": "Parameter", + "name": "parameter", + "required": true, + "description": "Some parameter we want", + "default": "", + "type": "string", + "displayOptions": { + "show": { + "resource": [ + "Example" + ], + "operation": [ + "Render Example By Id" + ] + } + } + }, + { + "displayName": "Width", + "name": "width", + "default": 800, + "type": "number", + "routing": { + "send": { + "type": "query", + "property": "width", + "value": "={{ $value }}", + "propertyInDotNotation": false + } + }, + "displayOptions": { + "show": { + "resource": [ + "Example" + ], + "operation": [ + "Render Example By Id" + ] + } + } + }, + { + "displayName": "Height", + "name": "height", + "default": 600, + "type": "number", + "routing": { + "send": { + "type": "query", + "property": "height", + "value": "={{ $value }}", + "propertyInDotNotation": false + } + }, + "displayOptions": { + "show": { + "resource": [ + "Example" + ], + "operation": [ + "Render Example By Id" + ] + } + } + } + ] + ); +}); + +test('audio.json', async () => { + const doc = require('./samples/audio.json'); + const config = {}; + const parser = new N8NPropertiesBuilder(doc, config); + const result = parser.build(); + + // Ensure the post Receive is a function + let postReceive = (result[1].options![0] as INodeProperties).routing!.output!.postReceive![0]; + expect(postReceive).toBeInstanceOf(Function); + + // @ts-ignore + expect((await postReceive([{json: 'test'}], { + headers: { + 'content-type': 'audio/mpeg' + } + }))[0].binary?.data.fileType).toBe('audio'); + + delete (result[1].options![0] as INodeProperties).routing!.output!.postReceive![0]; + + expect(result).toEqual([{ + "displayName": "Resource", + "name": "resource", + "type": "options", + "noDataExpression": true, + "options": [{"name": "Default", "value": "Default", "description": ""}], + "default": "" + }, { + "displayName": "Operation", + "name": "operation", + "type": "options", + "noDataExpression": true, + "displayOptions": {"show": {"resource": ["Default"]}}, + "options": [{ + "name": "Download Audio", + "value": "Download Audio", + "action": "Download MP3 Audio", + "description": "Download MP3 Audio", + "routing": { + "request": { + "method": "GET", + "url": "=/samples/audio/mp3/sample3.mp3", + "headers": {"Accept": "image/*"}, + "json": false, + "encoding": "arraybuffer" + }, "output": {"postReceive": []} + } + }], + "default": "" + }, { + "displayName": "GET /samples/audio/mp3/sample3.mp3", + "name": "operation", + "type": "notice", + "typeOptions": {"theme": "info"}, + "default": "", + "displayOptions": { + "show": { + "resource": ["Default"], + "operation": ["Download Audio"] + } + } + }] + ); +}); + +test('video.json', async () => { + const doc = require('./samples/video.json'); + const config = {}; + const parser = new N8NPropertiesBuilder(doc, config); + const result = parser.build(); + + // Ensure the post Receive is a function + let postReceive = (result[1].options![0] as INodeProperties).routing!.output!.postReceive![0]; + expect(postReceive).toBeInstanceOf(Function); + + // @ts-ignore + expect((await postReceive([{json: 'test'}], { + headers: { + 'content-type': 'video/mp4' + } + }))[0].binary?.data.fileType).toBe('video'); + + delete (result[1].options![0] as INodeProperties).routing!.output!.postReceive![0]; + expect(result).toEqual([{ + "displayName": "Resource", + "name": "resource", + "type": "options", + "noDataExpression": true, + "options": [{"name": "Default", "value": "Default", "description": ""}], + "default": "" + }, { + "displayName": "Operation", + "name": "operation", + "type": "options", + "noDataExpression": true, + "displayOptions": {"show": {"resource": ["Default"]}}, + "options": [{ + "name": "Download Video", + "value": "Download Video", + "action": "Download MP4 Video", + "description": "Download MP4 Video", + "routing": { + "request": { + "method": "GET", + "url": "=/samples/video/mp4/sample_640x360.mp4", + "headers": {"Accept": "image/*"}, + "json": false, + "encoding": "arraybuffer" + }, "output": {"postReceive": []} + } + }], + "default": "" + }, { + "displayName": "GET /samples/video/mp4/sample_640x360.mp4", + "name": "operation", + "type": "notice", + "typeOptions": {"theme": "info"}, + "default": "", + "displayOptions": { + "show": { + "resource": ["Default"], + "operation": ["Download Video"] + } + } + }] + ); +}); + +test('pdf.json', async () => { + const doc = require('./samples/pdf.json'); + const config = {}; + const parser = new N8NPropertiesBuilder(doc, config); + const result = parser.build(); + + // Ensure the post Receive is a function + let postReceive = (result[1].options![0] as INodeProperties).routing!.output!.postReceive![0]; + expect(postReceive).toBeInstanceOf(Function); + + // @ts-ignore + expect((await postReceive([{json: 'test'}], { + headers: { + 'content-type': 'application/pdf' + } + }))[0].binary?.data.fileType).toBe('pdf'); + + delete (result[1].options![0] as INodeProperties).routing!.output!.postReceive![0]; + + expect(result).toEqual([{ + "displayName": "Resource", + "name": "resource", + "type": "options", + "noDataExpression": true, + "options": [{"name": "Default", "value": "Default", "description": ""}], + "default": "" + }, { + "displayName": "Operation", + "name": "operation", + "type": "options", + "noDataExpression": true, + "displayOptions": {"show": {"resource": ["Default"]}}, + "options": [{ + "name": "Download Audio", + "value": "Download Audio", + "action": "Download MP3 Audio", + "description": "Download MP3 Audio", + "routing": { + "request": { + "method": "GET", + "url": "=/storage/fed269b15c6809f599c9fce/2017/10/file-sample_150kB.pdf", + "headers": {"Accept": "image/*"}, + "json": false, + "encoding": "arraybuffer" + }, "output": {"postReceive": []} + } + }], + "default": "" + }, { + "displayName": "GET /storage/fed269b15c6809f599c9fce/2017/10/file-sample_150kB.pdf", + "name": "operation", + "type": "notice", + "typeOptions": {"theme": "info"}, + "default": "", + "displayOptions": { + "show": { + "resource": ["Default"], + "operation": ["Download Audio"] + } + } + }] + ); +}); \ No newline at end of file diff --git a/tests/samples/audio.json b/tests/samples/audio.json new file mode 100644 index 0000000..1b7e423 --- /dev/null +++ b/tests/samples/audio.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Video Download API", + "version": "1.0.0", + "description": "API for downloading an example MP4 video." + }, + "paths": { + "/samples/audio/mp3/sample3.mp3": { + "get": { + "summary": "Download MP3 Audio", + "operationId": "downloadAudio", + "responses": { + "200": { + "description": "Successful audio download", + "content": { + "audio/mp3": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "servers": [ + { + "url": "https://filesamples.com/" + } + ] +} \ No newline at end of file diff --git a/tests/samples/image.json b/tests/samples/image.json new file mode 100644 index 0000000..aad08a3 --- /dev/null +++ b/tests/samples/image.json @@ -0,0 +1,128 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Image example", + "version": "0" + }, + "tags": [ + { + "name": "Example" + } + ], + "components": { + "schemas": { + "ClientErrorResponse": { + "allOf": [ + { + "type": "object", + "required": [ + "errors" + ], + "properties": { + "errors": { + "type": "array", + "items": { + "type": "object", + "required": [ + "status", + "title", + "detail" + ], + "properties": { + "status": { + "type": "string", + "example": "404" + }, + "title": { + "type": "string", + "example": "Not found" + }, + "detail": { + "type": "string", + "example": "The requested resource cannot be found." + } + } + } + } + } + } + ] + } + } + }, + "paths": { + "/example/{id}/render/{parameter}": { + "get": { + "tags": [ + "Example" + ], + "summary": "Render the example by ID", + "description": "Renders something to a PNG image using the specified parameter and resolution.\nThis endpoint returns the rendered image as a PNG file.", + "operationId": "render_example_by_id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The ID of the Example to render", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "parameter", + "in": "path", + "description": "Some parameter we want", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 800, + "minimum": 0 + } + }, + { + "name": "height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 600, + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "The PNG image.", + "content": { + "image/png": {} + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientErrorResponse" + }, + "example": "\n{\n \"errors\": [\n {\n \"status\": \"404\",\n \"title\": \"Resource not found\",\n \"detail\": \"The requested resource could not be found.\"\n }\n ]\n}\n" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/samples/pdf.json b/tests/samples/pdf.json new file mode 100644 index 0000000..d78f16f --- /dev/null +++ b/tests/samples/pdf.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Video Download API", + "version": "1.0.0", + "description": "API for downloading an example MP4 video." + }, + "paths": { + "/storage/fed269b15c6809f599c9fce/2017/10/file-sample_150kB.pdf": { + "get": { + "summary": "Download MP3 Audio", + "operationId": "downloadAudio", + "responses": { + "200": { + "description": "Successful audio download", + "content": { + "audio/mp3": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "servers": [ + { + "url": "https://file-examples.com" + } + ] +} \ No newline at end of file diff --git a/tests/samples/video.json b/tests/samples/video.json new file mode 100644 index 0000000..c0ccfd5 --- /dev/null +++ b/tests/samples/video.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Video Download API", + "version": "1.0.0", + "description": "API for downloading an example MP4 video." + }, + "paths": { + "/samples/video/mp4/sample_640x360.mp4": { + "get": { + "summary": "Download MP4 Video", + "operationId": "downloadVideo", + "responses": { + "200": { + "description": "Successful video download", + "content": { + "video/mp4": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "servers": [ + { + "url": "https://filesamples.com/" + } + ] +} \ No newline at end of file