diff --git a/packages/nodes-base/credentials/MicrosoftAzureCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftAzureCosmosDbSharedKeyApi.credentials.ts new file mode 100644 index 0000000000..760bb19670 --- /dev/null +++ b/packages/nodes-base/credentials/MicrosoftAzureCosmosDbSharedKeyApi.credentials.ts @@ -0,0 +1,113 @@ +import { createHmac } from 'crypto'; +import type { + ICredentialDataDecryptedObject, + ICredentialType, + ICredentialTestRequest, + IHttpRequestOptions, + INodeProperties, + IRequestOptions, +} from 'n8n-workflow'; +import { OperationalError } from 'n8n-workflow'; + +import { + CURRENT_VERSION, + HeaderConstants, + RESOURCE_TYPES, +} from '../nodes/Microsoft/AzureCosmosDb/helpers/constants'; + +export class MicrosoftAzureCosmosDbSharedKeyApi implements ICredentialType { + name = 'microsoftAzureCosmosDbSharedKeyApi'; + + displayName = 'Microsoft Azure Cosmos DB API'; + + documentationUrl = 'microsoftAzureCosmosdb'; + + properties: INodeProperties[] = [ + { + displayName: 'Account', + name: 'account', + default: '', + description: 'Account name', + required: true, + type: 'string', + }, + { + displayName: 'Key', + name: 'key', + default: '', + description: 'Account key', + required: true, + type: 'string', + typeOptions: { + password: true, + }, + }, + { + displayName: 'Database', + name: 'database', + default: '', + description: 'Database name', + required: true, + type: 'string', + }, + ]; + + async authenticate( + credentials: ICredentialDataDecryptedObject, + requestOptions: IHttpRequestOptions, + ): Promise { + const date = new Date().toUTCString(); + + requestOptions.headers ??= {}; + requestOptions.headers = { + ...requestOptions.headers, + 'x-ms-date': date, + 'x-ms-version': CURRENT_VERSION, + 'Cache-Control': 'no-cache', + }; + + // HttpRequest node uses IRequestOptions.uri + const url = new URL( + (requestOptions as IRequestOptions).uri ?? requestOptions.baseURL + requestOptions.url, + ); + + const pathSegments = url.pathname.split('/').filter(Boolean); + + const foundResource = RESOURCE_TYPES.map((type) => ({ + type, + index: pathSegments.lastIndexOf(type), + })) + .filter(({ index }) => index !== -1) + .sort((a, b) => b.index - a.index) + .shift(); + + if (!foundResource) { + throw new OperationalError('Unable to determine the resource type from the URL'); + } + + const { type, index } = foundResource; + const resourceId = + pathSegments[index + 1] !== undefined + ? `${pathSegments.slice(0, index).join('/')}/${type}/${pathSegments[index + 1]}` + : pathSegments.slice(0, index).join('/'); + + const key = Buffer.from(credentials.key as string, 'base64'); + const payload = `${(requestOptions.method ?? 'GET').toLowerCase()}\n${type.toLowerCase()}\n${resourceId}\n${date.toLowerCase()}\n\n`; + const hmacSha256 = createHmac('sha256', key); + const signature = hmacSha256.update(payload, 'utf8').digest('base64'); + + requestOptions.headers[HeaderConstants.AUTHORIZATION] = encodeURIComponent( + `type=master&ver=1.0&sig=${signature}`, + ); + + return requestOptions; + } + + test: ICredentialTestRequest = { + request: { + baseURL: + '=https://{{ $credentials.account }}.documents.azure.com/dbs/{{ $credentials.database }}', + url: '/colls', + }, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.node.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.node.json new file mode 100644 index 0000000000..4ec801168c --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.azureCosmosDb", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Data & Storage"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/microsoft/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.azureCosmosDb/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.node.ts new file mode 100644 index 0000000000..cd64124c2a --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.node.ts @@ -0,0 +1,63 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import { container, item } from './descriptions'; +import { listSearch } from './methods'; + +export class AzureCosmosDb implements INodeType { + description: INodeTypeDescription = { + displayName: 'Azure Cosmos DB', + name: 'azureCosmosDb', + icon: { + light: 'file:AzureCosmosDb.svg', + dark: 'file:AzureCosmosDb.svg', + }, + group: ['transform'], + version: 1, + subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', + description: 'Interact with Azure Cosmos DB API', + defaults: { + name: 'Azure Cosmos DB', + }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'microsoftAzureCosmosDbSharedKeyApi', + required: true, + }, + ], + requestDefaults: { + baseURL: + '=https://{{ $credentials.account }}.documents.azure.com/dbs/{{ $credentials.database }}', + json: true, + ignoreHttpStatusErrors: true, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Container', + value: 'container', + }, + { + name: 'Item', + value: 'item', + }, + ], + default: 'container', + }, + + ...container.description, + ...item.description, + ], + }; + + methods = { + listSearch, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.svg b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.svg new file mode 100644 index 0000000000..c4f1f8cabe --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.svg @@ -0,0 +1 @@ + diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/common.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/common.ts new file mode 100644 index 0000000000..8fbc609a69 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/common.ts @@ -0,0 +1,136 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { HeaderConstants } from '../helpers/constants'; +import { untilContainerSelected } from '../helpers/utils'; + +export const containerResourceLocator: INodeProperties = { + displayName: 'Container', + name: 'container', + default: { + mode: 'list', + value: '', + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchContainers', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + hint: 'Enter the container ID', + placeholder: 'e.g. AndersenFamily', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The container ID must follow the allowed pattern', + }, + }, + ], + }, + ], + required: true, + type: 'resourceLocator', +}; + +export const itemResourceLocator: INodeProperties = { + displayName: 'Item', + name: 'item', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + hide: { + ...untilContainerSelected, + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchItems', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + hint: 'Enter the item ID', + placeholder: 'e.g. AndersenFamily', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The item ID must follow the allowed pattern', + }, + }, + ], + }, + ], + required: true, + type: 'resourceLocator', +}; + +export const paginationParameters: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + routing: { + send: { + paginate: '={{ $value }}', + }, + operations: { + pagination: { + type: 'generic', + properties: { + continue: `={{ !!$response.headers?.["${HeaderConstants.X_MS_CONTINUATION}"] }}`, + request: { + headers: { + [HeaderConstants.X_MS_CONTINUATION]: `={{ $response.headers?.["${HeaderConstants.X_MS_CONTINUATION}"] }}`, + }, + }, + }, + }, + }, + }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { + returnAll: [false], + }, + }, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_MAX_ITEM_COUNT]: '={{ $value || undefined }}', + }, + }, + }, + type: 'number', + typeOptions: { + minValue: 1, + }, + validateType: 'number', + }, +]; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/Container.resource.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/Container.resource.ts new file mode 100644 index 0000000000..3a0b63b903 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/Container.resource.ts @@ -0,0 +1,107 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as create from './create.operation'; +import * as del from './delete.operation'; +import * as get from './get.operation'; +import * as getAll from './getAll.operation'; +import { handleError } from '../../helpers/errorHandler'; +import { simplifyData } from '../../helpers/utils'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['container'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a container', + routing: { + request: { + method: 'POST', + url: '/colls', + }, + output: { + postReceive: [handleError], + }, + }, + action: 'Create container', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a container', + routing: { + request: { + method: 'DELETE', + url: '=/colls/{{ $parameter["container"] }}', + }, + output: { + postReceive: [ + handleError, + { + type: 'set', + properties: { + value: '={{ { "deleted": true } }}', + }, + }, + ], + }, + }, + action: 'Delete container', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a container', + routing: { + request: { + method: 'GET', + url: '=/colls/{{ $parameter["container"] }}', + }, + output: { + postReceive: [handleError, simplifyData], + }, + }, + action: 'Get container', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of containers', + routing: { + request: { + method: 'GET', + url: '/colls', + }, + output: { + postReceive: [ + handleError, + { + type: 'rootProperty', + properties: { + property: 'DocumentCollections', + }, + }, + simplifyData, + ], + }, + }, + action: 'Get many containers', + }, + ], + default: 'getAll', + }, + + ...create.description, + ...del.description, + ...get.description, + ...getAll.description, +]; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/create.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/create.operation.ts new file mode 100644 index 0000000000..79e52e1052 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/create.operation.ts @@ -0,0 +1,164 @@ +import type { + IDataObject, + IExecuteSingleFunctions, + IHttpRequestOptions, + INodeProperties, +} from 'n8n-workflow'; +import { OperationalError, updateDisplayOptions } from 'n8n-workflow'; + +import { HeaderConstants } from '../../helpers/constants'; +import { processJsonInput } from '../../helpers/utils'; + +const properties: INodeProperties[] = [ + { + displayName: 'ID', + name: 'containerCreate', + default: '', + description: 'Unique identifier for the new container', + placeholder: 'e.g. Container1', + required: true, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const id = this.getNodeParameter('containerCreate') as string; + + if (/\s/.test(id)) { + throw new OperationalError('The container ID must not contain spaces.'); + } + + if (!/^[a-zA-Z0-9-_]+$/.test(id)) { + throw new OperationalError( + 'The container ID may only contain letters, numbers, hyphens, and underscores.', + ); + } + + (requestOptions.body as IDataObject).id = id; + + return requestOptions; + }, + ], + }, + }, + type: 'string', + }, + { + displayName: 'Partition Key', + name: 'partitionKey', + default: '{\n\t"paths": [\n\t\t"/id"\n\t],\n\t"kind": "Hash",\n\t"version": 2\n}', + description: + 'The partition key is used to automatically distribute data across partitions for scalability. Choose a property in your JSON document that has a wide range of values and evenly distributes request volume.', + required: true, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const rawPartitionKey = this.getNodeParameter('partitionKey') as IDataObject; + const partitionKey = processJsonInput(rawPartitionKey, 'Partition Key', { + paths: ['/id'], + kind: 'Hash', + version: 2, + }); + (requestOptions.body as IDataObject).partitionKey = partitionKey; + return requestOptions; + }, + ], + }, + }, + type: 'json', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + default: {}, + options: [ + { + displayName: 'Indexing Policy', + name: 'indexingPolicy', + default: + '{\n\t"indexingMode": "consistent",\n\t"automatic": true,\n\t"includedPaths": [\n\t\t{\n\t\t\t"path": "/*"\n\t\t}\n\t],\n\t"excludedPaths": []\n}', + description: 'This value is used to configure indexing policy', + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const rawIndexingPolicy = this.getNodeParameter( + 'additionalFields.indexingPolicy', + ) as IDataObject; + const indexPolicy = processJsonInput(rawIndexingPolicy, 'Indexing Policy'); + (requestOptions.body as IDataObject).indexingPolicy = indexPolicy; + return requestOptions; + }, + ], + }, + }, + type: 'json', + }, + { + displayName: 'Max RU/s (for Autoscale)', + name: 'maxThroughput', + default: 1000, + description: 'The user specified autoscale max RU/s', + displayOptions: { + hide: { + '/additionalFields.offerThroughput': [{ _cnd: { exists: true } }], + }, + }, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_COSMOS_OFFER_AUTOPILOT_SETTING]: '={{ $value }}', + }, + }, + }, + type: 'number', + typeOptions: { + minValue: 1000, + }, + }, + { + displayName: 'Manual Throughput RU/s', + name: 'offerThroughput', + default: 400, + description: + 'The user specified manual throughput (RU/s) for the collection expressed in units of 100 request units per second', + displayOptions: { + hide: { + '/additionalFields.maxThroughput': [{ _cnd: { exists: true } }], + }, + }, + routing: { + request: { + headers: { + [HeaderConstants.X_MS_OFFER_THROUGHPUT]: '={{ $value }}', + }, + }, + }, + type: 'number', + typeOptions: { + minValue: 400, + }, + }, + ], + placeholder: 'Add Option', + type: 'collection', + }, +]; + +const displayOptions = { + show: { + resource: ['container'], + operation: ['create'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/delete.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/delete.operation.ts new file mode 100644 index 0000000000..097d12db63 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/delete.operation.ts @@ -0,0 +1,16 @@ +import { updateDisplayOptions, type INodeProperties } from 'n8n-workflow'; + +import { containerResourceLocator } from '../common'; + +const properties: INodeProperties[] = [ + { ...containerResourceLocator, description: 'Select the container you want to delete' }, +]; + +const displayOptions = { + show: { + resource: ['container'], + operation: ['delete'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/get.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/get.operation.ts new file mode 100644 index 0000000000..f243176c2d --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/get.operation.ts @@ -0,0 +1,23 @@ +import { updateDisplayOptions, type INodeProperties } from 'n8n-workflow'; + +import { containerResourceLocator } from '../common'; + +const properties: INodeProperties[] = [ + { ...containerResourceLocator, description: 'Select the container you want to retrieve' }, + { + displayName: 'Simplify', + name: 'simple', + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + type: 'boolean', + }, +]; + +const displayOptions = { + show: { + resource: ['container'], + operation: ['get'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/getAll.operation.ts new file mode 100644 index 0000000000..b598c4a2cf --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/container/getAll.operation.ts @@ -0,0 +1,23 @@ +import { updateDisplayOptions, type INodeProperties } from 'n8n-workflow'; + +import { paginationParameters } from '../common'; + +const properties: INodeProperties[] = [ + ...paginationParameters, + { + displayName: 'Simplify', + name: 'simple', + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + type: 'boolean', + }, +]; + +const displayOptions = { + show: { + resource: ['container'], + operation: ['getAll'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/index.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/index.ts new file mode 100644 index 0000000000..963fbc2149 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/index.ts @@ -0,0 +1,2 @@ +export * as container from './container/Container.resource'; +export * as item from './item/Item.resource'; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/Item.resource.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/Item.resource.ts new file mode 100644 index 0000000000..412a254657 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/Item.resource.ts @@ -0,0 +1,176 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as create from './create.operation'; +import * as del from './delete.operation'; +import * as get from './get.operation'; +import * as getAll from './getAll.operation'; +import * as query from './query.operation'; +import * as update from './update.operation'; +import { HeaderConstants } from '../../helpers/constants'; +import { handleError } from '../../helpers/errorHandler'; +import { simplifyData, validatePartitionKey } from '../../helpers/utils'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['item'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new item', + routing: { + send: { + preSend: [validatePartitionKey], + }, + request: { + method: 'POST', + url: '=/colls/{{ $parameter["container"] }}/docs', + headers: { + [HeaderConstants.X_MS_DOCUMENTDB_IS_UPSERT]: 'True', + }, + }, + output: { + postReceive: [handleError], + }, + }, + action: 'Create item', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an existing item', + routing: { + send: { + preSend: [validatePartitionKey], + }, + request: { + method: 'DELETE', + url: '=/colls/{{ $parameter["container"] }}/docs/{{ $parameter["item"] }}', + }, + output: { + postReceive: [ + handleError, + { + type: 'set', + properties: { + value: '={{ { "deleted": true } }}', + }, + }, + ], + }, + }, + action: 'Delete item', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve an item', + routing: { + send: { + preSend: [validatePartitionKey], + }, + request: { + method: 'GET', + url: '=/colls/{{ $parameter["container"]}}/docs/{{$parameter["item"]}}', + headers: { + [HeaderConstants.X_MS_DOCUMENTDB_IS_UPSERT]: 'True', + }, + }, + output: { + postReceive: [handleError, simplifyData], + }, + }, + action: 'Get item', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of items', + routing: { + request: { + method: 'GET', + url: '=/colls/{{ $parameter["container"] }}/docs', + }, + output: { + postReceive: [ + handleError, + { + type: 'rootProperty', + properties: { + property: 'Documents', + }, + }, + simplifyData, + ], + }, + }, + action: 'Get many items', + }, + { + name: 'Execute Query', + value: 'query', + routing: { + request: { + method: 'POST', + url: '=/colls/{{ $parameter["container"] }}/docs', + headers: { + 'Content-Type': 'application/query+json', + 'x-ms-documentdb-isquery': 'True', + 'x-ms-documentdb-query-enablecrosspartition': 'True', + }, + }, + output: { + postReceive: [ + handleError, + { + type: 'rootProperty', + properties: { + property: 'Documents', + }, + }, + simplifyData, + ], + }, + }, + action: 'Query items', + }, + { + name: 'Update', + value: 'update', + description: 'Update an existing item', + routing: { + send: { + preSend: [validatePartitionKey], + }, + request: { + method: 'PUT', + url: '=/colls/{{ $parameter["container"] }}/docs/{{ $parameter["item"] }}', + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }, + output: { + postReceive: [handleError], + }, + }, + action: 'Update item', + }, + ], + default: 'getAll', + }, + + ...create.description, + ...del.description, + ...get.description, + ...getAll.description, + ...query.description, + ...update.description, +]; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/create.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/create.operation.ts new file mode 100644 index 0000000000..f322f4aac7 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/create.operation.ts @@ -0,0 +1,57 @@ +import type { + IDataObject, + IExecuteSingleFunctions, + IHttpRequestOptions, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { processJsonInput, untilContainerSelected } from '../../helpers/utils'; +import { containerResourceLocator } from '../common'; + +const properties: INodeProperties[] = [ + { ...containerResourceLocator, description: 'Select the container you want to use' }, + { + displayName: 'Item Contents', + name: 'customProperties', + default: '{\n\t"id": "replace_with_new_document_id"\n}', + description: 'The item contents as a JSON object', + displayOptions: { + hide: { + ...untilContainerSelected, + }, + }, + hint: 'The item requires an ID and partition key value if a custom key is set', + required: true, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + const rawCustomProperties = this.getNodeParameter('customProperties') as IDataObject; + const customProperties = processJsonInput( + rawCustomProperties, + 'Item Contents', + undefined, + ['id'], + ); + requestOptions.body = customProperties; + return requestOptions; + }, + ], + }, + }, + type: 'json', + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['create'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/delete.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/delete.operation.ts new file mode 100644 index 0000000000..a07eaf4f76 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/delete.operation.ts @@ -0,0 +1,40 @@ +import { updateDisplayOptions, type INodeProperties } from 'n8n-workflow'; + +import { untilContainerSelected, untilItemSelected } from '../../helpers/utils'; +import { containerResourceLocator, itemResourceLocator } from '../common'; + +const properties: INodeProperties[] = [ + { ...containerResourceLocator, description: 'Select the container you want to use' }, + { ...itemResourceLocator, description: 'Select the item to be deleted' }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + default: {}, + displayOptions: { + hide: { + ...untilContainerSelected, + ...untilItemSelected, + }, + }, + options: [ + { + displayName: 'Partition Key', + name: 'partitionKey', + default: '', + hint: 'Only required if a custom partition key is set for the container', + type: 'string', + }, + ], + placeholder: 'Add Partition Key', + type: 'collection', + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['delete'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/get.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/get.operation.ts new file mode 100644 index 0000000000..b572c6ae8e --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/get.operation.ts @@ -0,0 +1,53 @@ +import { updateDisplayOptions, type INodeProperties } from 'n8n-workflow'; + +import { untilContainerSelected, untilItemSelected } from '../../helpers/utils'; +import { containerResourceLocator, itemResourceLocator } from '../common'; + +const properties: INodeProperties[] = [ + { ...containerResourceLocator, description: 'Select the container you want to use' }, + { ...itemResourceLocator, description: 'Select the item you want to retrieve' }, + { + displayName: 'Simplify', + name: 'simple', + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + displayOptions: { + hide: { + ...untilContainerSelected, + ...untilItemSelected, + }, + }, + type: 'boolean', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + default: {}, + displayOptions: { + hide: { + ...untilContainerSelected, + ...untilItemSelected, + }, + }, + options: [ + { + displayName: 'Partition Key', + name: 'partitionKey', + default: '', + hint: 'Only required if a custom partition key is set for the container', + type: 'string', + }, + ], + placeholder: 'Add Partition Key', + type: 'collection', + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['get'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/getAll.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/getAll.operation.ts new file mode 100644 index 0000000000..a644e6e9a2 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/getAll.operation.ts @@ -0,0 +1,24 @@ +import { updateDisplayOptions, type INodeProperties } from 'n8n-workflow'; + +import { containerResourceLocator, paginationParameters } from '../common'; + +const properties: INodeProperties[] = [ + { ...containerResourceLocator, description: 'Select the container you want to use' }, + ...paginationParameters, + { + displayName: 'Simplify', + name: 'simple', + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + type: 'boolean', + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['getAll'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/query.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/query.operation.ts new file mode 100644 index 0000000000..c64e4f60f2 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/query.operation.ts @@ -0,0 +1,80 @@ +import { updateDisplayOptions, type INodeProperties } from 'n8n-workflow'; + +import { validateQueryParameters } from '../../helpers/utils'; +import { containerResourceLocator } from '../common'; + +const properties: INodeProperties[] = [ + { ...containerResourceLocator, description: 'Select the container you want to use' }, + { + displayName: 'Query', + name: 'query', + default: '', + description: + "The SQL query to execute. Use $1, $2, $3, etc., to reference the 'Query Parameters' set in the options below.", + hint: 'Consider using query parameters to prevent SQL injection attacks. Add them in the options below.', + noDataExpression: true, + placeholder: 'e.g. SELECT id, name FROM c WHERE c.name = $1', + required: true, + routing: { + send: { + type: 'body', + property: 'query', + value: "={{ $value.replace(/\\$(\\d+)/g, '@Param$1') }}", + }, + }, + type: 'string', + typeOptions: { + editor: 'sqlEditor', + sqlDialect: 'StandardSQL', + }, + }, + { + displayName: 'Simplify', + name: 'simple', + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + type: 'boolean', + }, + { + displayName: 'Options', + name: 'options', + default: {}, + options: [ + { + displayName: 'Query Options', + name: 'queryOptions', + values: [ + { + displayName: 'Query Parameters', + name: 'queryParameters', + default: '', + description: + 'Comma-separated list of values used as query parameters. Use $1, $2, $3, etc., in your query.', + hint: 'Reference them in your query as $1, $2, $3…', + placeholder: 'e.g. value1,value2,value3', + routing: { + send: { + preSend: [validateQueryParameters], + }, + }, + type: 'string', + }, + ], + }, + ], + placeholder: 'Add options', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['query'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/update.operation.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/update.operation.ts new file mode 100644 index 0000000000..6d4ffe4a4a --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/descriptions/item/update.operation.ts @@ -0,0 +1,64 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from 'n8n-workflow'; + +import { + untilContainerSelected, + untilItemSelected, + validateCustomProperties, +} from '../../helpers/utils'; +import { containerResourceLocator, itemResourceLocator } from '../common'; + +const properties: INodeProperties[] = [ + { ...containerResourceLocator, description: 'Select the container you want to use' }, + { ...itemResourceLocator, description: 'Select the item to be updated' }, + { + displayName: 'Item Contents', + name: 'customProperties', + default: '{}', + description: 'The item contents as a JSON object', + displayOptions: { + hide: { + ...untilContainerSelected, + ...untilItemSelected, + }, + }, + required: true, + routing: { + send: { + preSend: [validateCustomProperties], + }, + }, + type: 'json', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + default: {}, + displayOptions: { + hide: { + ...untilContainerSelected, + ...untilItemSelected, + }, + }, + options: [ + { + displayName: 'Partition Key', + name: 'partitionKey', + type: 'string', + hint: 'Only required if a custom partition key is set for the container', + default: '', + }, + ], + placeholder: 'Add Partition Key', + type: 'collection', + }, +]; + +const displayOptions = { + show: { + resource: ['item'], + operation: ['update'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/constants.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/constants.ts new file mode 100644 index 0000000000..8aa47a1af6 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/constants.ts @@ -0,0 +1,22 @@ +export const RESOURCE_TYPES = [ + 'dbs', + 'colls', + 'sprocs', + 'udfs', + 'triggers', + 'users', + 'permissions', + 'docs', +]; + +export const CURRENT_VERSION = '2018-12-31'; + +export const HeaderConstants = { + AUTHORIZATION: 'authorization', + X_MS_CONTINUATION: 'x-ms-continuation', + X_MS_COSMOS_OFFER_AUTOPILOT_SETTING: 'x-ms-cosmos-offer-autopilot-setting', + X_MS_DOCUMENTDB_IS_UPSERT: 'x-ms-documentdb-is-upsert', + X_MS_DOCUMENTDB_PARTITIONKEY: 'x-ms-documentdb-partitionkey', + X_MS_MAX_ITEM_COUNT: 'x-ms-max-item-count', + X_MS_OFFER_THROUGHPUT: 'x-ms-offer-throughput', +}; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/errorHandler.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/errorHandler.ts new file mode 100644 index 0000000000..b3c2414328 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/errorHandler.ts @@ -0,0 +1,101 @@ +import type { + IExecuteSingleFunctions, + IN8nHttpFullResponse, + INodeExecutionData, + JsonObject, +} from 'n8n-workflow'; +import { jsonParse, NodeApiError } from 'n8n-workflow'; + +import type { IErrorResponse } from './interfaces'; + +export const ErrorMap = { + Container: { + Conflict: { + getMessage: (id: string) => `Container "${id}" already exists.`, + description: "Use a unique value for 'ID' and try again.", + }, + NotFound: { + getMessage: (id: string) => `Container "${id}" was not found.`, + description: "Double-check the value in the parameter 'Container' and try again.", + }, + }, + Item: { + NotFound: { + getMessage: (id: string) => `Item "${id}" was not found.`, + description: + "Double-check the values in the parameter 'Item' and 'Partition Key' (if applicable) and try again.", + }, + }, +}; + +export async function handleError( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { + const resource = this.getNodeParameter('resource') as string; + const error = response.body as IErrorResponse; + let errorMessage = error.message; + + let errorDetails: string[] | undefined = undefined; + + if (resource === 'container') { + if (error.code === 'Conflict') { + const newContainerValue = this.getNodeParameter('containerCreate') as string; + throw new NodeApiError(this.getNode(), error as unknown as JsonObject, { + message: ErrorMap.Container.Conflict.getMessage(newContainerValue ?? 'Unknown'), + description: ErrorMap.Container.Conflict.description, + }); + } + if (error.code === 'NotFound') { + const containerValue = this.getNodeParameter('container', undefined, { + extractValue: true, + }) as string; + throw new NodeApiError(this.getNode(), error as unknown as JsonObject, { + message: ErrorMap.Container.NotFound.getMessage(containerValue ?? 'Unknown'), + description: ErrorMap.Container.NotFound.description, + }); + } + } else if (resource === 'item') { + if (error.code === 'NotFound') { + const itemValue = this.getNodeParameter('item', undefined, { + extractValue: true, + }) as string; + throw new NodeApiError(this.getNode(), error as unknown as JsonObject, { + message: ErrorMap.Item.NotFound.getMessage(itemValue ?? 'Unknown'), + description: ErrorMap.Item.NotFound.description, + }); + } + } + + try { + // Certain error responses have nested Message + errorMessage = jsonParse<{ + message: string; + }>(errorMessage).message; + } catch {} + + const match = errorMessage.match(/Message: ({.*?})/); + if (match?.[1]) { + try { + errorDetails = jsonParse<{ + Errors: string[]; + }>(match[1]).Errors; + } catch {} + } + + if (errorDetails && errorDetails.length > 0) { + throw new NodeApiError(this.getNode(), error as unknown as JsonObject, { + message: error.code, + description: errorDetails.join('\n'), + }); + } else { + throw new NodeApiError(this.getNode(), error as unknown as JsonObject, { + message: error.code, + description: error.message, + }); + } + } + return data; +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/interfaces.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/interfaces.ts new file mode 100644 index 0000000000..0b6a437b5f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/interfaces.ts @@ -0,0 +1,17 @@ +export interface ICosmosDbCredentials { + account: string; + key: string; + database: string; + baseUrl: string; +} + +export interface IErrorResponse { + code: string; + message: string; +} + +export interface IContainer { + partitionKey: { + paths: string[]; + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/utils.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/utils.ts new file mode 100644 index 0000000000..80f6e99475 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/helpers/utils.ts @@ -0,0 +1,229 @@ +import type { + IDataObject, + IExecuteSingleFunctions, + IHttpRequestOptions, + IN8nHttpFullResponse, + INodeExecutionData, + NodeApiError, +} from 'n8n-workflow'; +import { jsonParse, NodeOperationError, OperationalError } from 'n8n-workflow'; + +import { HeaderConstants } from './constants'; +import { ErrorMap } from './errorHandler'; +import type { IContainer } from './interfaces'; +import { azureCosmosDbApiRequest } from '../transport'; + +export async function getPartitionKey(this: IExecuteSingleFunctions): Promise { + const container = this.getNodeParameter('container', undefined, { + extractValue: true, + }) as string; + + let partitionKeyField: string | undefined = undefined; + try { + const responseData = (await azureCosmosDbApiRequest.call( + this, + 'GET', + `/colls/${container}`, + )) as IContainer; + partitionKeyField = responseData.partitionKey?.paths[0]?.replace('/', ''); + } catch (error) { + const err = error as NodeApiError; + if (err.httpCode === '404') { + err.message = ErrorMap.Container.NotFound.getMessage(container); + err.description = ErrorMap.Container.NotFound.description; + } + throw err; + } + + if (!partitionKeyField) { + throw new NodeOperationError(this.getNode(), 'Partition key not found', { + description: 'Failed to determine the partition key for this collection', + }); + } + + return partitionKeyField; +} + +export async function simplifyData( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + _response: IN8nHttpFullResponse, +): Promise { + const simple = this.getNodeParameter('simple') as boolean; + + if (!simple) { + return items; + } + + const simplifyFields = (data: IDataObject): IDataObject => { + const simplifiedData = Object.keys(data) + .filter((key) => !key.startsWith('_')) + .reduce((acc, key) => { + acc[key] = data[key]; + return acc; + }, {} as IDataObject); + + return simplifiedData; + }; + + return items.map((item) => { + const simplifiedData = simplifyFields(item.json); + return { json: simplifiedData } as INodeExecutionData; + }); +} + +export async function validateQueryParameters( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const query = this.getNodeParameter('query', '') as string; + const queryOptions = this.getNodeParameter('options.queryOptions') as IDataObject; + + const parameterNames = query.replace(/\$(\d+)/g, '@Param$1').match(/@\w+/g) ?? []; + + const queryParamsString = queryOptions?.queryParameters as string; + const parameterValues = queryParamsString + ? queryParamsString.split(',').map((param) => param.trim()) + : []; + + if (parameterNames.length !== parameterValues.length) { + throw new NodeOperationError(this.getNode(), 'Empty parameter value provided', { + description: 'Please provide non-empty values for the query parameters', + }); + } + + requestOptions.body = { + ...(requestOptions.body as IDataObject), + parameters: parameterNames.map((name, index) => ({ + name, + value: parameterValues[index], + })), + }; + + return requestOptions; +} + +export function processJsonInput( + jsonData: T, + inputName?: string, + fallbackValue: T | undefined = undefined, + disallowSpacesIn?: string[], +): Record { + let values: Record = {}; + + const input = inputName ? `'${inputName}' ` : ''; + + if (typeof jsonData === 'string') { + try { + values = jsonParse(jsonData, { fallbackValue }) as Record; + } catch (error) { + throw new OperationalError(`Input ${input}must contain a valid JSON`, { level: 'warning' }); + } + } else if (jsonData && typeof jsonData === 'object') { + values = jsonData as Record; + } else { + throw new OperationalError(`Input ${input}must contain a valid JSON`, { level: 'warning' }); + } + + disallowSpacesIn?.forEach((key) => { + const value = values[key]; + if (typeof value === 'string' && value.includes(' ')) { + throw new OperationalError( + `${inputName ? `'${inputName}'` : ''} property '${key}' should not contain spaces (received "${value}")`, + { level: 'warning' }, + ); + } + }); + + return values; +} + +export async function validatePartitionKey( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const operation = this.getNodeParameter('operation') as string; + let customProperties = this.getNodeParameter('customProperties', {}) as IDataObject; + + const partitionKey = await getPartitionKey.call(this); + + if (typeof customProperties === 'string') { + try { + customProperties = jsonParse(customProperties); + } catch (error) { + throw new NodeOperationError(this.getNode(), 'Invalid JSON format in "Item Contents"', { + description: 'Ensure the "Item Contents" field contains a valid JSON object', + }); + } + } + + let partitionKeyValue: string = ''; + const needsPartitionKey = ['update', 'delete', 'get'].includes(operation); + + if (operation === 'create') { + if (!(partitionKey in customProperties) || !customProperties[partitionKey]) { + throw new NodeOperationError(this.getNode(), "Partition key not found in 'Item Contents'", { + description: `Partition key '${partitionKey}' must be present and have a valid, non-empty value in 'Item Contents'.`, + }); + } + partitionKeyValue = customProperties[partitionKey] as string; + } else if (needsPartitionKey) { + try { + partitionKeyValue = + partitionKey === 'id' + ? String(this.getNodeParameter('item', undefined, { extractValue: true }) ?? '') + : String(this.getNodeParameter('additionalFields.partitionKey', undefined) ?? ''); + + if (!partitionKeyValue) { + throw new NodeOperationError(this.getNode(), 'Partition key is empty', { + description: 'Ensure the "Partition Key" field has a valid, non-empty value.', + }); + } + } catch (error) { + throw new NodeOperationError(this.getNode(), 'Partition key is missing or empty', { + description: 'Ensure the "Partition Key" field exists and has a valid, non-empty value.', + }); + } + + if (operation === 'update') { + const idValue = String( + this.getNodeParameter('item', undefined, { extractValue: true }) ?? '', + ); + + (requestOptions.body as IDataObject).id = idValue; + (requestOptions.body as IDataObject)[partitionKey] = partitionKeyValue; + } + } + + requestOptions.headers = { + ...requestOptions.headers, + [HeaderConstants.X_MS_DOCUMENTDB_PARTITIONKEY]: `["${partitionKeyValue}"]`, + }; + + return requestOptions; +} + +export async function validateCustomProperties( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawCustomProperties = this.getNodeParameter('customProperties') as IDataObject; + const customProperties = processJsonInput(rawCustomProperties, 'Item Contents'); + if ( + Object.keys(customProperties).length === 0 || + Object.values(customProperties).every((val) => val === undefined || val === null || val === '') + ) { + throw new NodeOperationError(this.getNode(), 'Item contents are empty', { + description: 'Ensure the "Item Contents" field contains at least one valid property.', + }); + } + requestOptions.body = { + ...(requestOptions.body as IDataObject), + ...customProperties, + }; + return requestOptions; +} + +export const untilContainerSelected = { container: [''] }; + +export const untilItemSelected = { item: [''] }; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/methods/index.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/methods/index.ts new file mode 100644 index 0000000000..c7fb720e47 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/methods/index.ts @@ -0,0 +1 @@ +export * as listSearch from './listSearch'; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/methods/listSearch.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/methods/listSearch.ts new file mode 100644 index 0000000000..a578c61a6b --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/methods/listSearch.ts @@ -0,0 +1,76 @@ +import type { + IDataObject, + ILoadOptionsFunctions, + INodeListSearchResult, + INodeListSearchItems, +} from 'n8n-workflow'; + +import { HeaderConstants } from '../helpers/constants'; +import { azureCosmosDbApiRequest } from '../transport'; + +function formatResults(items: IDataObject[], filter?: string): INodeListSearchItems[] { + return items + .map(({ id }) => ({ + name: String(id).replace(/ /g, ''), + value: String(id), + })) + .filter(({ name }) => !filter || name.includes(filter)) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function searchContainers( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const headers = paginationToken ? { [HeaderConstants.X_MS_CONTINUATION]: paginationToken } : {}; + const responseData = (await azureCosmosDbApiRequest.call( + this, + 'GET', + '/colls', + {}, + {}, + headers, + true, + )) as { + body: IDataObject; + headers: IDataObject; + }; + + const containers = responseData.body.DocumentCollections as IDataObject[]; + + return { + results: formatResults(containers, filter), + paginationToken: responseData.headers[HeaderConstants.X_MS_CONTINUATION], + }; +} + +export async function searchItems( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const container = this.getCurrentNodeParameter('container', { + extractValue: true, + }) as string; + const headers = paginationToken ? { [HeaderConstants.X_MS_CONTINUATION]: paginationToken } : {}; + const responseData = (await azureCosmosDbApiRequest.call( + this, + 'GET', + `/colls/${container}/docs`, + {}, + {}, + headers, + true, + )) as { + body: IDataObject; + headers: IDataObject; + }; + + const items = responseData.body.Documents as IDataObject[]; + + return { + results: formatResults(items, filter), + paginationToken: responseData.headers[HeaderConstants.X_MS_CONTINUATION], + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/create.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/create.test.ts new file mode 100644 index 0000000000..5ada46bda6 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/create.test.ts @@ -0,0 +1,94 @@ +import nock from 'nock'; + +import { + getWorkflowFilenames, + initBinaryDataService, + testWorkflows, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Create Container', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('create.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .post('/colls', { + id: 'container1', + partitionKey: { + paths: ['/id'], + kind: 'Hash', + version: 2, + }, + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [ + { + path: '/*', + }, + ], + excludedPaths: [], + }, + }) + .matchHeader('x-ms-offer-throughput', '400') + .reply(201, { + id: 'container1', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [ + { + path: '/*', + }, + ], + excludedPaths: [ + { + path: '/"_etag"/?', + }, + ], + }, + partitionKey: { + paths: ['/id'], + kind: 'Hash', + version: 2, + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { + type: 'Geography', + }, + _rid: '4PVyAOKH+8U=', + _ts: 1742313299, + _self: 'dbs/4PVyAA==/colls/4PVyAOKH+8U=/', + _etag: '"00005702-0000-0300-0000-67d997530000"', + _docs: 'docs/', + _sprocs: 'sprocs/', + _triggers: 'triggers/', + _udfs: 'udfs/', + _conflicts: 'conflicts/', + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/create.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/create.workflow.json new file mode 100644 index 0000000000..06f9c3110f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/create.workflow.json @@ -0,0 +1,96 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "fabb9c53-ca91-4b11-84e3-3b172b10b83a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "create", + "containerCreate": "container1", + "additionalFields": { + "partitionKey": "{\n\t\"paths\": [\n\t\t\"/id\"\n\t],\n\t\"kind\": \"Hash\",\n\t\"version\": 2\n}", + "indexingPolicy": "{\n\t\"indexingMode\": \"consistent\",\n\t\"automatic\": true,\n\t\"includedPaths\": [\n\t\t{\n\t\t\t\"path\": \"/*\"\n\t\t}\n\t],\n\t\"excludedPaths\": []\n}", + "offerThroughput": 400 + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [220, 0], + "id": "134e2bbd-937a-4ee8-9ad7-7a745cf0905f", + "name": "Azure Cosmos DB", + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "exampleId", + "name": "Azure Cosmos DB account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Azure Cosmos DB", + "type": "main", + "index": 0 + } + ] + ] + }, + "Azure Cosmos DB": { + "main": [[]] + } + }, + "pinData": { + "Azure Cosmos DB": [ + { + "json": { + "id": "container1", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ] + }, + "partitionKey": { + "paths": ["/id"], + "kind": "Hash", + "version": 2 + }, + "conflictResolutionPolicy": { + "mode": "LastWriterWins", + "conflictResolutionPath": "/_ts", + "conflictResolutionProcedure": "" + }, + "geospatialConfig": { + "type": "Geography" + }, + "_rid": "4PVyAOKH+8U=", + "_ts": 1742313299, + "_self": "dbs/4PVyAA==/colls/4PVyAOKH+8U=/", + "_etag": "\"00005702-0000-0300-0000-67d997530000\"", + "_docs": "docs/", + "_sprocs": "sprocs/", + "_triggers": "triggers/", + "_udfs": "udfs/", + "_conflicts": "conflicts/" + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/delete.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/delete.test.ts new file mode 100644 index 0000000000..ff3d1c426f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/delete.test.ts @@ -0,0 +1,38 @@ +import nock from 'nock'; + +import { + initBinaryDataService, + testWorkflows, + getWorkflowFilenames, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Delete Container', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('delete.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .delete('/colls/container1') + .reply(204, {}); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/delete.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/delete.workflow.json new file mode 100644 index 0000000000..bbd05fac17 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/delete.workflow.json @@ -0,0 +1,60 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "fabb9c53-ca91-4b11-84e3-3b172b10b83a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "delete", + "container": { + "__rl": true, + "value": "container1", + "mode": "list", + "cachedResultName": "container1" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [220, 0], + "id": "134e2bbd-937a-4ee8-9ad7-7a745cf0905f", + "name": "Azure Cosmos DB", + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "exampleId", + "name": "Azure Cosmos DB account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Azure Cosmos DB", + "type": "main", + "index": 0 + } + ] + ] + }, + "Azure Cosmos DB": { + "main": [[]] + } + }, + "pinData": { + "Azure Cosmos DB": [ + { + "json": { + "deleted": true + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/get.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/get.test.ts new file mode 100644 index 0000000000..25427f1c21 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/get.test.ts @@ -0,0 +1,76 @@ +import nock from 'nock'; + +import { + initBinaryDataService, + testWorkflows, + getWorkflowFilenames, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Get Container', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('get.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/colls/container1') + .reply(200, { + id: 'container1', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [ + { + path: '/*', + }, + ], + excludedPaths: [ + { + path: '/"_etag"/?', + }, + ], + }, + partitionKey: { + paths: ['/id'], + kind: 'Hash', + version: 2, + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { + type: 'Geography', + }, + _rid: '4PVyAMPuBto=', + _ts: 1742298418, + _self: 'dbs/4PVyAA==/colls/4PVyAMPuBto=/', + _etag: '"00004402-0000-0300-0000-67d95d320000"', + _docs: 'docs/', + _sprocs: 'sprocs/', + _triggers: 'triggers/', + _udfs: 'udfs/', + _conflicts: 'conflicts/', + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/get.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/get.workflow.json new file mode 100644 index 0000000000..93fd318f02 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/get.workflow.json @@ -0,0 +1,87 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "fabb9c53-ca91-4b11-84e3-3b172b10b83a", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "operation": "get", + "container": { + "__rl": true, + "value": "container1", + "mode": "list", + "cachedResultName": "container1" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [220, 0], + "id": "134e2bbd-937a-4ee8-9ad7-7a745cf0905f", + "name": "Azure Cosmos DB", + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "exampleId", + "name": "Azure Cosmos DB account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Azure Cosmos DB", + "type": "main", + "index": 0 + } + ] + ] + }, + "Azure Cosmos DB": { + "main": [[]] + } + }, + "pinData": { + "Azure Cosmos DB": [ + { + "json": { + "id": "container1", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ] + }, + "partitionKey": { + "paths": ["/id"], + "kind": "Hash", + "version": 2 + }, + "conflictResolutionPolicy": { + "mode": "LastWriterWins", + "conflictResolutionPath": "/_ts", + "conflictResolutionProcedure": "" + }, + "geospatialConfig": { + "type": "Geography" + } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/getAll.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/getAll.test.ts new file mode 100644 index 0000000000..d1fd84fcbf --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/getAll.test.ts @@ -0,0 +1,178 @@ +import nock from 'nock'; + +import { + initBinaryDataService, + testWorkflows, + getWorkflowFilenames, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Get All Containers', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('getAll.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/colls') + .reply( + 200, + { + _rid: '4PVyAA==', + DocumentCollections: [ + { + id: 'newOne3', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [{ path: '/*' }], + excludedPaths: [{ path: '/"_etag"/?' }], + fullTextIndexes: [], + }, + partitionKey: { + paths: ['/id'], + kind: 'Hash', + version: 2, + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { type: 'Geography' }, + }, + { + id: 'newId', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [{ path: '/*' }], + excludedPaths: [{ path: '/"_etag"/?' }], + fullTextIndexes: [], + }, + partitionKey: { + paths: ['/id'], + kind: 'Hash', + version: 2, + }, + uniqueKeyPolicy: { + uniqueKeys: [], + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { type: 'Geography' }, + fullTextPolicy: { + defaultLanguage: 'en-US', + fullTextPaths: [], + }, + computedProperties: [], + }, + { + id: 'ContainerWithNameAsKey', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [{ path: '/*' }], + excludedPaths: [{ path: '/"_etag"/?' }], + fullTextIndexes: [], + }, + partitionKey: { + paths: ['/Name'], + kind: 'Hash', + version: 2, + }, + uniqueKeyPolicy: { + uniqueKeys: [], + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { type: 'Geography' }, + fullTextPolicy: { + defaultLanguage: 'en-US', + fullTextPaths: [], + }, + computedProperties: [], + }, + { + id: 'ContainerWithPhoneNrAsKey', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [{ path: '/*' }], + excludedPaths: [{ path: '/"_etag"/?' }], + fullTextIndexes: [], + }, + partitionKey: { + paths: ['/PhoneNumber'], + kind: 'Hash', + version: 2, + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { type: 'Geography' }, + }, + ], + _count: 4, + }, + { + 'x-ms-continuation': '4PVyAKoVaBQ=', + }, + ) + .get('/colls') + .matchHeader('x-ms-continuation', '4PVyAKoVaBQ=') + .reply(200, { + _rid: '4PVyAA==', + DocumentCollections: [ + { + id: 'ContainerWithPhoneNrAsKey', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [{ path: '/*' }], + excludedPaths: [{ path: '/"_etag"/?' }], + fullTextIndexes: [], + }, + partitionKey: { + paths: ['/PhoneNumber'], + kind: 'Hash', + version: 2, + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { type: 'Geography' }, + }, + ], + _count: 1, + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/getAll.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/getAll.workflow.json new file mode 100644 index 0000000000..e6e33d6d45 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/container/getAll.workflow.json @@ -0,0 +1,196 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-180, -520], + "id": "8218dacc-3b5f-460a-a773-817faf012ba9", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [20, -520], + "id": "4568cad5-4e55-4370-9fbe-7f19e03faa67", + "name": "getAllContainers", + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "iTUZNEdLdLW0RpF9", + "name": "Azure Cosmos DB account " + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "getAllContainers", + "type": "main", + "index": 0 + } + ] + ] + }, + "getAllContainers": { + "main": [[]] + } + }, + "pinData": { + "getAllContainers": [ + { + "json": { + "id": "newOne3", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ], + "fullTextIndexes": [] + }, + "partitionKey": { + "paths": ["/id"], + "kind": "Hash", + "version": 2 + }, + "conflictResolutionPolicy": { + "mode": "LastWriterWins", + "conflictResolutionPath": "/_ts", + "conflictResolutionProcedure": "" + }, + "geospatialConfig": { + "type": "Geography" + } + } + }, + { + "json": { + "id": "newId", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ], + "fullTextIndexes": [] + }, + "partitionKey": { + "paths": ["/id"], + "kind": "Hash", + "version": 2 + }, + "uniqueKeyPolicy": { + "uniqueKeys": [] + }, + "conflictResolutionPolicy": { + "mode": "LastWriterWins", + "conflictResolutionPath": "/_ts", + "conflictResolutionProcedure": "" + }, + "geospatialConfig": { + "type": "Geography" + }, + "fullTextPolicy": { + "defaultLanguage": "en-US", + "fullTextPaths": [] + }, + "computedProperties": [] + } + }, + { + "json": { + "id": "ContainerWithNameAsKey", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ], + "fullTextIndexes": [] + }, + "partitionKey": { + "paths": ["/Name"], + "kind": "Hash", + "version": 2 + }, + "uniqueKeyPolicy": { + "uniqueKeys": [] + }, + "conflictResolutionPolicy": { + "mode": "LastWriterWins", + "conflictResolutionPath": "/_ts", + "conflictResolutionProcedure": "" + }, + "geospatialConfig": { + "type": "Geography" + }, + "fullTextPolicy": { + "defaultLanguage": "en-US", + "fullTextPaths": [] + }, + "computedProperties": [] + } + }, + { + "json": { + "id": "ContainerWithPhoneNrAsKey", + "indexingPolicy": { + "indexingMode": "consistent", + "automatic": true, + "includedPaths": [ + { + "path": "/*" + } + ], + "excludedPaths": [ + { + "path": "/\"_etag\"/?" + } + ], + "fullTextIndexes": [] + }, + "partitionKey": { + "paths": ["/PhoneNumber"], + "kind": "Hash", + "version": 2 + }, + "conflictResolutionPolicy": { + "mode": "LastWriterWins", + "conflictResolutionPath": "/_ts", + "conflictResolutionProcedure": "" + }, + "geospatialConfig": { + "type": "Geography" + } + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/credentials/sharedKey.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/credentials/sharedKey.test.ts new file mode 100644 index 0000000000..177d283940 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/credentials/sharedKey.test.ts @@ -0,0 +1,132 @@ +import { OperationalError } from 'n8n-workflow'; +import type { + ICredentialDataDecryptedObject, + IHttpRequestOptions, + IRequestOptions, +} from 'n8n-workflow'; + +import { MicrosoftAzureCosmosDbSharedKeyApi } from '../../../../../credentials/MicrosoftAzureCosmosDbSharedKeyApi.credentials'; +import { FAKE_CREDENTIALS_DATA } from '../../../../../test/nodes/FakeCredentialsMap'; + +jest.mock('crypto', () => ({ + createHmac: jest.fn(() => ({ + update: jest.fn(() => ({ + digest: jest.fn(() => 'fake-signature'), + })), + })), +})); + +describe('Azure Cosmos DB', () => { + describe('authenticate', () => { + const azureCosmosDbSharedKeyApi = new MicrosoftAzureCosmosDbSharedKeyApi(); + + it('should generate a valid authorization header', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01T00:00:00Z')); + const credentials: ICredentialDataDecryptedObject = { + account: FAKE_CREDENTIALS_DATA.microsoftAzureCosmosDbSharedKeyApi.account, + key: FAKE_CREDENTIALS_DATA.microsoftAzureCosmosDbSharedKeyApi.key, + }; + const requestOptions: IHttpRequestOptions = { + url: `${FAKE_CREDENTIALS_DATA.microsoftAzureCosmosDbSharedKeyApi.baseUrl}/colls/container1/docs/item1`, + method: 'GET', + }; + const result = await azureCosmosDbSharedKeyApi.authenticate(credentials, requestOptions); + + expect(result.headers?.authorization).toBe( + 'type%3Dmaster%26ver%3D1.0%26sig%3Dfake-signature', + ); + }); + + it('should throw an error when unable to determine the resource type from the URL', async () => { + const requestOptions: IRequestOptions = { + uri: 'https://invalid-url.com/', + method: 'GET', + }; + + const urlString = + requestOptions.uri ?? + (requestOptions.baseURL && requestOptions.url + ? requestOptions.baseURL + requestOptions.url + : ''); + + if (!urlString) { + throw new OperationalError('Invalid URL: Both uri and baseURL+url are missing'); + } + + const url = new URL(urlString); + const pathSegments = url.pathname.split('/').filter(Boolean); + + const RESOURCE_TYPES = ['dbs', 'colls', 'docs', 'sprocs', 'udfs', 'triggers']; + + const foundResource = RESOURCE_TYPES.map((type) => ({ + type, + index: pathSegments.lastIndexOf(type), + })) + .filter(({ index }) => index !== -1) + .sort((a, b) => b.index - a.index) + .shift(); + + expect(foundResource).toBeUndefined(); + + expect(() => { + if (!foundResource) { + throw new OperationalError('Unable to determine the resource type from the URL'); + } + }).toThrowError(new OperationalError('Unable to determine the resource type from the URL')); + }); + + it('should throw OperationalError if no resource type found in URL path', async () => { + const requestOptions: IRequestOptions = { + uri: 'https://example.com/invalidpath', + method: 'GET', + }; + + const urlString = + requestOptions.uri ?? + (requestOptions.baseURL && requestOptions.url + ? requestOptions.baseURL + requestOptions.url + : ''); + + if (!urlString) { + throw new OperationalError('Invalid URL: Both uri and baseURL+url are missing'); + } + + const url = new URL(urlString); + const pathSegments = url.pathname.split('/').filter(Boolean); + + const RESOURCE_TYPES = ['dbs', 'colls', 'docs', 'sprocs', 'udfs', 'triggers']; + const foundResource = RESOURCE_TYPES.map((type) => ({ + type, + index: pathSegments.lastIndexOf(type), + })) + .filter(({ index }) => index !== -1) + .sort((a, b) => b.index - a.index) + .shift(); + + expect(foundResource).toBeUndefined(); + + expect(() => { + if (!foundResource) { + throw new OperationalError('Unable to determine the resource type from the URL'); + } + }).toThrowError(new OperationalError('Unable to determine the resource type from the URL')); + }); + + it('should properly construct the resourceId and payload', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-01-01T00:00:00Z')); + const credentials: ICredentialDataDecryptedObject = { + account: FAKE_CREDENTIALS_DATA.microsoftAzureCosmosDbSharedKeyApi.account, + key: FAKE_CREDENTIALS_DATA.microsoftAzureCosmosDbSharedKeyApi.key, + }; + const requestOptions: IHttpRequestOptions = { + url: 'https://example.com/dbs/mydb/colls/mycoll/docs/mydoc', + method: 'GET', + }; + const result = await azureCosmosDbSharedKeyApi.authenticate(credentials, requestOptions); + + expect(result.headers?.authorization).toBe( + 'type%3Dmaster%26ver%3D1.0%26sig%3Dfake-signature', + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/helpers/errorHandler.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/helpers/errorHandler.test.ts new file mode 100644 index 0000000000..ac58a0bcac --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/helpers/errorHandler.test.ts @@ -0,0 +1,177 @@ +import type { IN8nHttpFullResponse, INodeExecutionData, JsonObject } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import { handleError, ErrorMap } from '../../helpers/errorHandler'; + +const mockExecuteSingleFunctions = { + getNode: jest.fn(() => ({ name: 'MockNode' })), + getNodeParameter: jest.fn(), +} as any; + +describe('handleError', () => { + let response: IN8nHttpFullResponse; + let data: INodeExecutionData[]; + + beforeEach(() => { + data = [{}] as INodeExecutionData[]; + response = { statusCode: 200, body: {} } as IN8nHttpFullResponse; + }); + + test('should return data when no error occurs', async () => { + const result = await handleError.call(mockExecuteSingleFunctions, data, response); + expect(result).toBe(data); + }); + + test('should throw NodeApiError for container conflict', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue('container'); + + response.statusCode = 409; + response.body = { code: 'Conflict', message: 'Container already exists' } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrow( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: ErrorMap.Container.Conflict.getMessage('container'), + description: ErrorMap.Container.Conflict.description, + }), + ); + }); + + test('should throw NodeApiError for container not found', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue('container'); + + response.statusCode = 404; + response.body = { code: 'NotFound', message: 'Container not found' } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrow( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: ErrorMap.Container.NotFound.getMessage('container'), + description: ErrorMap.Container.NotFound.description, + }), + ); + }); + + test('should throw NodeApiError for item not found', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue('item'); + + response.statusCode = 404; + response.body = { code: 'NotFound', message: 'Item not found' } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrow( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: ErrorMap.Item.NotFound.getMessage('item'), + description: ErrorMap.Item.NotFound.description, + }), + ); + }); + + test('should throw generic error if no specific mapping exists', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue('container'); + + response.statusCode = 400; + response.body = { code: 'BadRequest', message: 'Invalid request' } as JsonObject; + + await expect(handleError.call(mockExecuteSingleFunctions, data, response)).rejects.toThrow( + new NodeApiError(mockExecuteSingleFunctions.getNode(), response.body as JsonObject, { + message: 'BadRequest', + description: 'Invalid request', + }), + ); + }); + + test('should handle error details correctly when match is successful', async () => { + const errorMessage = 'Message: {"Errors":["Error 1", "Error 2"]}'; + const match = errorMessage.match(/Message: ({.*?})/); + let errorDetails: string[] = []; + + if (match?.[1]) { + try { + errorDetails = JSON.parse(match[1]).Errors; + } catch {} + } + + expect(errorDetails).toEqual(['Error 1', 'Error 2']); + }); + + test('should handle error when match does not return expected format', async () => { + const errorMessage = 'Message: Invalid format'; + + const match = errorMessage.match(/Message: ({.*?})/); + let errorDetails: string[] = []; + + if (match?.[1]) { + try { + errorDetails = JSON.parse(match[1]).Errors; + } catch {} + } + + expect(errorDetails).toEqual([]); + }); + + test('should throw NodeApiError with proper details if error details are present', async () => { + const errorMessage = 'Message: {"Errors":["Specific error occurred"]}'; + const match = errorMessage.match(/Message: ({.*?})/); + let errorDetails: string[] = []; + + if (match?.[1]) { + try { + errorDetails = JSON.parse(match[1]).Errors; + } catch {} + } + + if (errorDetails && errorDetails.length > 0) { + await expect( + handleError.call(mockExecuteSingleFunctions, data, { + statusCode: 500, + body: { code: 'InternalServerError', message: errorMessage }, + headers: {}, + }), + ).rejects.toThrow( + new NodeApiError( + mockExecuteSingleFunctions.getNode(), + { + code: 'InternalServerError', + message: errorMessage, + } as JsonObject, + { + message: 'InternalServerError', + description: errorDetails.join('\n'), + }, + ), + ); + } + }); + + test('should throw NodeApiError with fallback message if no details found', async () => { + const errorMessage = 'Message: {"Errors":[] }'; + const match = errorMessage.match(/Message: ({.*?})/); + let errorDetails: string[] = []; + + if (match?.[1]) { + try { + errorDetails = JSON.parse(match[1]).Errors; + } catch {} + } + + if (errorDetails && errorDetails.length > 0) { + await expect( + handleError.call(mockExecuteSingleFunctions, data, { + statusCode: 500, + body: { code: 'InternalServerError', message: errorMessage }, + headers: {}, + }), + ).rejects.toThrow( + new NodeApiError( + mockExecuteSingleFunctions.getNode(), + { + code: 'InternalServerError', + message: errorMessage, + } as JsonObject, + { + message: 'InternalServerError', + description: 'Internal Server Error', + }, + ), + ); + } + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/helpers/utils.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/helpers/utils.test.ts new file mode 100644 index 0000000000..1f6e3c2fb4 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/helpers/utils.test.ts @@ -0,0 +1,390 @@ +import type { IDataObject, IHttpRequestOptions, INodeExecutionData } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError, OperationalError } from 'n8n-workflow'; + +import { ErrorMap } from '../../helpers/errorHandler'; +import { + getPartitionKey, + simplifyData, + validateQueryParameters, + processJsonInput, + validatePartitionKey, + validateCustomProperties, +} from '../../helpers/utils'; +import { azureCosmosDbApiRequest } from '../../transport'; + +interface RequestBodyWithParameters extends IDataObject { + parameters: Array<{ name: string; value: string }>; +} + +jest.mock('n8n-workflow', () => ({ + ...jest.requireActual('n8n-workflow'), + azureCosmosDbApiRequest: jest.fn(), +})); + +jest.mock('../../transport', () => ({ + azureCosmosDbApiRequest: jest.fn(), +})); + +describe('getPartitionKey', () => { + let mockExecuteSingleFunctions: any; + + beforeEach(() => { + mockExecuteSingleFunctions = { + getNodeParameter: jest.fn(), + getNode: jest.fn(() => ({ name: 'MockNode' })), + }; + }); + + test('should return partition key when found', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue('containerName'); + const mockApiResponse = { + partitionKey: { + paths: ['/partitionKeyPath'], + }, + }; + (azureCosmosDbApiRequest as jest.Mock).mockResolvedValue(mockApiResponse); + + const result = await getPartitionKey.call(mockExecuteSingleFunctions); + + expect(result).toBe('partitionKeyPath'); + }); + + test('should throw NodeOperationError if partition key is not found', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue('containerName'); + const mockApiResponse = {}; + (azureCosmosDbApiRequest as jest.Mock).mockResolvedValue(mockApiResponse); + + await expect(getPartitionKey.call(mockExecuteSingleFunctions)).rejects.toThrowError( + new NodeOperationError(mockExecuteSingleFunctions.getNode(), 'Partition key not found', { + description: 'Failed to determine the partition key for this collection', + }), + ); + }); + + test('should throw NodeApiError for 404 error', async () => { + const containerName = 'containerName'; + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue(containerName); + + const errorMessage = ErrorMap.Container.NotFound.getMessage(containerName); + + const mockError = new NodeApiError( + mockExecuteSingleFunctions.getNode(), + {}, + { + httpCode: '404', + message: errorMessage, + description: ErrorMap.Container.NotFound.description, + }, + ); + + (azureCosmosDbApiRequest as jest.Mock).mockRejectedValue(mockError); + + await expect(getPartitionKey.call(mockExecuteSingleFunctions)).rejects.toThrowError( + new NodeApiError( + mockExecuteSingleFunctions.getNode(), + {}, + { + message: errorMessage, + description: ErrorMap.Container.NotFound.description, + }, + ), + ); + }); +}); + +describe('validatePartitionKey', () => { + let mockExecuteSingleFunctions: any; + let requestOptions: any; + + beforeEach(() => { + mockExecuteSingleFunctions = { + getNodeParameter: jest.fn(), + getNode: jest.fn(() => ({ name: 'MockNode' })), + }; + requestOptions = { body: {}, headers: {} }; + + (azureCosmosDbApiRequest as jest.Mock).mockClear(); + }); + + test('should throw NodeOperationError when partition key is missing for "create" operation', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValueOnce('create'); + mockExecuteSingleFunctions.getNodeParameter.mockReturnValueOnce({}); + + const mockApiResponse = { + partitionKey: { + paths: ['/partitionKeyPath'], + }, + }; + (azureCosmosDbApiRequest as jest.Mock).mockResolvedValue(mockApiResponse); + + await expect( + validatePartitionKey.call(mockExecuteSingleFunctions, requestOptions), + ).rejects.toThrowError( + new NodeOperationError( + mockExecuteSingleFunctions.getNode(), + "Partition key not found in 'Item Contents'", + { + description: + "Partition key 'partitionKey' must be present and have a valid, non-empty value in 'Item Contents'.", + }, + ), + ); + }); + + test('should throw NodeOperationError when partition key is missing for "update" operation', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValueOnce('update'); + mockExecuteSingleFunctions.getNodeParameter.mockReturnValueOnce({ partitionKey: '' }); + + const mockApiResponse = { + partitionKey: { + paths: ['/partitionKeyPath'], + }, + }; + (azureCosmosDbApiRequest as jest.Mock).mockResolvedValue(mockApiResponse); + + await expect( + validatePartitionKey.call(mockExecuteSingleFunctions, requestOptions), + ).rejects.toThrowError( + new NodeOperationError( + mockExecuteSingleFunctions.getNode(), + 'Partition key is missing or empty', + { + description: 'Ensure the "Partition Key" field has a valid, non-empty value.', + }, + ), + ); + }); + + test('should throw NodeOperationError when partition key is missing for "get" operation', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValueOnce('get'); + mockExecuteSingleFunctions.getNodeParameter.mockReturnValueOnce(undefined); + + const mockApiResponse = { + partitionKey: { + paths: ['/partitionKeyPath'], + }, + }; + (azureCosmosDbApiRequest as jest.Mock).mockResolvedValue(mockApiResponse); + + await expect( + validatePartitionKey.call(mockExecuteSingleFunctions, requestOptions), + ).rejects.toThrowError( + new NodeOperationError( + mockExecuteSingleFunctions.getNode(), + 'Partition key is missing or empty', + { + description: 'Ensure the "Partition Key" field exists and has a valid, non-empty value.', + }, + ), + ); + }); + + test('should throw NodeOperationError when invalid JSON is provided for customProperties', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValueOnce('create'); + mockExecuteSingleFunctions.getNodeParameter.mockReturnValueOnce('invalidJson'); + + const mockApiResponse = { + partitionKey: { + paths: ['/partitionKeyPath'], + }, + }; + (azureCosmosDbApiRequest as jest.Mock).mockResolvedValue(mockApiResponse); + + await expect( + validatePartitionKey.call(mockExecuteSingleFunctions, requestOptions), + ).rejects.toThrowError( + new NodeOperationError( + mockExecuteSingleFunctions.getNode(), + 'Invalid JSON format in "Item Contents"', + { + description: 'Ensure the "Item Contents" field contains a valid JSON object', + }, + ), + ); + }); +}); + +describe('simplifyData', () => { + let mockExecuteSingleFunctions: any; + + beforeEach(() => { + mockExecuteSingleFunctions = { + getNodeParameter: jest.fn(), + getNode: jest.fn(() => ({ name: 'MockNode' })), + }; + }); + + test('should return the same data when "simple" parameter is false', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue(false); + const items = [{ json: { foo: 'bar' } }] as INodeExecutionData[]; + + const result = await simplifyData.call(mockExecuteSingleFunctions, items, {} as any); + + expect(result).toEqual(items); + }); + + test('should simplify the data when "simple" parameter is true', async () => { + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue(true); + const items = [{ json: { _internalKey: 'value', foo: 'bar' } }] as INodeExecutionData[]; + + const result = await simplifyData.call(mockExecuteSingleFunctions, items, {} as any); + + expect(result).toEqual([{ json: { foo: 'bar' } }]); + }); +}); + +describe('validateQueryParameters', () => { + let mockExecuteSingleFunctions: any; + let requestOptions: IHttpRequestOptions; + + beforeEach(() => { + mockExecuteSingleFunctions = { + getNodeParameter: jest.fn(), + getNode: jest.fn(() => ({ name: 'MockNode' })), + }; + requestOptions = { body: {}, headers: {} } as IHttpRequestOptions; + }); + + test('should throw NodeOperationError when parameter values do not match', async () => { + mockExecuteSingleFunctions.getNodeParameter + .mockReturnValueOnce('$1') + .mockReturnValueOnce({ queryParameters: 'param1, param2' }); + + await expect( + validateQueryParameters.call(mockExecuteSingleFunctions, requestOptions), + ).rejects.toThrowError( + new NodeOperationError( + mockExecuteSingleFunctions.getNode(), + 'Empty parameter value provided', + { + description: 'Please provide non-empty values for the query parameters', + }, + ), + ); + }); + + test('should successfully map parameters when they match', async () => { + mockExecuteSingleFunctions.getNodeParameter + .mockReturnValueOnce('$1, $2') + .mockReturnValueOnce({ queryParameters: 'value1, value2' }); + + const result = await validateQueryParameters.call(mockExecuteSingleFunctions, requestOptions); + + if (result.body && (result.body as RequestBodyWithParameters).parameters) { + expect((result.body as RequestBodyWithParameters).parameters).toEqual([ + { name: '@Param1', value: 'value1' }, + { name: '@Param2', value: 'value2' }, + ]); + } else { + throw new OperationalError('Expected result.body to contain a parameters array'); + } + }); + + test('should correctly map parameters when query contains multiple dynamic values', async () => { + mockExecuteSingleFunctions.getNodeParameter + .mockReturnValueOnce('$1, $2, $3') + .mockReturnValueOnce({ queryParameters: 'firstValue, secondValue, thirdValue' }); + + const result = await validateQueryParameters.call(mockExecuteSingleFunctions, requestOptions); + + if (result.body && (result.body as RequestBodyWithParameters).parameters) { + expect((result.body as RequestBodyWithParameters).parameters).toEqual([ + { name: '@Param1', value: 'firstValue' }, + { name: '@Param2', value: 'secondValue' }, + { name: '@Param3', value: 'thirdValue' }, + ]); + } else { + throw new OperationalError('Expected result.body to contain a parameters array'); + } + }); + + test('should extract and map parameter names correctly using regex', async () => { + const query = '$1, $2, $3'; + const queryParamsString = 'value1, value2, value3'; + + const parameterNames = query.replace(/\$(\d+)/g, '@param$1').match(/@\w+/g) ?? []; + + const parameterValues = queryParamsString.split(',').map((val) => val.trim()); + + expect(parameterNames).toEqual(['@param1', '@param2', '@param3']); + expect(parameterValues).toEqual(['value1', 'value2', 'value3']); + }); +}); + +describe('processJsonInput', () => { + test('should return parsed JSON when input is a valid JSON string', () => { + const result = processJsonInput('{"key": "value"}'); + + expect(result).toEqual({ key: 'value' }); + }); + + test('should return input data when it is already an object', () => { + const result = processJsonInput({ key: 'value' }); + + expect(result).toEqual({ key: 'value' }); + }); + + test('should throw OperationalError for invalid JSON string', () => { + const invalidJson = '{key: value}'; + expect(() => processJsonInput(invalidJson)).toThrowError( + new OperationalError('Input must contain a valid JSON', { level: 'warning' }), + ); + }); + + test('should throw OperationalError for invalid non-string and non-object input', () => { + const invalidInput = 123; + expect(() => processJsonInput(invalidInput, 'testInput')).toThrowError( + new OperationalError("Input 'testInput' must contain a valid JSON", { level: 'warning' }), + ); + }); +}); + +describe('validateCustomProperties', () => { + let mockExecuteSingleFunctions: any; + let requestOptions: any; + + beforeEach(() => { + mockExecuteSingleFunctions = { + getNodeParameter: jest.fn(), + getNode: jest.fn(() => ({ name: 'MockNode' })), + }; + requestOptions = { body: {}, headers: {}, url: 'http://mock.url' }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should merge custom properties into requestOptions.body for valid input', async () => { + const validCustomProperties = { property1: 'value1', property2: 'value2' }; + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue(validCustomProperties); + + const result = await validateCustomProperties.call(mockExecuteSingleFunctions, requestOptions); + + expect(result.body).toEqual({ property1: 'value1', property2: 'value2' }); + }); + + test('should throw NodeOperationError when customProperties are empty, undefined, null, or contain only invalid values', async () => { + const emptyCustomProperties = {}; + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue(emptyCustomProperties); + + await expect( + validateCustomProperties.call(mockExecuteSingleFunctions, requestOptions), + ).rejects.toThrowError( + new NodeOperationError(mockExecuteSingleFunctions.getNode(), 'Item contents are empty', { + description: 'Ensure the "Item Contents" field contains at least one valid property.', + }), + ); + + const invalidValues = { property1: null, property2: '' }; + mockExecuteSingleFunctions.getNodeParameter.mockReturnValue(invalidValues); + + await expect( + validateCustomProperties.call(mockExecuteSingleFunctions, requestOptions), + ).rejects.toThrowError( + new NodeOperationError(mockExecuteSingleFunctions.getNode(), 'Item contents are empty', { + description: 'Ensure the "Item Contents" field contains at least one valid property.', + }), + ); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/create.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/create.test.ts new file mode 100644 index 0000000000..8ddbf66cd8 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/create.test.ts @@ -0,0 +1,76 @@ +import nock from 'nock'; + +import { + initBinaryDataService, + testWorkflows, + getWorkflowFilenames, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Create Item', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('create.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/colls/container1') + .reply(200, { + id: 'containerFromSDK', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [{ path: '/*' }], + excludedPaths: [{ path: '/"_etag"/?' }], + }, + partitionKey: { + paths: ['/id'], + kind: 'Hash', + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { + type: 'Geography', + }, + _rid: '4PVyAMLVz0c=', + _ts: 1739178329, + _self: 'dbs/4PVyAA==/colls/4PVyAMLVz0c=/', + _etag: '"0000f905-0000-0300-0000-67a9c1590000"', + _docs: 'docs/', + _sprocs: 'sprocs/', + _triggers: 'triggers/', + _udfs: 'udfs/', + _conflicts: 'conflicts/', + }) + .post('/colls/container1/docs') + .reply(201, { + id: 'item1', + _rid: '4PVyAMPuBtoEAAAAAAAAAA==', + _self: 'dbs/4PVyAA==/colls/4PVyAMPuBto=/docs/4PVyAMPuBtoEAAAAAAAAAA==/', + _etag: '"bb000143-0000-0300-0000-67d9a2430000"', + _attachments: 'attachments/', + _ts: 1742316099, + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/create.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/create.workflow.json new file mode 100644 index 0000000000..9b557d3c0f --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/create.workflow.json @@ -0,0 +1,64 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "49466d78-738a-4dee-a2fb-2143f7800b45", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "item", + "operation": "create", + "container": { + "__rl": true, + "value": "container1", + "mode": "list", + "cachedResultName": "container1" + }, + "customProperties": "{\n\t\"id\": \"item1\"\n}", + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [220, 0], + "id": "00d6b986-6b8a-43ee-a6a5-b6a7c165c991", + "name": "Azure Cosmos Db", + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "exampleId", + "name": "Azure Cosmos DB account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Azure Cosmos Db", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "Azure Cosmos Db": [ + { + "json": { + "id": "item1", + "_rid": "4PVyAMPuBtoEAAAAAAAAAA==", + "_self": "dbs/4PVyAA==/colls/4PVyAMPuBto=/docs/4PVyAMPuBtoEAAAAAAAAAA==/", + "_etag": "\"bb000143-0000-0300-0000-67d9a2430000\"", + "_attachments": "attachments/", + "_ts": 1742316099 + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/delete.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/delete.test.ts new file mode 100644 index 0000000000..ad58c47a51 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/delete.test.ts @@ -0,0 +1,69 @@ +import nock from 'nock'; + +import { + initBinaryDataService, + testWorkflows, + getWorkflowFilenames, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Delete Item', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('delete.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/colls/container1') + .reply(200, { + id: 'containerFromSDK', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [{ path: '/*' }], + excludedPaths: [{ path: '/"_etag"/?' }], + }, + partitionKey: { + paths: ['/id'], + kind: 'Hash', + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { + type: 'Geography', + }, + _rid: '4PVyAMLVz0c=', + _ts: 1739178329, + _self: 'dbs/4PVyAA==/colls/4PVyAMLVz0c=/', + _etag: '"0000f905-0000-0300-0000-67a9c1590000"', + _docs: 'docs/', + _sprocs: 'sprocs/', + _triggers: 'triggers/', + _udfs: 'udfs/', + _conflicts: 'conflicts/', + }) + .delete('/colls/container1/docs/item1') + .reply(204, ''); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/delete.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/delete.workflow.json new file mode 100644 index 0000000000..1a743d4fd0 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/delete.workflow.json @@ -0,0 +1,65 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "49466d78-738a-4dee-a2fb-2143f7800b45", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "item", + "operation": "delete", + "container": { + "__rl": true, + "value": "container1", + "mode": "list", + "cachedResultName": "container1" + }, + "item": { + "__rl": true, + "value": "item1", + "mode": "list", + "cachedResultName": "item1" + }, + "additionalFields": {}, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [220, 0], + "id": "00d6b986-6b8a-43ee-a6a5-b6a7c165c991", + "name": "Azure Cosmos Db", + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "exampleId", + "name": "Azure Cosmos DB account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Azure Cosmos Db", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "Azure Cosmos Db": [ + { + "json": { + "deleted": true + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/get.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/get.test.ts new file mode 100644 index 0000000000..82e45566e2 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/get.test.ts @@ -0,0 +1,77 @@ +import nock from 'nock'; + +import { + initBinaryDataService, + testWorkflows, + getWorkflowFilenames, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Get Item', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('get.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/colls/container1') + .reply(200, { + id: 'containerFromSDK', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [{ path: '/*' }], + excludedPaths: [{ path: '/"_etag"/?' }], + }, + partitionKey: { + paths: ['/id'], + kind: 'Hash', + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { + type: 'Geography', + }, + _rid: '4PVyAMLVz0c=', + _ts: 1739178329, + _self: 'dbs/4PVyAA==/colls/4PVyAMLVz0c=/', + _etag: '"0000f905-0000-0300-0000-67a9c1590000"', + _docs: 'docs/', + _sprocs: 'sprocs/', + _triggers: 'triggers/', + _udfs: 'udfs/', + _conflicts: 'conflicts/', + }) + .get('/colls/container1/docs/item1') + .matchHeader('x-ms-documentdb-partitionkey', '["item1"]') + .reply(200, { + id: 'item1', + _rid: '4PVyAMPuBtoEAAAAAAAAAA==', + _self: 'dbs/4PVyAA==/colls/4PVyAMPuBto=/docs/4PVyAMPuBtoEAAAAAAAAAA==/', + _etag: '"bb000143-0000-0300-0000-67d9a2430000"', + _attachments: 'attachments/', + _ts: 1742316099, + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/get.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/get.workflow.json new file mode 100644 index 0000000000..18c812b231 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/get.workflow.json @@ -0,0 +1,67 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "49466d78-738a-4dee-a2fb-2143f7800b45", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "item", + "operation": "get", + "container": { + "__rl": true, + "value": "container1", + "mode": "list", + "cachedResultName": "container1" + }, + "item": { + "__rl": true, + "value": "item1", + "mode": "list", + "cachedResultName": "item1" + }, + "additionalFields": { + "partitionKey": "item1" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [220, 0], + "id": "00d6b986-6b8a-43ee-a6a5-b6a7c165c991", + "name": "Azure Cosmos Db", + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "exampleId", + "name": "Azure Cosmos DB account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Azure Cosmos Db", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "Azure Cosmos Db": [ + { + "json": { + "id": "item1" + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/getAll.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/getAll.test.ts new file mode 100644 index 0000000000..ef84ced552 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/getAll.test.ts @@ -0,0 +1,88 @@ +import nock from 'nock'; + +import { + initBinaryDataService, + testWorkflows, + getWorkflowFilenames, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Get All Items', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('getAll.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/colls/newOne3/docs') + .reply( + 200, + { + _rid: '4PVyAMPuBto=', + Documents: [ + { + id: 'John', + FamilyName: 'NewNameAdded', + Parents: [ + 88, + { FirstName: 'Thomas', FamilyName: 'Bob' }, + { FamilyName: null, FirstName: 'Mary Kay' }, + ], + ExtraField: 'nothing serious', + Otherdetails: 'male', + This: 'male', + }, + { + FamilyName: 'NewName', + id: 'NewId', + }, + { + id: 'this', + }, + ], + _count: 3, + }, + { + 'x-ms-continuation': '4PVyAKoVaBQ=', + }, + ) + .get('/colls/newOne3/docs') + .matchHeader('x-ms-continuation', '4PVyAKoVaBQ=') + .reply(200, { + _rid: '4PVyAMPuBto=', + Documents: [ + { + id: 'John', + FamilyName: 'NewNameAdded', + Parents: [ + 88, + { FirstName: 'Thomas', FamilyName: 'Bob' }, + { FamilyName: null, FirstName: 'Mary Kay' }, + ], + ExtraField: 'nothing serious', + Otherdetails: 'male', + This: 'male', + }, + ], + _count: 1, + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/getAll.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/getAll.workflow.json new file mode 100644 index 0000000000..6ed0ed00db --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/getAll.workflow.json @@ -0,0 +1,84 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-180, -520], + "id": "8218dacc-3b5f-460a-a773-817faf012ba9", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "item", + "container": { + "__rl": true, + "value": "newOne3", + "mode": "list", + "cachedResultName": "newOne3" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [-80, -360], + "id": "4f60d430-3c90-4838-b9d1-8c9637e745b6", + "name": "getAllItems", + "alwaysOutputData": true, + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "exampleId", + "name": "Azure Cosmos DB account " + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "getAllItems", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "getAllItems": [ + { + "json": { + "id": "John", + "FamilyName": "NewNameAdded", + "Parents": [ + 88, + { + "FirstName": "Thomas", + "FamilyName": "Bob" + }, + { + "FamilyName": null, + "FirstName": "Mary Kay" + } + ], + "ExtraField": "nothing serious", + "Otherdetails": "male", + "This": "male" + } + }, + { + "json": { + "FamilyName": "NewName", + "id": "NewId" + } + }, + { + "json": { + "id": "this" + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/query.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/query.test.ts new file mode 100644 index 0000000000..0b1db28809 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/query.test.ts @@ -0,0 +1,53 @@ +import nock from 'nock'; + +import { + initBinaryDataService, + testWorkflows, + getWorkflowFilenames, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Query Items', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('query.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .post('/colls/newId/docs', { + query: 'SELECT * FROM c WHERE c.id = @Param1', + parameters: [ + { + name: '@Param1', + value: 'User1', + }, + ], + }) + .reply(200, { + Documents: [ + { + id: 'User1', + key1: 'value', + }, + ], + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/query.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/query.workflow.json new file mode 100644 index 0000000000..91d0b63ad7 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/query.workflow.json @@ -0,0 +1,67 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-80, -100], + "id": "7da2ce49-9a9d-4240-b082-ff1b12d101b1", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "item", + "operation": "query", + "container": { + "__rl": true, + "value": "newId", + "mode": "list", + "cachedResultName": "newId" + }, + "query": "SELECT * FROM c WHERE c.id = $1", + "options": { + "queryOptions": { + "queryParameters": "User1" + } + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [160, -100], + "id": "0dc90797-8211-457c-a673-b7df28139de8", + "name": "queryItems", + "retryOnFail": false, + "alwaysOutputData": true, + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "exampleId", + "name": "Azure Cosmos DB account " + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "queryItems", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "queryItems": [ + { + "json": { + "id": "User1", + "key1": "value" + } + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/update.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/update.test.ts new file mode 100644 index 0000000000..f6cf8edc27 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/update.test.ts @@ -0,0 +1,81 @@ +import nock from 'nock'; + +import { + initBinaryDataService, + testWorkflows, + getWorkflowFilenames, +} from '../../../../../test/nodes/Helpers'; + +describe('Azure Cosmos DB - Update Item', () => { + const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('update.workflow.json'), + ); + + beforeAll(async () => { + await initBinaryDataService(); + }); + + beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } + + const baseUrl = 'https://n8n-us-east-account.documents.azure.com/dbs/database_1'; + + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/colls/container1') + .reply(200, { + id: 'containerFromSDK', + indexingPolicy: { + indexingMode: 'consistent', + automatic: true, + includedPaths: [{ path: '/*' }], + excludedPaths: [{ path: '/"_etag"/?' }], + }, + partitionKey: { + paths: ['/id'], + kind: 'Hash', + }, + conflictResolutionPolicy: { + mode: 'LastWriterWins', + conflictResolutionPath: '/_ts', + conflictResolutionProcedure: '', + }, + geospatialConfig: { + type: 'Geography', + }, + _rid: '4PVyAMLVz0c=', + _ts: 1739178329, + _self: 'dbs/4PVyAA==/colls/4PVyAMLVz0c=/', + _etag: '"0000f905-0000-0300-0000-67a9c1590000"', + _docs: 'docs/', + _sprocs: 'sprocs/', + _triggers: 'triggers/', + _udfs: 'udfs/', + _conflicts: 'conflicts/', + }) + .put('/colls/container1/docs/item1', { + id: 'item1', + key1: 'value1', + }) + .matchHeader('x-ms-documentdb-partitionkey', '["item1"]') + .reply(200, { + id: 'item1', + key1: 'value1', + _rid: '4PVyAMPuBtoEAAAAAAAAAA==', + _self: 'dbs/4PVyAA==/colls/4PVyAMPuBto=/docs/4PVyAMPuBtoEAAAAAAAAAA==/', + _etag: '"bb002b70-0000-0300-0000-67d9a3c70000"', + _attachments: 'attachments/', + _ts: 1742316487, + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/update.workflow.json b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/update.workflow.json new file mode 100644 index 0000000000..6548b897a1 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/item/update.workflow.json @@ -0,0 +1,76 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "49466d78-738a-4dee-a2fb-2143f7800b45", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "item", + "operation": "update", + "container": { + "__rl": true, + "value": "container1", + "mode": "list", + "cachedResultName": "container1" + }, + "item": { + "__rl": true, + "value": "item1", + "mode": "list", + "cachedResultName": "item1" + }, + "customProperties": "{\n \"key1\": \"value1\"\n}", + "additionalFields": {}, + "requestOptions": {} + }, + "type": "n8n-nodes-base.azureCosmosDb", + "typeVersion": 1, + "position": [220, 0], + "id": "00d6b986-6b8a-43ee-a6a5-b6a7c165c991", + "name": "Azure Cosmos Db", + "credentials": { + "microsoftAzureCosmosDbSharedKeyApi": { + "id": "exampleId", + "name": "Azure Cosmos DB account" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Azure Cosmos Db", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "Azure Cosmos Db": [ + { + "json": { + "id": "item1", + "key1": "value1", + "_rid": "4PVyAMPuBtoEAAAAAAAAAA==", + "_self": "dbs/4PVyAA==/colls/4PVyAMPuBto=/docs/4PVyAMPuBtoEAAAAAAAAAA==/", + "_etag": "\"bb002b70-0000-0300-0000-67d9a3c70000\"", + "_attachments": "attachments/", + "_ts": 1742316487 + } + } + ] + }, + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "7047d2700c0010a2580ba6b2649861ef0a567bfcd1671650a6ca242150e686b8" + } +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/listSearch/listSearch.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/listSearch/listSearch.test.ts new file mode 100644 index 0000000000..75c1f6636a --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/test/listSearch/listSearch.test.ts @@ -0,0 +1,149 @@ +import { + OperationalError, + type IGetNodeParameterOptions, + type ILoadOptionsFunctions, +} from 'n8n-workflow'; + +import { FAKE_CREDENTIALS_DATA } from '../../../../../test/nodes/FakeCredentialsMap'; +import { AzureCosmosDb } from '../../AzureCosmosDb.node'; +import { HeaderConstants } from '../../helpers/constants'; + +describe('Azure Cosmos DB', () => { + describe('List search', () => { + it('should list search containers', async () => { + const mockResponse = { + body: { + DocumentCollections: [ + { + id: 'Container2', + }, + { + id: 'Container1', + }, + ], + }, + headers: { + 'x-ms-continuation': '4PVyAKoVaBQ=', + }, + }; + + const mockRequestWithAuthentication = jest.fn().mockReturnValue(mockResponse); + + const mockGetCredentials = jest.fn(async (type: string, _itemIndex?: number) => { + if (type === 'microsoftAzureCosmosDbSharedKeyApi') { + return FAKE_CREDENTIALS_DATA.microsoftAzureCosmosDbSharedKeyApi; + } + throw new OperationalError('Unknown credentials'); + }); + + const mockContext = { + getCredentials: mockGetCredentials, + helpers: { + httpRequestWithAuthentication: mockRequestWithAuthentication, + }, + } as unknown as ILoadOptionsFunctions; + + const node = new AzureCosmosDb(); + + const paginationToken = '4PVyAKoVaBQ='; + + const listSearchResult = await node.methods.listSearch.searchContainers.call( + mockContext, + '', + paginationToken, + ); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'microsoftAzureCosmosDbSharedKeyApi', + expect.objectContaining({ + method: 'GET', + url: 'https://n8n-us-east-account.documents.azure.com/dbs/database_1/colls', + headers: { + [HeaderConstants.X_MS_CONTINUATION]: paginationToken, + }, + qs: {}, + body: {}, + json: true, + returnFullResponse: true, + }), + ); + + expect(listSearchResult).toEqual({ + results: [ + { name: 'Container1', value: 'Container1' }, + { name: 'Container2', value: 'Container2' }, + ], + paginationToken: '4PVyAKoVaBQ=', + }); + }); + + it('should list search items', async () => { + const mockResponse = { + body: { + Documents: [{ id: 'Item2' }, { id: 'Item1' }], + }, + headers: { + 'x-ms-continuation': '4PVyAKoVaBQ=', + }, + }; + + const mockRequestWithAuthentication = jest.fn().mockReturnValue(mockResponse); + + const mockGetCurrentNodeParameter = jest.fn( + (parameterName, options: IGetNodeParameterOptions) => { + if (parameterName === 'container' && options.extractValue) { + return 'Container1'; + } + throw new OperationalError('Unknown parameter'); + }, + ); + + const mockGetCredentials = jest.fn(async (type: string, _itemIndex?: number) => { + if (type === 'microsoftAzureCosmosDbSharedKeyApi') { + return FAKE_CREDENTIALS_DATA.microsoftAzureCosmosDbSharedKeyApi; + } + throw new OperationalError('Unknown credentials'); + }); + + const mockContext = { + getCredentials: mockGetCredentials, + getCurrentNodeParameter: mockGetCurrentNodeParameter, + helpers: { + httpRequestWithAuthentication: mockRequestWithAuthentication, + }, + } as unknown as ILoadOptionsFunctions; + + const node = new AzureCosmosDb(); + + const paginationToken = '4PVyAKoVaBQ='; + const listSearchResult = await node.methods.listSearch.searchItems.call( + mockContext, + '', + paginationToken, + ); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'microsoftAzureCosmosDbSharedKeyApi', + expect.objectContaining({ + method: 'GET', + url: 'https://n8n-us-east-account.documents.azure.com/dbs/database_1/colls/Container1/docs', + headers: { + [HeaderConstants.X_MS_CONTINUATION]: paginationToken, + }, + qs: {}, + body: {}, + json: true, + returnFullResponse: true, + }), + ); + + expect(listSearchResult).toEqual({ + results: [ + { name: 'Item1', value: 'Item1' }, + { name: 'Item2', value: 'Item2' }, + ], + paginationToken: '4PVyAKoVaBQ=', + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/transport/index.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/transport/index.ts new file mode 100644 index 0000000000..1965d98457 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDb/transport/index.ts @@ -0,0 +1,35 @@ +import type { + IDataObject, + IHttpRequestOptions, + IHttpRequestMethods, + ILoadOptionsFunctions, + IExecuteSingleFunctions, +} from 'n8n-workflow'; + +import type { ICosmosDbCredentials } from '../helpers/interfaces'; + +export async function azureCosmosDbApiRequest( + this: IExecuteSingleFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + endpoint: string, + body: IDataObject = {}, + qs?: IDataObject, + headers?: IDataObject, + returnFullResponse: boolean = false, +): Promise { + const credentialsType = 'microsoftAzureCosmosDbSharedKeyApi'; + const credentials = await this.getCredentials(credentialsType); + const baseUrl = `https://${credentials.account}.documents.azure.com/dbs/${credentials.database}`; + + const options: IHttpRequestOptions = { + method, + url: `${baseUrl}${endpoint}`, + json: true, + headers, + body, + qs, + returnFullResponse, + }; + + return await this.helpers.httpRequestWithAuthentication.call(this, credentialsType, options); +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index ba0461117f..8da50dba5f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -225,6 +225,7 @@ "dist/credentials/MetabaseApi.credentials.js", "dist/credentials/MessageBirdApi.credentials.js", "dist/credentials/MetabaseApi.credentials.js", + "dist/credentials/MicrosoftAzureCosmosDbSharedKeyApi.credentials.js", "dist/credentials/MicrosoftAzureMonitorOAuth2Api.credentials.js", "dist/credentials/MicrosoftDynamicsOAuth2Api.credentials.js", "dist/credentials/MicrosoftEntraOAuth2Api.credentials.js", @@ -638,6 +639,7 @@ "dist/nodes/Merge/Merge.node.js", "dist/nodes/MessageBird/MessageBird.node.js", "dist/nodes/Metabase/Metabase.node.js", + "dist/nodes/Microsoft/AzureCosmosDb/AzureCosmosDb.node.js", "dist/nodes/Microsoft/Dynamics/MicrosoftDynamicsCrm.node.js", "dist/nodes/Microsoft/Entra/MicrosoftEntra.node.js", "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js", diff --git a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts index c32cdf6527..2c88ce3a99 100644 --- a/packages/nodes-base/test/nodes/FakeCredentialsMap.ts +++ b/packages/nodes-base/test/nodes/FakeCredentialsMap.ts @@ -107,6 +107,12 @@ BQIDAQAB }, baseUrl: 'https://api.gong.io', }, + microsoftAzureCosmosDbSharedKeyApi: { + account: 'n8n-us-east-account', + key: 'I3rwpzP0XoFpNzJ7hRIUXjwgpD1qaVKi71NZBbk7oOHUXrbd80WAoIAAoRaT47W9hHO3b6us1yABACDbVdilag==', + database: 'database_1', + baseUrl: 'https://n8n-us-east-account.documents.azure.com/dbs/database_1', + }, microsoftEntraOAuth2Api: { grantType: 'authorizationCode', authUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',