From 704f9aa81a326f54d27b5bbd63deeda421bca87a Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Tue, 4 Feb 2025 16:04:27 +0200 Subject: [PATCH] Worked on requests --- ...crosoftCosmosDbSharedKeyApi.credentials.ts | 2 +- .../nodes/Microsoft/CosmosDB/CosmosDb.node.ts | 9 +- .../Microsoft/CosmosDB/GenericFunctions.ts | 232 +++++++++++++++++ .../descriptions/ContainerDescription.ts | 28 +- .../CosmosDB/descriptions/ItemDescription.ts | 240 ++++++++++++++---- 5 files changed, 431 insertions(+), 80 deletions(-) diff --git a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index d0dfa8a6af..987fd13a9f 100644 --- a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -71,7 +71,6 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { ...requestOptions.headers, 'x-ms-date': date, 'x-ms-version': '2018-12-31', - 'x-ms-partitionkey': '[]', }; if (credentials.sessionToken) { @@ -80,6 +79,7 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { // This shouldn't be the full url // Refer to https://stackoverflow.com/questions/45645389/documentdb-rest-api-authorization-token-error + // const url = new URL (requestOptions.uri); const url = new URL(requestOptions.baseURL + requestOptions.url); const pathSegments = url.pathname.split('/').filter((segment) => segment); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts index 5e37f8605f..028374ef9a 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 { presendStringifyBody, searchCollections } from './GenericFunctions'; +import { searchCollections, searchItems } from './GenericFunctions'; export class CosmosDb implements INodeType { description: INodeTypeDescription = { @@ -59,9 +59,9 @@ export class CosmosDb implements INodeType { type: 'options', noDataExpression: true, routing: { - send: { - preSend: [presendStringifyBody], - }, + // send: { + // preSend: [presendStringifyBody], + // }, }, options: [ { @@ -85,6 +85,7 @@ export class CosmosDb implements INodeType { methods = { listSearch: { searchCollections, + searchItems, }, }; } diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index e6cf960e35..b4de6a5591 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -231,3 +231,235 @@ export async function searchCollections( results, }; } + +export async function searchItems( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const collection = this.getNodeParameter('collId') as { mode: string; value: string }; + + if (!collection?.value) { + throw new ApplicationError('Collection ID is required.'); + } + const opts: IHttpRequestOptions = { + method: 'GET', + url: `/colls/${collection.value}/docs`, + }; + + const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); + + const responseBody = responseData as { + Documents: IDataObject[]; + }; + const items = responseBody.Documents; + + if (!items) { + return { results: [] }; + } + + const results: INodeListSearchItems[] = items + .map((item) => { + return { + name: String(item.id), + value: String(item.id), + }; + }) + .filter((item) => !filter || item.name.includes(filter)) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + results, + }; +} + +export async function validateQueryParameters( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const params = this.getNodeParameter('parameters', {}) as { + parameters: Array<{ name: string; value: string }>; + }; + + if (!params || !Array.isArray(params.parameters)) { + throw new ApplicationError( + 'The "parameters" field cannot be empty. Please add at least one parameter.', + ); + } + + const parameters = params.parameters; + + for (const parameter of parameters) { + if (!parameter.name || parameter.name.trim() === '') { + throw new ApplicationError('Each parameter must have a non-empty "name".'); + } + + if (!parameter.value) { + throw new ApplicationError(`The parameter "${parameter.name}" must have a valid "value".`); + } + } + + return requestOptions; +} + +export async function validateOperations( + this: IExecuteSingleFunctions, + 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; + }>; + + 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() === '') { + throw new ApplicationError('Each operation must have a valid "path".'); + } + + if ( + ['set', 'replace', 'add', 'increment'].includes(operation.op) && + (operation.value === undefined || operation.value === null) + ) { + throw new ApplicationError(`The operation "${operation.op}" must include a valid "value".`); + } + } + + return requestOptions; +} + +export async function formatCustomProperties( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawCustomProperties = this.getNodeParameter('customProperties', '{}') as string; + + let parsedProperties: Record; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedProperties = JSON.parse(rawCustomProperties); + } catch (error) { + throw new ApplicationError( + 'Invalid JSON format in "Custom Properties". Please provide a valid JSON object.', + ); + } + + if ( + typeof parsedProperties !== 'object' || + parsedProperties === null || + Array.isArray(parsedProperties) + ) { + throw new ApplicationError('The "Custom Properties" field must be a valid JSON object.'); + } + + if ( + !requestOptions.body || + typeof requestOptions.body !== 'object' || + requestOptions.body === null + ) { + requestOptions.body = {}; + } + + Object.assign(requestOptions.body as Record, parsedProperties); + + return requestOptions; +} + +export async function formatJSONFields( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawPartitionKey = this.getNodeParameter('partitionKey', '{}') as string; + const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; + const indexingPolicy = additionalFields.indexingPolicy as string; + + let parsedPartitionKey: Record; + let parsedIndexPolicy: Record | undefined; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedPartitionKey = JSON.parse(rawPartitionKey); + + if (indexingPolicy) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedIndexPolicy = JSON.parse(indexingPolicy); + } + } catch (error) { + throw new ApplicationError( + 'Invalid JSON format in either "Partition Key" or "Indexing Policy". Please provide valid JSON objects.', + ); + } + + if ( + !requestOptions.body || + typeof requestOptions.body !== 'object' || + requestOptions.body === null + ) { + requestOptions.body = {}; + } + + (requestOptions.body as Record).partitionKey = parsedPartitionKey; + + if (parsedIndexPolicy) { + (requestOptions.body as Record).indexingPolicy = parsedIndexPolicy; + } + + return requestOptions; +} + +export async function mapOperationsToRequest( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawOperations = this.getNodeParameter('operations', []) as { + operations: Array<{ + op: string; + path: string; + from?: string; + value?: string | number; + }>; + }; + + if (!rawOperations || !Array.isArray(rawOperations.operations)) { + throw new ApplicationError('Invalid operations format. Expected an array.'); + } + + // Map and validate operations + const formattedOperations = rawOperations.operations.map((operation) => { + const { op, path, from, value } = operation; + + // Validate required fields + if (!op || !path) { + throw new ApplicationError('Each operation must include "op" and "path".'); + } + + // Construct operation object + const formattedOperation: Record = { op, path }; + + // Add optional fields if they exist + if (from && op === 'move') { + formattedOperation.from = from; + } + if (value !== undefined && op !== 'remove') { + formattedOperation.value = value; + } + + return formattedOperation; + }); + + // Assign the formatted operations to the request body + requestOptions.body = { operations: formattedOperations }; + + return requestOptions; +} diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts index 2ceafa919f..d32a8b12e3 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts @@ -1,5 +1,7 @@ import type { INodeProperties } from 'n8n-workflow'; +import { formatJSONFields } from '../GenericFunctions'; + export const containerOperations: INodeProperties[] = [ { displayName: 'Operation', @@ -17,13 +19,13 @@ export const containerOperations: INodeProperties[] = [ value: 'create', description: 'Create a container', routing: { + send: { + preSend: [formatJSONFields], + }, request: { ignoreHttpStatusErrors: true, method: 'POST', url: '/colls', - headers: { - headers: {}, - }, }, }, action: 'Create container', @@ -75,7 +77,7 @@ export const containerOperations: INodeProperties[] = [ export const createFields: INodeProperties[] = [ { displayName: 'ID', - name: 'id', + name: 'newid', type: 'string', default: '', placeholder: 'e.g. AndersenFamily', @@ -109,13 +111,6 @@ export const createFields: INodeProperties[] = [ operation: ['create'], }, }, - routing: { - send: { - type: 'body', - property: 'partitionKey', - value: '={{$value}}', - }, - }, }, { displayName: 'Additional Fields', @@ -136,13 +131,6 @@ export const createFields: INodeProperties[] = [ placeholder: '"automatic": true, "indexingMode": "Consistent", "includedPaths": [{ "path": "/*", "indexes": [{ "dataType": "String", "precision": -1, "kind": "Range" }]}]', description: 'This value is used to configure indexing policy', - routing: { - send: { - type: 'body', - property: 'indexingPolicy', - value: '={{$value}}', - }, - }, }, { displayName: 'Max RU/s (for Autoscale)', @@ -191,7 +179,7 @@ export const createFields: INodeProperties[] = [ export const getFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -240,7 +228,7 @@ export const getAllFields: INodeProperties[] = []; export const deleteFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index f7d44d86e2..13f5ce611f 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -1,6 +1,11 @@ -import type { IExecuteSingleFunctions, IHttpRequestOptions, INodeProperties } from 'n8n-workflow'; +import type { INodeProperties } from 'n8n-workflow'; -import { handlePagination } from '../GenericFunctions'; +import { + formatCustomProperties, + handlePagination, + validateOperations, + validateQueryParameters, +} from '../GenericFunctions'; export const itemOperations: INodeProperties[] = [ { @@ -19,12 +24,17 @@ export const itemOperations: INodeProperties[] = [ value: 'create', description: 'Create a new item', routing: { + send: { + preSend: [formatCustomProperties], + }, request: { ignoreHttpStatusErrors: true, method: 'POST', url: '=/colls/{{ $parameter["collId"] }}/docs', headers: { - // 'x-ms-documentdb-partitionkey': '={{$parameter["partitionKey"]}}', + // 'x-ms-partitionkey': '=["{{$parameter["newId"]}}"]', + // 'x-ms-documentdb-partitionkey': '=["{{$parameter["newId"]}}"]', + 'x-ms-documentdb-is-upsert': 'True', }, }, }, @@ -48,16 +58,6 @@ export const itemOperations: INodeProperties[] = [ value: 'get', description: 'Retrieve an item', routing: { - send: { - preSend: [ - async function ( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, - ): Promise { - return requestOptions; - }, - ], - }, request: { ignoreHttpStatusErrors: true, method: 'GET', @@ -82,6 +82,16 @@ export const itemOperations: INodeProperties[] = [ method: 'GET', url: '=/colls/{{ $parameter["collId"] }}/docs', }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'json', + }, + }, + ], + }, }, action: 'Get many items', }, @@ -90,6 +100,9 @@ export const itemOperations: INodeProperties[] = [ value: 'query', description: 'Query items', routing: { + send: { + preSend: [validateQueryParameters], + }, request: { ignoreHttpStatusErrors: true, method: 'POST', @@ -107,12 +120,17 @@ export const itemOperations: INodeProperties[] = [ value: 'update', description: 'Update an existing item', routing: { + send: { + preSend: [validateOperations], + }, request: { ignoreHttpStatusErrors: true, method: 'PATCH', url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', headers: { 'Content-Type': 'application/json-patch+json', + 'x-ms-partitionkey': '=["{{$parameter["id"]}}"]', + 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', }, }, }, @@ -170,7 +188,7 @@ export const createFields: INodeProperties[] = [ }, // { // displayName: 'ID', - // name: 'id', + // name: 'newId', // type: 'string', // default: '', // placeholder: 'e.g. AndersenFamily', @@ -260,19 +278,48 @@ export const deleteFields: INodeProperties[] = [ ], }, { - displayName: 'ID', + displayName: 'Item', name: 'id', - type: 'string', - default: '', - placeholder: 'e.g. AndersenFamily', - description: 'Unique ID for the item', + type: 'resourceLocator', required: true, + default: { + mode: 'list', + value: '', + }, + description: "Select the item's ID", displayOptions: { show: { resource: ['item'], operation: ['delete'], }, }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchItems', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'itemName', + type: 'string', + hint: 'Enter the item name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The item name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. AndersenFamily', + }, + ], }, ]; @@ -322,19 +369,48 @@ export const getFields: INodeProperties[] = [ ], }, { - displayName: 'ID', + displayName: 'Item', name: 'id', - type: 'string', - default: '', - placeholder: 'e.g. AndersenFamily', - description: "Item's ID", + type: 'resourceLocator', required: true, + default: { + mode: 'list', + value: '', + }, + description: "Select the item's ID", displayOptions: { show: { resource: ['item'], operation: ['get'], }, }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchItems', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'itemName', + type: 'string', + hint: 'Enter the item name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The item name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. AndersenFamily', + }, + ], }, ]; @@ -474,7 +550,6 @@ export const queryFields: INodeProperties[] = [ type: 'string', default: '', required: true, - description: 'The SQL query text to execute', displayOptions: { show: { resource: ['item'], @@ -532,7 +607,8 @@ export const queryFields: INodeProperties[] = [ send: { type: 'body', property: 'parameters', - value: '={{$value}}', + value: + '={{$parameter["parameters"] && $parameter["parameters"].parameters ? $parameter["parameters"].parameters : []}}', }, }, }, @@ -584,56 +660,110 @@ export const updateFields: INodeProperties[] = [ ], }, { - displayName: 'ID', + displayName: 'Item', name: 'id', - type: 'string', - default: '', - placeholder: 'e.g. AndersenFamily', - description: 'Unique ID for the document', + type: 'resourceLocator', required: true, + default: { + mode: 'list', + value: '', + }, + description: "Select the item's ID", displayOptions: { show: { resource: ['item'], operation: ['update'], }, }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchItems', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'itemName', + type: 'string', + hint: 'Enter the item name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The item name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. AndersenFamily', + }, + ], }, - //TO-DO-check-this { displayName: 'Operations', name: 'operations', - type: 'resourceMapper', - default: { - mappingMode: 'defineBelow', - value: null, - }, + type: 'fixedCollection', + placeholder: 'Add Operation', + description: 'Patch operations to apply to the document', required: true, + default: [], typeOptions: { - resourceMapper: { - resourceMapperMethod: 'getMappingColumns', - mode: 'update', - fieldWords: { - singular: 'operation', - plural: 'operations', - }, - addAllFields: true, - multiKeyMatch: false, - supportAutoMap: true, - matchingFieldsLabels: { - title: 'Custom Matching Operations', - description: 'Define the operations to perform, such as "set", "delete", or "add".', - hint: 'Map input data to the expected structure of the operations array.', - }, - }, + multipleValues: true, }, - description: 'Define the operations to perform, such as setting or updating document fields', displayOptions: { show: { resource: ['item'], operation: ['update'], }, }, - //TO-DO-presend-function + options: [ + { + name: 'operations', + displayName: 'Operation', + values: [ + { + displayName: 'Operation', + name: 'op', + type: 'options', + options: [ + { name: 'Add', value: 'add' }, + { name: 'Increment', value: 'increment' }, + { name: 'Move', value: 'move' }, + { name: 'Remove', value: 'remove' }, + { name: 'Replace', value: 'replace' }, + { name: 'Set', value: 'set' }, + ], + default: 'set', + }, + { + displayName: 'Path', + name: 'path', + type: 'string', + default: '', + placeholder: '/Parents/0/FamilyName', + description: 'The path to the document field to be updated', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'The value to set (if applicable)', + }, + ], + }, + ], + routing: { + send: { + type: 'body', + property: 'operations', + value: '={{ $parameter["operations"].operations }}', + }, + }, }, ];