diff --git a/src/N8NPropertiesBuilder.spec.ts b/src/N8NPropertiesBuilder.spec.ts index a67f2ee..02fc1da 100644 --- a/src/N8NPropertiesBuilder.spec.ts +++ b/src/N8NPropertiesBuilder.spec.ts @@ -1,11 +1,11 @@ -import {N8NPropertiesBuilder, Override} from './N8NPropertiesBuilder'; +import { N8NPropertiesBuilder, Override } from './N8NPropertiesBuilder'; -import {BaseOperationsCollector} from "./OperationsCollector"; -import {OpenAPIV3} from "openapi-types"; -import {OperationContext} from "./openapi/OpenAPIVisitor"; +import { BaseOperationsCollector } from "./OperationsCollector"; +import { OpenAPIV3 } from "openapi-types"; +import { OperationContext } from "./openapi/OpenAPIVisitor"; import * as lodash from "lodash"; -import {DefaultOperationParser} from "./OperationParser"; -import {DefaultResourceParser} from "./ResourceParser"; +import { DefaultOperationParser } from "./OperationParser"; +import { DefaultResourceParser } from "./ResourceParser"; export class CustomOperationParser extends DefaultOperationParser { name(operation: OpenAPIV3.OperationObject, context: OperationContext): string { @@ -58,7 +58,7 @@ test('query param - schema', () => { }, }; - const parser = new N8NPropertiesBuilder({paths}, { + const parser = new N8NPropertiesBuilder({ paths }, { operation: new CustomOperationParser(), resource: new CustomResourceParser(), }); @@ -207,7 +207,7 @@ test('query param - content', () => { }, }; - const parser = new N8NPropertiesBuilder({paths, components}, { + const parser = new N8NPropertiesBuilder({ paths, components }, { operation: new CustomOperationParser(), resource: new CustomResourceParser(), }); @@ -326,7 +326,7 @@ test('query param - dot in field name', () => { }, }; - const parser = new N8NPropertiesBuilder({paths}, { + const parser = new N8NPropertiesBuilder({ paths }, { operation: new CustomOperationParser(), resource: new CustomResourceParser(), }); @@ -436,7 +436,7 @@ test('path param', () => { }, }; - const parser = new N8NPropertiesBuilder({paths}, { + const parser = new N8NPropertiesBuilder({ paths }, { OperationsCollector: BaseOperationsCollector, operation: new CustomOperationParser(), resource: new CustomResourceParser(), @@ -555,7 +555,7 @@ test('request body', () => { }, }; - const parser = new N8NPropertiesBuilder({paths, components}, { + const parser = new N8NPropertiesBuilder({ paths, components }, { OperationsCollector: BaseOperationsCollector, operation: new CustomOperationParser(), resource: new CustomResourceParser(), @@ -659,7 +659,7 @@ test('request body', () => { operation: ['Create'], }, }, - default: JSON.stringify({foo: 'bar'}, null, 2), + default: JSON.stringify({ foo: 'bar' }, null, 2), required: undefined, routing: { "send": { @@ -701,7 +701,7 @@ test('enum schema', () => { }; // @ts-ignore - const parser = new N8NPropertiesBuilder({paths}, { + const parser = new N8NPropertiesBuilder({ paths }, { OperationsCollector: BaseOperationsCollector, operation: new CustomOperationParser(), resource: new CustomResourceParser(), @@ -786,29 +786,29 @@ test('enum schema', () => { }); test('body "array" param', () => { - const paths = { - '/api/entities': { - post: { - operationId: 'EntityController_create', - summary: 'Create entity', - requestBody: { - content: { - 'application/json': { - schema: { - type: 'array', - items: { - type: 'string', - }, + const paths = { + '/api/entities': { + post: { + operationId: 'EntityController_create', + summary: 'Create entity', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string', }, }, }, }, - tags: ['🖥️ Entity'], }, + tags: ['🖥️ Entity'], }, - }; + }, + }; - const parser = new N8NPropertiesBuilder({paths}, { + const parser = new N8NPropertiesBuilder({ paths }, { OperationsCollector: BaseOperationsCollector, operation: new CustomOperationParser(), resource: new CustomResourceParser(), @@ -880,8 +880,8 @@ test('body "array" param', () => { "type": "string" } ] - expect(result).toEqual(expected) - } + expect(result).toEqual(expected) +} ) test('test overrides', () => { @@ -955,7 +955,7 @@ test('test overrides', () => { }, ]; - const parser = new N8NPropertiesBuilder({paths, components}, { + const parser = new N8NPropertiesBuilder({ paths, components }, { OperationsCollector: BaseOperationsCollector, operation: new CustomOperationParser(), resource: new CustomResourceParser(), @@ -1099,7 +1099,7 @@ test('multiple tags', () => { }, }; - const parser = new N8NPropertiesBuilder({paths}, { + const parser = new N8NPropertiesBuilder({ paths }, { operation: new CustomOperationParser(), resource: new CustomResourceParser(), }) @@ -1297,7 +1297,7 @@ test('no tags - default tag', () => { }, }; - const parser = new N8NPropertiesBuilder({paths}, { + const parser = new N8NPropertiesBuilder({ paths }, { operation: new CustomOperationParser(), resource: new CustomResourceParser(), }); @@ -1394,3 +1394,109 @@ test('no tags - default tag', () => { ] ); }); + +test('array query parameters', () => { + const paths = { + '/api/locations': { + get: { + operationId: 'getLocations', + summary: 'Get locations within area', + parameters: [ + { + name: 'sw_corner[]', + in: 'query', + required: true, + description: 'Southwest corner coordinates [latitude, longitude]', + schema: { + type: 'array', + items: { + type: 'number' + }, + minItems: 2, + maxItems: 2 + }, + style: 'form', + explode: true, + example: [53.500403, -2.276471] + }, + { + name: 'ne_corner[]', + in: 'query', + required: true, + description: 'Northeast corner coordinates [latitude, longitude]', + schema: { + type: 'array', + items: { + type: 'number' + }, + minItems: 2, + maxItems: 2 + }, + style: 'form', + explode: true, + example: [53.521749, -2.259879] + }, + { + name: 'tags', + in: 'query', + required: false, + description: 'Filter by tags', + schema: { + type: 'array', + items: { + type: 'string' + } + }, + style: 'form', + explode: false + }, + { + name: 'limit', + in: 'query', + schema: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50 + }, + description: 'Maximum number of locations to return' + } + ], + tags: ['Locations'], + }, + }, + }; + + const parser = new N8NPropertiesBuilder({ paths }, { + OperationsCollector: BaseOperationsCollector, + operation: new CustomOperationParser(), + resource: new CustomResourceParser(), + }); + const result = parser.build(); + + // Find the sw_corner parameter + const swCornerField = result.find(field => field.name === 'sw_corner%5B%5D'); // URL encoded sw_corner[] + expect(swCornerField).toBeDefined(); + expect(swCornerField?.type).toBe('fixedCollection'); + expect(swCornerField?.typeOptions?.multipleValues).toBe(true); + expect(swCornerField?.required).toBe(true); + expect(swCornerField?.routing?.send?.type).toBe('query'); + expect(swCornerField?.routing?.send?.property).toBe('sw_corner[]'); + + // Find the ne_corner parameter + const neCornerField = result.find(field => field.name === 'ne_corner%5B%5D'); + expect(neCornerField).toBeDefined(); + expect(neCornerField?.type).toBe('fixedCollection'); + + // Find the tags parameter (comma-separated) + const tagsField = result.find(field => field.name === 'tags'); + expect(tagsField).toBeDefined(); + expect(tagsField?.type).toBe('fixedCollection'); + expect(tagsField?.routing?.send?.value).toContain('join(",")'); + + // Find the regular limit parameter + const limitField = result.find(field => field.name === 'limit'); + expect(limitField).toBeDefined(); + expect(limitField?.type).toBe('number'); + expect(limitField?.routing?.send?.value).toBe('={{ $value }}'); +}); diff --git a/src/n8n/SchemaToINodeProperties.ts b/src/n8n/SchemaToINodeProperties.ts index f4f70f9..381d9b9 100644 --- a/src/n8n/SchemaToINodeProperties.ts +++ b/src/n8n/SchemaToINodeProperties.ts @@ -1,8 +1,8 @@ -import {OpenAPIV3} from "openapi-types"; -import {INodeProperties, NodePropertyTypes} from "n8n-workflow"; -import {RefResolver} from "../openapi/RefResolver"; +import { OpenAPIV3 } from "openapi-types"; +import { INodeProperties, NodePropertyTypes } from "n8n-workflow"; +import { RefResolver } from "../openapi/RefResolver"; import * as lodash from "lodash"; -import {SchemaExample} from "../openapi/SchemaExample"; +import { SchemaExample } from "../openapi/SchemaExample"; type Schema = OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject; type FromSchemaNodeProperty = Pick; @@ -13,6 +13,12 @@ function combine(...sources: Partial[]): INodeProperties { // n8n does want to have required: false|null|undefined delete obj.required } + // Remove undefined values to prevent them from appearing in output + Object.keys(obj).forEach(key => { + if (obj[key] === undefined) { + delete obj[key]; + } + }); return obj } @@ -100,27 +106,41 @@ export class N8NINodeProperties { if (!fieldSchemaKeys) { throw new Error(`Parameter schema nor content not found`) } + + // Handle array query parameters specially + const schema = this.refResolver.resolve(parameter.schema || (parameter.content && findKey(parameter.content, /application\/json.*/)?.schema)); + const isArrayQueryParam = parameter.in === 'query' && schema && schema.type === 'array'; + + if (isArrayQueryParam) { + fieldSchemaKeys = this.fromArrayQueryParameter(schema, parameter); + } + const fieldParameterKeys: Partial = { displayName: lodash.startCase(parameter.name), name: encodeURIComponent(parameter.name.replace(/\./g, "-")), required: parameter.required, - description: parameter.description, - default: parameter.example, + ...(parameter.description && { description: parameter.description }), + ...(parameter.example !== undefined && { default: parameter.example }), }; const field = combine(fieldParameterKeys, fieldSchemaKeys) switch (parameter.in) { case "query": - field.routing = { - send: { - type: 'query', - property: parameter.name, - value: '={{ $value }}', - propertyInDotNotation: false, - }, - }; + if (isArrayQueryParam) { + // Array query parameters need special routing + field.routing = this.getArrayQueryRouting(parameter, schema); + } else { + field.routing = { + send: { + type: 'query', + property: parameter.name, + value: '={{ $value }}', + propertyInDotNotation: false, + }, + }; + } break; - case "path" : + case "path": field.required = true break case "header": @@ -141,6 +161,121 @@ export class N8NINodeProperties { return field } + private fromArrayQueryParameter(schema: OpenAPIV3.SchemaObject, parameter: OpenAPIV3.ParameterObject): Partial { + // Type guard to ensure this is an array schema + if (schema.type !== 'array' || !schema.items) { + throw new Error('fromArrayQueryParameter called with non-array schema'); + } + + const itemsSchema = this.refResolver.resolve(schema.items); + let defaultValue = this.schemaExample.extractExample(schema); + + // For array query parameters, we typically want a fixedCollection type + // that allows users to add multiple items + const field: Partial = { + type: 'fixedCollection', + default: {}, + description: schema.description || parameter.description, + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'items', + displayName: 'Items', + values: [ + { + displayName: 'Value', + name: 'value', + type: this.getArrayItemType(itemsSchema), + default: this.getArrayItemDefault(itemsSchema), + description: itemsSchema.description || `Item value (${itemsSchema.type || 'any'})`, + } + ] + } + ] + }; + + // If we have a specific example, use it for the default + if (defaultValue !== undefined && Array.isArray(defaultValue) && defaultValue.length > 0) { + field.default = { + items: defaultValue.map(val => ({ value: val })) + }; + } + + return field; + } + + private getArrayItemType(itemsSchema: OpenAPIV3.SchemaObject): NodePropertyTypes { + switch (itemsSchema.type) { + case 'boolean': + return 'boolean'; + case 'number': + case 'integer': + return 'number'; + case 'string': + default: + return 'string'; + } + } + + private getArrayItemDefault(itemsSchema: OpenAPIV3.SchemaObject): any { + const defaultValue = this.schemaExample.extractExample(itemsSchema); + if (defaultValue !== undefined) { + return defaultValue; + } + + switch (itemsSchema.type) { + case 'boolean': + return false; + case 'number': + case 'integer': + return 0; + case 'string': + default: + return ''; + } + } + + private getArrayQueryRouting(parameter: OpenAPIV3.ParameterObject, schema: OpenAPIV3.SchemaObject): any { + // Handle different array serialization styles + const style = parameter.style || 'form'; + const explode = parameter.explode !== false; // Default is true for form style + + if (style === 'form' && explode) { + // For form+explode style (most common for array query params) + // This handles cases like ?tags=tag1&tags=tag2 or ?sw_corner[]=1&sw_corner[]=2 + return { + send: { + type: 'query', + property: parameter.name, + value: '={{ $value.items ? $value.items.map(item => item.value) : [] }}', + propertyInDotNotation: false, + }, + }; + } else if (style === 'form' && !explode) { + // For form+no-explode style: ?tags=tag1,tag2 + return { + send: { + type: 'query', + property: parameter.name, + value: '={{ $value.items ? $value.items.map(item => item.value).join(",") : "" }}', + propertyInDotNotation: false, + }, + }; + } else { + // Default fallback to form+explode behavior + return { + send: { + type: 'query', + property: parameter.name, + value: '={{ $value.items ? $value.items.map(item => item.value) : [] }}', + propertyInDotNotation: false, + }, + }; + } + } + fromParameters(parameters: (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[] | undefined): INodeProperties[] { if (!parameters) { return []; diff --git a/tests/petstore.spec.ts b/tests/petstore.spec.ts index bc3b614..80c1d4b 100644 --- a/tests/petstore.spec.ts +++ b/tests/petstore.spec.ts @@ -1,5 +1,5 @@ -import {N8NPropertiesBuilder} from "../src/N8NPropertiesBuilder"; -import {INodeProperties} from "n8n-workflow"; +import { N8NPropertiesBuilder } from "../src/N8NPropertiesBuilder"; +import { INodeProperties } from "n8n-workflow"; test('petstore.json', () => { const doc = require('./samples/petstore.json'); @@ -746,7 +746,13 @@ test('petstore.json', () => { } }, { - "default": "[\n null\n]", + "default": { + "items": [ + { + "value": undefined, + }, + ], + }, "description": "Tags to filter by", "displayName": "Tags", "displayOptions": { @@ -760,15 +766,33 @@ test('petstore.json', () => { } }, "name": "tags", + "options": [ + { + "displayName": "Items", + "name": "items", + "values": [ + { + "default": "", + "description": "Item value (string)", + "displayName": "Value", + "name": "value", + "type": "string", + }, + ], + }, + ], "routing": { "send": { "property": "tags", "propertyInDotNotation": false, "type": "query", - "value": "={{ $value }}" + "value": "={{ $value.items ? $value.items.map(item => item.value) : [] }}" } }, - "type": "json" + "type": "fixedCollection", + "typeOptions": { + "multipleValues": true, + }, }, { "default": "", @@ -915,7 +939,6 @@ test('petstore.json', () => { }, { "default": "", - "description": "", "displayName": "Api Key", "displayOptions": { "show": {