diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts index a0fdf917ed..f34561a96a 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts @@ -3,7 +3,7 @@ import { NodeConnectionType } from 'n8n-workflow'; import { containerFields, containerOperations } from './descriptions/ContainerDescription'; import { itemFields, itemOperations } from './descriptions/ItemDescription'; -import { searchCollections, searchItems } from './GenericFunctions'; +import { getDynamicFields, searchCollections, searchItems } from './GenericFunctions'; export class CosmosDb implements INodeType { description: INodeTypeDescription = { @@ -64,6 +64,7 @@ export class CosmosDb implements INodeType { listSearch: { searchCollections, searchItems, + getDynamicFields, }, }; } diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index e32bc036da..f22a1c342a 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -118,6 +118,41 @@ export async function handlePagination( return aggregatedResult.map((result) => ({ json: result })); } +export async function handleErrorPostReceive( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { + const responseBody = response.body as IDataObject; + + let errorMessage = 'Unknown error occurred'; + + if (typeof responseBody.message === 'string') { + try { + const jsonMatch = responseBody.message.match(/Message: (\{.*\})/); + + if (jsonMatch && jsonMatch[1]) { + const parsedMessage = JSON.parse(jsonMatch[1]); + + if ( + parsedMessage.Errors && + Array.isArray(parsedMessage.Errors) && + parsedMessage.Errors.length > 0 + ) { + errorMessage = parsedMessage.Errors[0].split(' Learn more:')[0].trim(); + } + } + } catch (error) { + errorMessage = 'Failed to extract error message'; + } + } + + throw new ApplicationError(errorMessage); + } + return data; +} + export async function microsoftCosmosDbRequest( this: ILoadOptionsFunctions, opts: IHttpRequestOptions, @@ -133,6 +168,7 @@ export async function microsoftCosmosDbRequest( ...opts, baseURL: `${credentials.baseUrl}`, headers: { + ...opts.headers, Accept: 'application/json', 'Content-Type': 'application/json', }, @@ -298,35 +334,69 @@ export async function validateOperations( requestOptions: IHttpRequestOptions, ): Promise { const rawOperations = this.getNodeParameter('operations', []) as IDataObject; - console.log('Operations', rawOperations); + if (!rawOperations || !Array.isArray(rawOperations.operations)) { throw new ApplicationError('The "operations" field must contain at least one operation.'); } const operations = rawOperations.operations as Array<{ op: string; - path: string; - value?: string; + path?: { mode: string; value: string }; + toPath?: { mode: string; value: string }; + from?: { mode: string; value: string }; + value?: string | number; }>; - for (const operation of operations) { - if (!['add', 'increment', 'move', 'remove', 'replace', 'set'].includes(operation.op)) { - throw new ApplicationError( - `Invalid operation type "${operation.op}". Allowed values are "add", "increment", "move", "remove", "replace", and "set".`, - ); - } - - if (!operation.path || operation.path.trim() === '') { + const transformedOperations = operations.map((operation) => { + if ( + operation.op !== 'move' && + (!operation.path?.value || + typeof operation.path.value !== 'string' || + operation.path.value.trim() === '') + ) { throw new ApplicationError('Each operation must have a valid "path".'); } if ( - ['set', 'replace', 'add', 'increment'].includes(operation.op) && + ['set', 'replace', 'add', 'incr'].includes(operation.op) && (operation.value === undefined || operation.value === null) ) { - throw new ApplicationError(`The operation "${operation.op}" must include a valid "value".`); + throw new ApplicationError(`The "${operation.op}" operation must include a valid "value".`); } - } + + if (operation.op === 'move') { + if ( + !operation.from?.value || + typeof operation.from.value !== 'string' || + operation.from.value.trim() === '' + ) { + throw new ApplicationError('The "move" operation must have a valid "from" path.'); + } + + if ( + !operation.toPath?.value || + typeof operation.toPath.value !== 'string' || + operation.toPath.value.trim() === '' + ) { + throw new ApplicationError('The "move" operation must have a valid "toPath".'); + } + } + + if (operation.op === 'incr' && isNaN(Number(operation.value))) { + throw new ApplicationError('The "increment" operation must have a numeric value.'); + } + + return { + op: operation.op, + path: operation.op === 'move' ? operation.toPath?.value : operation.path?.value, + ...(operation.from ? { from: operation.from.value } : {}), + ...(operation.op === 'incr' + ? { value: Number(operation.value) } + : { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) }), + }; + }); + + requestOptions.body = transformedOperations; return requestOptions; } @@ -488,3 +558,85 @@ export async function processResponseContainers( return []; } + +function extractFieldPaths(obj: any, prefix = ''): string[] { + let paths: string[] = []; + + Object.entries(obj).forEach(([key, value]) => { + if (key.startsWith('_') || key === 'id') { + return; + } + const newPath = prefix ? `${prefix}/${key}` : `/${key}`; + if (Array.isArray(value) && value.length > 0) { + value.forEach((item, index) => { + if (typeof item === 'object' && item !== null) { + paths = paths.concat(extractFieldPaths(item, `${newPath}/${index}`)); + } else { + paths.push(`${newPath}/${index}`); + } + }); + } else if (typeof value === 'object' && value !== null) { + paths = paths.concat(extractFieldPaths(value, newPath)); + } else { + paths.push(newPath); + } + }); + + return paths; +} + +export async function searchItemById( + this: ILoadOptionsFunctions, + itemId: string, +): Promise { + const collection = this.getNodeParameter('collId') as { mode: string; value: string }; + + if (!collection?.value) { + throw new ApplicationError('Collection ID is required.'); + } + + if (!itemId) { + throw new ApplicationError('Item ID is required.'); + } + + const opts: IHttpRequestOptions = { + method: 'GET', + url: `/colls/${collection.value}/docs/${itemId}`, + headers: { + 'x-ms-documentdb-partitionkey': `["${itemId}"]`, + }, + }; + + const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); + + if (!responseData) { + return null; + } + + return responseData; +} + +export async function getDynamicFields( + this: ILoadOptionsFunctions, +): Promise { + const itemId = this.getNodeParameter('id', '') as { mode: string; value: string }; + + if (!itemId) { + throw new ApplicationError('Item ID is required to fetch fields.'); + } + + const itemData = await searchItemById.call(this, itemId.value); + + if (!itemData) { + throw new ApplicationError(`Item with ID "${itemId.value}" not found.`); + } + + const fieldPaths = extractFieldPaths(itemData); + + return { + results: fieldPaths.map((path) => ({ + name: path, + value: path, + })), + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index aff7def5ec..140c1d9196 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -2,6 +2,7 @@ import type { INodeProperties } from 'n8n-workflow'; import { formatCustomProperties, + handleErrorPostReceive, handlePagination, processResponseItems, validateOperations, @@ -145,6 +146,9 @@ export const itemOperations: INodeProperties[] = [ 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', }, }, + output: { + postReceive: [handleErrorPostReceive], + }, }, action: 'Update item', }, @@ -155,7 +159,7 @@ export const itemOperations: INodeProperties[] = [ export const createFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -184,7 +188,7 @@ export const createFields: INodeProperties[] = [ displayName: 'By ID', name: 'containerId', type: 'string', - hint: 'Enter the container ID', + hint: 'Enter the container Id', validation: [ { type: 'regex', @@ -239,7 +243,7 @@ export const createFields: INodeProperties[] = [ export const deleteFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -274,7 +278,7 @@ export const deleteFields: INodeProperties[] = [ type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The container name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], @@ -330,7 +334,7 @@ export const deleteFields: INodeProperties[] = [ export const getFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -365,7 +369,7 @@ export const getFields: INodeProperties[] = [ type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The container name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], @@ -421,7 +425,7 @@ export const getFields: INodeProperties[] = [ export const getAllFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -456,7 +460,7 @@ export const getAllFields: INodeProperties[] = [ type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The container name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], @@ -506,7 +510,7 @@ export const getAllFields: INodeProperties[] = [ export const queryFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -621,7 +625,7 @@ export const queryFields: INodeProperties[] = [ export const updateFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -656,7 +660,7 @@ export const updateFields: INodeProperties[] = [ type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The container name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], @@ -736,29 +740,112 @@ export const updateFields: INodeProperties[] = [ type: 'options', options: [ { name: 'Add', value: 'add' }, - { name: 'Increment', value: 'increment' }, + { name: 'Increment', value: 'incr' }, { name: 'Move', value: 'move' }, { name: 'Remove', value: 'remove' }, + { name: 'Replace', value: 'replace' }, { name: 'Set', value: 'set' }, ], default: 'set', }, { - displayName: 'From', + displayName: 'From Path', name: 'from', - type: 'string', - default: '', + type: 'resourceLocator', + description: 'Select a field from the list or enter it manually', displayOptions: { show: { op: ['move'], }, }, + default: { + mode: 'list', + value: '', + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getDynamicFields', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'manual', + type: 'string', + hint: 'Enter the field name manually', + placeholder: 'e.g. /Parents/0/FamilyName', + }, + ], + }, + { + displayName: 'To Path', + name: 'toPath', + type: 'resourceLocator', + description: 'Select a field from the list or enter it manually', + displayOptions: { + show: { + op: ['move'], + }, + }, + default: { + mode: 'list', + value: '', + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getDynamicFields', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'manual', + type: 'string', + hint: 'Enter the field name manually', + placeholder: 'e.g. /Parents/0/FamilyName', + }, + ], }, { displayName: 'Path', name: 'path', - type: 'string', - default: '', + type: 'resourceLocator', + description: 'Select a field from the list or enter it manually', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + op: ['add', 'remove', 'set', 'incr', 'replace'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getDynamicFields', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'manual', + type: 'string', + hint: 'Enter the field name manually', + placeholder: 'e.g. /Parents/0/FamilyName', + }, + ], }, { displayName: 'Value', @@ -767,20 +854,13 @@ export const updateFields: INodeProperties[] = [ default: '', displayOptions: { show: { - op: ['add', 'set', 'increment'], + op: ['add', 'set', 'replace', 'incr'], }, }, }, ], }, ], - routing: { - send: { - type: 'body', - property: 'operations', - value: '={{ $parameter["operations"].operations }}', - }, - }, }, ];