From 6f757f10bd9102394d2a0b6bbc795f90444f66d2 Mon Sep 17 00:00:00 2001 From: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com> Date: Thu, 9 Jan 2025 12:58:46 +0000 Subject: [PATCH 01/19] fix(editor): Fix parameter input validation (#12532) --- packages/editor-ui/src/components/ParameterInput.vue | 2 +- packages/editor-ui/src/components/ParameterInputWrapper.vue | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index ba04cbfc2f..359a0a6549 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -365,7 +365,7 @@ const getIssues = computed(() => { if (Array.isArray(displayValue.value)) { checkValues = checkValues.concat(displayValue.value); } else { - checkValues = checkValues.concat(displayValue.value?.toString().split(',')); + checkValues.push(displayValue.value); } } diff --git a/packages/editor-ui/src/components/ParameterInputWrapper.vue b/packages/editor-ui/src/components/ParameterInputWrapper.vue index 2027294400..f0db8cc457 100644 --- a/packages/editor-ui/src/components/ParameterInputWrapper.vue +++ b/packages/editor-ui/src/components/ParameterInputWrapper.vue @@ -132,10 +132,10 @@ const evaluatedExpression = computed>(() => { } if (props.isForCredential) opts.additionalKeys = resolvedAdditionalExpressionData.value; - const stringifyExpressionResult = props.parameter.type !== 'multiOptions'; + const stringifyObject = props.parameter.type !== 'multiOptions'; return { ok: true, - result: workflowHelpers.resolveExpression(value, undefined, opts, stringifyExpressionResult), + result: workflowHelpers.resolveExpression(value, undefined, opts, stringifyObject), }; } catch (error) { return { ok: false, error }; From 9e65e853f4e189078e3a62b8b014076f06e6a393 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:47:58 +0000 Subject: [PATCH 02/19] :rocket: Release 1.74.1 (#12538) Co-authored-by: ShireenMissi <94372015+ShireenMissi@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- packages/cli/package.json | 2 +- packages/editor-ui/package.json | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7335ac5a94..3b094f30ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [1.74.1](https://github.com/n8n-io/n8n/compare/n8n@1.74.0...n8n@1.74.1) (2025-01-09) + + +### Bug Fixes + +* **editor:** Fix parameter input validation ([#12532](https://github.com/n8n-io/n8n/issues/12532)) ([6f757f1](https://github.com/n8n-io/n8n/commit/6f757f10bd9102394d2a0b6bbc795f90444f66d2)) + + + # [1.74.0](https://github.com/n8n-io/n8n/compare/n8n@1.73.0...n8n@1.74.0) (2025-01-08) diff --git a/package.json b/package.json index 9002c8dd4e..2aa7979b39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.74.0", + "version": "1.74.1", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/cli/package.json b/packages/cli/package.json index a65fe2960d..52bbdd3e6f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.74.0", + "version": "1.74.1", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 97a7660293..ed37d52ca5 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.74.0", + "version": "1.74.1", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { From d28af0f96a7ce73b525cc67cfffda36da200fcf9 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Sun, 26 Jan 2025 15:27:25 +0200 Subject: [PATCH 03/19] Added Azure Cosmos DB node --- package.json | 3 +- .../AzureCosmosDbSharedKeyApi.credentials.ts | 113 +++ .../AzureCosmosDB/AzureCosmosDb.node.ts | 84 ++ .../AzureCosmosDB/GenericFunctions.ts | 212 +++++ .../descriptions/ContainerDescription.ts | 429 ++++++++++ .../descriptions/ItemDescription.ts | 807 ++++++++++++++++++ packages/nodes-base/package.json | 4 +- pnpm-lock.yaml | 53 +- 8 files changed, 1677 insertions(+), 28 deletions(-) create mode 100644 packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts create mode 100644 packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts create mode 100644 packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts diff --git a/package.json b/package.json index 2aa7979b39..56aa53efc0 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "jest-mock": "^29.6.2", "jest-mock-extended": "^3.0.4", "lefthook": "^1.7.15", + "loader": "^2.1.1", "nock": "^13.3.2", "nodemon": "^3.0.1", "npm-run-all2": "^7.0.2", @@ -69,7 +70,7 @@ "tsc-alias": "^1.8.10", "tsc-watch": "^6.2.0", "turbo": "2.3.3", - "typescript": "*", + "typescript": "^5.6.2", "zx": "^8.1.4" }, "pnpm": { diff --git a/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts new file mode 100644 index 0000000000..f739d61b84 --- /dev/null +++ b/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts @@ -0,0 +1,113 @@ +import { + ApplicationError, + type ICredentialDataDecryptedObject, + type ICredentialType, + type IHttpRequestOptions, + type INodeProperties, +} from 'n8n-workflow'; + +import { getAuthorizationTokenUsingMasterKey } from '../nodes/Microsoft/AzureCosmosDB/GenericFunctions'; + +export class AzureCosmosDbSharedKeyApi implements ICredentialType { + name = 'azureCosmosDbSharedKeyApi'; + + displayName = 'Azure Cosmos DB API'; + + documentationUrl = 'azureCosmosDb'; + + properties: INodeProperties[] = [ + { + displayName: 'Database', + name: 'databaseAccount', + description: 'Database account', + type: 'string', + default: '', + }, + { + displayName: 'Key', + name: 'key', + description: 'Account key', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + ]; + + async authenticate( + credentials: ICredentialDataDecryptedObject, + requestOptions: IHttpRequestOptions, + ): Promise { + if (requestOptions.qs) { + for (const [key, value] of Object.entries(requestOptions.qs)) { + if (value === undefined) { + delete requestOptions.qs[key]; + } + } + } + + requestOptions.headers ??= {}; + const date = new Date().toUTCString(); + requestOptions.headers = { + ...requestOptions.headers, + 'x-ms-date': date, + 'x-ms-version': '2020-04-08', + }; + + if (credentials.sessionToken) { + requestOptions.headers['x-ms-session-token'] = credentials.sessionToken; + } + + let resourceType = ''; + let resourceLink = ''; + if (requestOptions.body && typeof requestOptions.body === 'object') { + const isCollectionRequest = 'colls' in requestOptions.body; + const isDocumentRequest = 'docs' in requestOptions.body; + + if (isCollectionRequest) { + resourceType = 'dbs'; + resourceLink = `dbs/${credentials.database}/colls`; + } else if (isDocumentRequest) { + resourceType = 'colls'; + const collId = requestOptions.qs?.collId || ''; + if (!collId) { + throw new ApplicationError('Collection ID (collId) is required for document requests.'); + } + resourceLink = `dbs/${credentials.database}/colls/${collId}/docs`; + } + } else if (requestOptions.qs && typeof requestOptions.qs === 'object') { + const queryType = requestOptions.qs.queryType; + + if (queryType === 'colls') { + resourceType = 'dbs'; + resourceLink = `dbs/${credentials.database}/colls`; + } else if (queryType === 'docs') { + resourceType = 'colls'; + const collId = requestOptions.qs.collId || ''; + if (!collId) { + throw new ApplicationError('Collection ID (collId) is required for document queries.'); + } + resourceLink = `dbs/${credentials.database}/colls/${collId}/docs`; + } + } else { + throw new ApplicationError( + 'Invalid requestOptions: Either body or query string (qs) is required.', + ); + } + + if (requestOptions.method) { + const authToken = getAuthorizationTokenUsingMasterKey( + requestOptions.method, + resourceType, + resourceLink, + date, + credentials.key as string, + ); + + requestOptions.headers.authorization = authToken; + } + + return requestOptions; + } +} 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..752529bbc7 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts @@ -0,0 +1,84 @@ +import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; + +import { containerFields, containerOperations } from './descriptions/ContainerDescription'; +import { itemFields, itemOperations } from '../../Aws/DynamoDB/ItemDescription'; + +export class AzureCosmosDb implements INodeType { + description: INodeTypeDescription = { + displayName: 'Azure Cosmos DB', + name: 'azureCosmosDb', + icon: { + light: 'file:CosmosDB.svg', + dark: 'file:CosmosDB.svg', + }, + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Interact with Azure Cosmos DB API', + defaults: { + name: 'Azure Cosmos Db', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + credentials: [ + { + name: 'azureCosmosDbSharedKeyApi', + required: true, + displayOptions: { + show: { + authentication: ['sharedKey'], + }, + }, + }, + ], + requestDefaults: { + baseURL: '=https://{$credentials.databaseAccount}.documents.azure.com', + headers: { + Accept: 'application/json', + }, + }, + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Shared Key', + value: 'sharedKey', + }, + ], + default: 'sharedKey', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Container', + value: 'container', + }, + { + name: 'Item', + value: 'item', + }, + ], + default: 'container', + }, + ...itemFields, + ...itemOperations, + ...containerOperations, + ...containerFields, + ], + }; + + methods = { + listSearch: { + // searchCollections, + // searchDatabases, + }, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts new file mode 100644 index 0000000000..0eae326328 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts @@ -0,0 +1,212 @@ +import * as crypto from 'crypto'; +import type { + IDataObject, + IHttpRequestOptions, + ILoadOptionsFunctions, + INodeListSearchItems, + INodeListSearchResult, +} from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; +import * as querystring from 'querystring'; + +export const HeaderConstants = { + // Required + AUTHORIZATION: 'Authorization', + CONTENT_TYPE: 'Content-Type', + X_MS_DATE: 'x-ms-date', + X_MS_VERSION: 'x-ms-version', + + //Required - for session consistency only + X_MS_SESSION_TOKEN: 'x-ms-session-token', + + // Optional + IF_MATCH: 'If-Match', + IF_NONE_MATCH: 'If-None-Match', + IF_MODIFIED_SINCE: 'If-Modified-Since', + USER_AGENT: 'User-Agent', + X_MS_ACTIVITY_ID: 'x-ms-activity-id', + X_MS_CONSISTENCY_LEVEL: 'x-ms-consistency-level', + X_MS_CONTINUATION: 'x-ms-continuation', + X_MS_MAX_ITEM_COUNT: 'x-ms-max-item-count', + X_MS_DOCUMENTDB_PARTITIONKEY: 'x-ms-documentdb-partitionkey', + X_MS_DOCUMENTDB_QUERY_ENABLECROSSPARTITION: 'x-ms-documentdb-query-enablecrosspartition', + A_IM: 'A-IM', + X_MS_DOCUMENTDB_PARTITIONKEYRANGEID: 'x-ms-documentdb-partitionkeyrangeid', + X_MS_COSMOS_ALLOW_TENTATIVE_WRITES: 'x-ms-cosmos-allow-tentative-writes', + + PREFIX_FOR_STORAGE: 'x-ms-', +}; + +export function getAuthorizationTokenUsingMasterKey( + verb: string, + resourceType: string, + resourceLink: string, + date: string, + masterKey: string, +): string { + const key = Buffer.from(masterKey, 'base64'); + + const payload = + `${verb.toLowerCase()}\n` + + `${resourceType.toLowerCase()}\n` + + `${resourceLink}\n` + + `${date.toLowerCase()}\n` + + '\n'; + + const hmacSha256 = crypto.createHmac('sha256', key); + const hashPayload = hmacSha256.update(payload, 'utf8').digest('base64'); + + const authorizationString = querystring.escape(`type=master&ver=1.0&sig=${hashPayload}`); + + return authorizationString; +} + +export async function azureCosmosDbRequest( + this: ILoadOptionsFunctions, + opts: IHttpRequestOptions, +): Promise { + const credentials = await this.getCredentials('azureCosmosDb'); + const databaseAccount = credentials?.database; + + if (!databaseAccount) { + throw new ApplicationError('Database account not found in credentials!', { level: 'error' }); + } + + const requestOptions: IHttpRequestOptions = { + ...opts, + baseURL: `https://${databaseAccount}.documents.azure.com`, + json: true, + }; + + const errorMapping: Record> = { + 403: { + 'The security token included in the request is invalid.': + 'The Cosmos DB credentials are not valid!', + 'The request signature we calculated does not match the signature you provided': + 'The Cosmos DB credentials are not valid!', + }, + 404: { + 'The specified resource does not exist.': 'The requested resource was not found!', + }, + }; + + try { + return (await this.helpers.requestWithAuthentication.call( + this, + 'azureCosmosDb', + requestOptions, + )) as IDataObject; + } catch (error) { + const statusCode = (error.statusCode || error.cause?.statusCode) as number; + let errorMessage = (error.response?.body?.message || + error.response?.body?.Message || + error.message) as string; + + if (statusCode in errorMapping && errorMessage in errorMapping[statusCode]) { + throw new ApplicationError(errorMapping[statusCode][errorMessage], { + level: 'error', + }); + } + + if (error.cause?.error) { + try { + errorMessage = error.cause?.error?.message as string; + } catch (ex) { + throw new ApplicationError( + `Failed to extract error details: ${ex.message || 'Unknown error'}`, + { level: 'error' }, + ); + } + } + + throw new ApplicationError(`Cosmos DB error response [${statusCode}]: ${errorMessage}`, { + level: 'error', + }); + } +} + +export async function searchCollections( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const dbId = this.getNodeParameter('dbId') as string; + if (!dbId) { + throw new ApplicationError('Database ID is required'); + } + + const credentials = await this.getCredentials('azureCosmosDb'); + const databaseAccount = credentials?.databaseAccount; + + if (!databaseAccount) { + throw new ApplicationError('Database account not found in credentials!', { level: 'error' }); + } + + const opts: IHttpRequestOptions = { + method: 'GET', + url: `/dbs/${dbId}/colls`, + baseURL: `https://${databaseAccount}.documents.azure.com`, + headers: { + 'Content-Type': 'application/json', + }, + }; + + const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); + + const responseBody = responseData as { + Collections: IDataObject[]; + }; + const collections = responseBody.Collections; + + if (!collections) { + return { results: [] }; + } + + const results: INodeListSearchItems[] = collections + .map((collection) => ({ + name: String(collection.id), + value: String(collection.id), + })) + .filter((collection) => !filter || collection.name.includes(filter)) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + results, + }; +} + +export async function searchDatabases( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const opts: IHttpRequestOptions = { + method: 'GET', + url: '/dbs', + headers: { + 'Content-Type': 'application/json', + }, + }; + + const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); + + const responseBody = responseData as { + Databases: IDataObject[]; + }; + const databases = responseBody.Databases; + + if (!databases) { + return { results: [] }; + } + + const results: INodeListSearchItems[] = databases + .map((database) => ({ + name: String(database.id), + value: String(database.id), + })) + .filter((database) => !filter || database.name.includes(filter)) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + results, + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts new file mode 100644 index 0000000000..bf651f7e89 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts @@ -0,0 +1,429 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const containerOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['container'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'POST', + url: '=/dbs/{{ $parameter["dbId"] }}/colls', + }, + }, + action: 'Create container', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'DELETE', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}', + }, + }, + action: 'Delete container', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a container', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}', + }, + }, + action: 'Get item', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of containers', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + url: '=/dbs/{{ $parameter["dbId"] }}/colls', + }, + }, + action: 'Get many containers', + }, + ], + default: 'getAll', + }, +]; + +export const createFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['container'], + operation: ['create'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + placeholder: 'e.g. AndersenFamily', + description: "Container's ID", + required: true, + displayOptions: { + show: { + resource: ['container'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'id', + value: '={{$value}}', + }, + }, + }, + { + displayName: 'Partition Key', + name: 'partitionKey', + type: 'json', + default: '{}', + placeholder: '"paths": ["/AccountNumber"],"kind": "Hash", "Version": 2', + description: 'User-defined JSON object representing the partition key', + required: true, + displayOptions: { + show: { + resource: ['container'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'partitionKey', + value: '={{$value}}', + }, + }, + }, + { + displayName: 'Additional Keys', + name: 'additionalKeys', + type: 'fixedCollection', + default: {}, + placeholder: '"paths": ["/AccountNumber"],"kind": "Hash", "Version": 2', + description: 'User-defined JSON object representing the document properties', + displayOptions: { + show: { + resource: ['container'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Indexing Policy', + name: 'indexingPolicy', + type: 'json', + default: '{}', + placeholder: + '"automatic": true, "indexingMode": "Consistent", "includedPaths": [{ "path": "/*", "indexes": [{ "dataType": "String", "precision": -1, "kind": "Range" }]}]', + description: 'This value is used to configure indexing policy', + routing: { + send: { + type: 'body', + property: 'indexingPolicy', + value: '={{$value}}', + }, + }, + }, + ], + }, +]; + +export const getFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['container'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, + { + displayName: 'Container ID', + name: 'collId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the container you want to retrieve', + displayOptions: { + show: { + resource: ['container'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchCollections', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'containerId', + type: 'string', + hint: 'Enter the container ID', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The container ID must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. AndersenFamily', + }, + ], + }, +]; + +export const getAllFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['container'], + operation: ['getAll'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, +]; + +export const deleteFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['container'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, + { + displayName: 'Container ID', + name: 'collId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the container you want to delete', + displayOptions: { + show: { + resource: ['container'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchCollections', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'containerId', + type: 'string', + hint: 'Enter the container ID', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The container ID must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. AndersenFamily', + }, + ], + }, +]; + +export const containerFields: INodeProperties[] = [ + ...createFields, + ...deleteFields, + ...getFields, + ...getAllFields, +]; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts new file mode 100644 index 0000000000..b794e145f5 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts @@ -0,0 +1,807 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const itemOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['item'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create an item', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'POST', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item', + }, + }, + action: 'Create item', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an item', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'DELETE', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item/{{ $parameter["id"] }}', + }, + }, + action: 'Delete item', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve an item', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item/{{ $parameter["id"] }}', + }, + }, + action: 'Get item', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve a list of items', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'GET', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs', + }, + }, + action: 'Get many items', + }, + { + name: 'Query', + value: 'query', + description: 'Query items', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'POST', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item', + }, + }, + action: 'Query items', + }, + { + name: 'Update', + value: 'update', + description: 'Update an item', + routing: { + request: { + ignoreHttpStatusErrors: true, + method: 'PATCH', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item/{{ $parameter["id"] }}', + }, + }, + action: 'Create item', + }, + ], + default: 'getAll', + }, +]; + +export const createFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['create'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, + { + displayName: 'Collection ID', + name: 'collId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the collection you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['create'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchCollections', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'collectionName', + type: 'string', + hint: 'Enter the collection name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The collection name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersCollection', + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + placeholder: 'e.g. AndersenFamily', + description: "Item's ID", + required: true, + displayOptions: { + show: { + resource: ['item'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'id', + value: '={{$value}}', + }, + }, + }, + { + displayName: 'Custom Properties', + name: 'customProperties', + type: 'json', + default: '{}', + placeholder: '{ "LastName": "Andersen", "Address": { "State": "WA", "City": "Seattle" } }', + description: 'User-defined JSON object representing the document properties', + required: true, + displayOptions: { + show: { + resource: ['item'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: '', + value: '={{$value}}', + }, + }, + }, +]; + +export const deleteFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, + { + displayName: 'Collection ID', + name: 'collId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the collection you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['delete'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchCollections', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'collectionName', + type: 'string', + hint: 'Enter the collection name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The collection name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersCollection', + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + placeholder: 'e.g. AndersenFamily', + description: 'Unique ID for the item', + required: true, + displayOptions: { + show: { + resource: ['item'], + operation: ['delete'], + }, + }, + }, +]; + +export const getFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, + { + displayName: 'Collection ID', + name: 'collId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the collection you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['get'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchCollections', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'collectionName', + type: 'string', + hint: 'Enter the collection name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The collection name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersCollection', + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + placeholder: 'e.g. AndersenFamily', + description: "Item's ID", + required: true, + displayOptions: { + show: { + resource: ['item'], + operation: ['get'], + }, + }, + routing: { + send: { + type: 'body', + property: 'id', + value: '={{$value}}', + }, + }, + }, +]; + +export const getAllFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['getAll'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, + { + displayName: 'Collection ID', + name: 'collId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the collection you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['getAll'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchCollections', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'collectionName', + type: 'string', + hint: 'Enter the collection name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The collection name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersCollection', + }, + ], + }, +]; + +//TO-DO-check-fields +export const queryFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['query'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, + { + displayName: 'Collection ID', + name: 'collId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the collection you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['query'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchCollections', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'collectionName', + type: 'string', + hint: 'Enter the collection name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The collection name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersCollection', + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + placeholder: 'e.g. AndersenFamily', + description: "Item's ID", + required: true, + displayOptions: { + show: { + resource: ['item'], + operation: ['query'], + }, + }, + routing: { + send: { + type: 'body', + property: 'id', + value: '={{$value}}', + }, + }, + }, +]; + +export const updateFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'dbId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the database you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['update'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchDatabases', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'databaseName', + type: 'string', + hint: 'Enter the database name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The database name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersDB', + }, + ], + }, + { + displayName: 'Collection ID', + name: 'collId', + type: 'resourceLocator', + required: true, + default: { + mode: 'list', + value: '', + }, + description: 'Select the collection you want to use', + displayOptions: { + show: { + resource: ['item'], + operation: ['update'], + }, + }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchCollections', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'collectionName', + type: 'string', + hint: 'Enter the collection name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The collection name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. UsersCollection', + }, + ], + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + placeholder: 'e.g. AndersenFamily', + description: 'Unique ID for the document', + required: true, + displayOptions: { + show: { + resource: ['item'], + operation: ['update'], + }, + }, + }, + //TO-DO-check-this + { + displayName: 'Operations', + name: 'operations', + type: 'resourceMapper', + default: { + mappingMode: 'defineBelow', + value: null, + }, + required: true, + typeOptions: { + resourceMapper: { + resourceMapperMethod: 'getMappingColumns', + mode: 'update', + fieldWords: { + singular: 'operation', + plural: 'operations', + }, + addAllFields: true, + multiKeyMatch: false, + supportAutoMap: true, + matchingFieldsLabels: { + title: 'Custom Matching Operations', + description: 'Define the operations to perform, such as "set", "delete", or "add".', + hint: 'Map input data to the expected structure of the operations array.', + }, + }, + }, + description: 'Define the operations to perform, such as setting or updating document fields', + displayOptions: { + show: { + resource: ['item'], + operation: ['update'], + }, + }, + //TO-DO-presend-function + }, +]; + +export const itemFields: INodeProperties[] = [ + ...createFields, + ...deleteFields, + ...getFields, + ...getAllFields, + ...queryFields, + ...updateFields, +]; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 16d9e05d0d..6978635c64 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.74.0", + "version": "1.75.0", "description": "Base nodes of n8n", "main": "index.js", "scripts": { @@ -39,6 +39,7 @@ "dist/credentials/AutomizyApi.credentials.js", "dist/credentials/AutopilotApi.credentials.js", "dist/credentials/Aws.credentials.js", + "dist/credentials/AzureCosmosDbSharedKeyApi.credentials.js", "dist/credentials/BambooHrApi.credentials.js", "dist/credentials/BannerbearApi.credentials.js", "dist/credentials/BaserowApi.credentials.js", @@ -625,6 +626,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/pnpm-lock.yaml b/pnpm-lock.yaml index 679b1155d2..8931279c42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: lefthook: specifier: ^1.7.15 version: 1.7.15 + loader: + specifier: ^2.1.1 + version: 2.1.1 nock: specifier: ^13.3.2 version: 13.3.2 @@ -440,7 +443,7 @@ importers: version: 3.666.0(@aws-sdk/client-sts@3.666.0) '@getzep/zep-cloud': specifier: 1.0.12 - version: 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu)) + version: 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i)) '@getzep/zep-js': specifier: 0.9.0 version: 0.9.0 @@ -467,7 +470,7 @@ importers: version: 0.3.1(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) '@langchain/community': specifier: 0.3.15 - version: 0.3.15(vc5hvyy27o4cmm4jplsptc2fqm) + version: 0.3.15(v4qhcw25bevfr6xzz4fnsvjiqe) '@langchain/core': specifier: 'catalog:' version: 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) @@ -554,7 +557,7 @@ importers: version: 23.0.1 langchain: specifier: 0.3.6 - version: 0.3.6(e4rnrwhosnp2xiru36mqgdy2bu) + version: 0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i) lodash: specifier: 'catalog:' version: 4.17.21 @@ -9734,6 +9737,9 @@ packages: enquirer: optional: true + loader@2.1.1: + resolution: {integrity: sha512-Z6nHbyKiECMexVUmpIAVXp+f+8okRtfiMAQ9j0eTtImCghxSqQwrbeSiAyCYKBnfvRlgXLdQt0tPuaZmcBQzpw==} + local-pkg@0.5.0: resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} engines: {node: '>=14'} @@ -9788,6 +9794,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} @@ -15764,7 +15771,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu))': + '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i))': dependencies: form-data: 4.0.0 node-fetch: 2.7.0(encoding@0.1.13) @@ -15773,7 +15780,7 @@ snapshots: zod: 3.23.8 optionalDependencies: '@langchain/core': 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) - langchain: 0.3.6(e4rnrwhosnp2xiru36mqgdy2bu) + langchain: 0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i) transitivePeerDependencies: - encoding @@ -16237,7 +16244,7 @@ snapshots: - aws-crt - encoding - '@langchain/community@0.3.15(vc5hvyy27o4cmm4jplsptc2fqm)': + '@langchain/community@0.3.15(v4qhcw25bevfr6xzz4fnsvjiqe)': dependencies: '@ibm-cloud/watsonx-ai': 1.1.2 '@langchain/core': 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) @@ -16247,7 +16254,7 @@ snapshots: flat: 5.0.2 ibm-cloud-sdk-core: 5.1.0 js-yaml: 4.1.0 - langchain: 0.3.6(e4rnrwhosnp2xiru36mqgdy2bu) + langchain: 0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i) langsmith: 0.2.3(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) uuid: 10.0.0 zod: 3.23.8 @@ -16260,7 +16267,7 @@ snapshots: '@aws-sdk/client-s3': 3.666.0 '@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0) '@azure/storage-blob': 12.18.0(encoding@0.1.13) - '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu)) + '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i)) '@getzep/zep-js': 0.9.0 '@google-ai/generativelanguage': 2.6.0(encoding@0.1.13) '@google-cloud/storage': 7.12.1(encoding@0.1.13) @@ -19553,14 +19560,6 @@ snapshots: transitivePeerDependencies: - debug - axios@1.7.4(debug@4.3.7): - dependencies: - follow-redirects: 1.15.6(debug@4.3.7) - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.7.7: dependencies: follow-redirects: 1.15.6(debug@4.3.6) @@ -21284,7 +21283,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) is-core-module: 2.13.1 resolve: 1.22.8 transitivePeerDependencies: @@ -21309,7 +21308,7 @@ snapshots: eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.7.2) eslint: 8.57.0 @@ -21329,7 +21328,7 @@ snapshots: array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 @@ -22108,7 +22107,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 4.0.2 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -22414,7 +22413,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 18.16.16 '@types/tough-cookie': 4.0.2 - axios: 1.7.4(debug@4.3.7) + axios: 1.7.4 camelcase: 6.3.0 debug: 4.3.7 dotenv: 16.4.5 @@ -22424,7 +22423,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.7.4) + retry-axios: 2.6.0(axios@1.7.4(debug@4.3.7)) tough-cookie: 4.1.3 transitivePeerDependencies: - supports-color @@ -23429,7 +23428,7 @@ snapshots: kuler@2.0.0: {} - langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu): + langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i): dependencies: '@langchain/core': 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) '@langchain/openai': 0.3.14(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) @@ -23594,6 +23593,8 @@ snapshots: optionalDependencies: enquirer: 2.3.6 + loader@2.1.1: {} + local-pkg@0.5.0: dependencies: mlly: 1.4.2 @@ -24999,7 +25000,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -25821,7 +25822,7 @@ snapshots: ret@0.1.15: {} - retry-axios@2.6.0(axios@1.7.4): + retry-axios@2.6.0(axios@1.7.4(debug@4.3.7)): dependencies: axios: 1.7.4 @@ -25848,7 +25849,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color From 6307ac162f1ea015e2f654cf909c48b13709fbb4 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Sun, 26 Jan 2025 15:28:04 +0200 Subject: [PATCH 04/19] Worked on pagination --- .../AzureCosmosDbSharedKeyApi.credentials.ts | 6 +- .../AzureCosmosDB/AzureCosmosDb.node.ts | 11 +- .../Microsoft/AzureCosmosDB/CosmosDB.svg | 1 + .../AzureCosmosDB/GenericFunctions.ts | 124 +++++++++++----- .../descriptions/ContainerDescription.ts | 11 +- .../descriptions/ItemDescription.ts | 133 +++++++++++++++--- 6 files changed, 223 insertions(+), 63 deletions(-) create mode 100644 packages/nodes-base/nodes/Microsoft/AzureCosmosDB/CosmosDB.svg diff --git a/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts index f739d61b84..499779c20c 100644 --- a/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts @@ -17,9 +17,9 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType { properties: INodeProperties[] = [ { - displayName: 'Database', - name: 'databaseAccount', - description: 'Database account', + displayName: 'Account', + name: 'account', + description: 'Account name', type: 'string', default: '', }, diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts index 752529bbc7..0373f08d30 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts @@ -2,7 +2,8 @@ import type { INodeType, INodeTypeDescription } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow'; import { containerFields, containerOperations } from './descriptions/ContainerDescription'; -import { itemFields, itemOperations } from '../../Aws/DynamoDB/ItemDescription'; +import { itemFields, itemOperations } from './descriptions/ItemDescription'; +import { searchCollections, searchDatabases } from './GenericFunctions'; export class AzureCosmosDb implements INodeType { description: INodeTypeDescription = { @@ -33,7 +34,7 @@ export class AzureCosmosDb implements INodeType { }, ], requestDefaults: { - baseURL: '=https://{$credentials.databaseAccount}.documents.azure.com', + baseURL: '=https://{$credentials.account}.documents.azure.com', headers: { Accept: 'application/json', }, @@ -68,8 +69,8 @@ export class AzureCosmosDb implements INodeType { ], default: 'container', }, - ...itemFields, ...itemOperations, + ...itemFields, ...containerOperations, ...containerFields, ], @@ -77,8 +78,8 @@ export class AzureCosmosDb implements INodeType { methods = { listSearch: { - // searchCollections, - // searchDatabases, + searchCollections, + searchDatabases, }, }; } diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/CosmosDB.svg b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/CosmosDB.svg new file mode 100644 index 0000000000..c4f1f8cabe --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/CosmosDB.svg @@ -0,0 +1 @@ + diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts index 0eae326328..51e3c1b187 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts @@ -1,41 +1,45 @@ import * as crypto from 'crypto'; import type { + DeclarativeRestApiSettings, IDataObject, + IExecutePaginationFunctions, IHttpRequestOptions, ILoadOptionsFunctions, + INodeExecutionData, INodeListSearchItems, INodeListSearchResult, } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow'; import * as querystring from 'querystring'; -export const HeaderConstants = { - // Required - AUTHORIZATION: 'Authorization', - CONTENT_TYPE: 'Content-Type', - X_MS_DATE: 'x-ms-date', - X_MS_VERSION: 'x-ms-version', +// export const HeaderConstants = { +// // Required +// AUTHORIZATION: 'Authorization', +// CONTENT_TYPE: 'Content-Type', +// X_MS_DATE: 'x-ms-date', +// X_MS_VERSION: 'x-ms-version', - //Required - for session consistency only - X_MS_SESSION_TOKEN: 'x-ms-session-token', +// //Required - for session consistency only +// X_MS_SESSION_TOKEN: 'x-ms-session-token', - // Optional - IF_MATCH: 'If-Match', - IF_NONE_MATCH: 'If-None-Match', - IF_MODIFIED_SINCE: 'If-Modified-Since', - USER_AGENT: 'User-Agent', - X_MS_ACTIVITY_ID: 'x-ms-activity-id', - X_MS_CONSISTENCY_LEVEL: 'x-ms-consistency-level', - X_MS_CONTINUATION: 'x-ms-continuation', - X_MS_MAX_ITEM_COUNT: 'x-ms-max-item-count', - X_MS_DOCUMENTDB_PARTITIONKEY: 'x-ms-documentdb-partitionkey', - X_MS_DOCUMENTDB_QUERY_ENABLECROSSPARTITION: 'x-ms-documentdb-query-enablecrosspartition', - A_IM: 'A-IM', - X_MS_DOCUMENTDB_PARTITIONKEYRANGEID: 'x-ms-documentdb-partitionkeyrangeid', - X_MS_COSMOS_ALLOW_TENTATIVE_WRITES: 'x-ms-cosmos-allow-tentative-writes', +// // Optional +// IF_MATCH: 'If-Match', +// IF_NONE_MATCH: 'If-None-Match', +// IF_MODIFIED_SINCE: 'If-Modified-Since', +// USER_AGENT: 'User-Agent', +// X_MS_ACTIVITY_ID: 'x-ms-activity-id', +// X_MS_CONSISTENCY_LEVEL: 'x-ms-consistency-level', +// X_MS_CONTINUATION: 'x-ms-continuation', +// X_MS_MAX_ITEM_COUNT: 'x-ms-max-item-count', +// X_MS_DOCUMENTDB_PARTITIONKEY: 'x-ms-documentdb-partitionkey', +// X_MS_DOCUMENTDB_ISQUERY: 'x-ms-documentdb-isquery', +// X_MS_DOCUMENTDB_QUERY_ENABLECROSSPARTITION: 'x-ms-documentdb-query-enablecrosspartition', +// A_IM: 'A-IM', +// X_MS_DOCUMENTDB_PARTITIONKEYRANGEID: 'x-ms-documentdb-partitionkeyrangeid', +// X_MS_COSMOS_ALLOW_TENTATIVE_WRITES: 'x-ms-cosmos-allow-tentative-writes', - PREFIX_FOR_STORAGE: 'x-ms-', -}; +// PREFIX_FOR_STORAGE: 'x-ms-', +// }; export function getAuthorizationTokenUsingMasterKey( verb: string, @@ -61,12 +65,67 @@ export function getAuthorizationTokenUsingMasterKey( return authorizationString; } +export async function handlePagination( + this: IExecutePaginationFunctions, + resultOptions: DeclarativeRestApiSettings.ResultOptions, +): Promise { + const aggregatedResult: IDataObject[] = []; + let nextPageToken: string | undefined; + const returnAll = this.getNodeParameter('returnAll') as boolean; + let limit = 60; + + if (!returnAll) { + limit = this.getNodeParameter('limit') as number; + resultOptions.maxResults = limit; + } + + resultOptions.paginate = true; + + do { + if (nextPageToken) { + resultOptions.options.headers = resultOptions.options.headers ?? {}; + resultOptions.options.headers['x-ms-continuation'] = nextPageToken; + } + + const responseData = await this.makeRoutingRequest(resultOptions); + + if (Array.isArray(responseData)) { + for (const responsePage of responseData) { + aggregatedResult.push(responsePage); + + if (!returnAll && aggregatedResult.length >= limit) { + return aggregatedResult.slice(0, limit).map((result) => ({ json: result })); + } + } + } + + //TO-DO-check-if-works + if (responseData.length > 0) { + const lastItem = responseData[responseData.length - 1]; + + if ('headers' in lastItem) { + const headers = (lastItem as unknown as { headers: { [key: string]: string } }).headers; + + if (headers) { + nextPageToken = headers['x-ms-continuation'] as string | undefined; + } + } + } + + if (!nextPageToken) { + break; + } + } while (nextPageToken); + + return aggregatedResult.map((result) => ({ json: result })); +} + export async function azureCosmosDbRequest( this: ILoadOptionsFunctions, opts: IHttpRequestOptions, ): Promise { - const credentials = await this.getCredentials('azureCosmosDb'); - const databaseAccount = credentials?.database; + const credentials = await this.getCredentials('azureCosmosDbSharedKeyApi'); + const databaseAccount = credentials?.account; if (!databaseAccount) { throw new ApplicationError('Database account not found in credentials!', { level: 'error' }); @@ -93,7 +152,7 @@ export async function azureCosmosDbRequest( try { return (await this.helpers.requestWithAuthentication.call( this, - 'azureCosmosDb', + 'azureCosmosDbSharedKeyApi', requestOptions, )) as IDataObject; } catch (error) { @@ -128,18 +187,17 @@ export async function azureCosmosDbRequest( export async function searchCollections( this: ILoadOptionsFunctions, filter?: string, - paginationToken?: string, ): Promise { const dbId = this.getNodeParameter('dbId') as string; if (!dbId) { throw new ApplicationError('Database ID is required'); } - const credentials = await this.getCredentials('azureCosmosDb'); - const databaseAccount = credentials?.databaseAccount; + const credentials = await this.getCredentials('azureCosmosDbSharedKeyApi'); + const databaseAccount = credentials?.account; if (!databaseAccount) { - throw new ApplicationError('Database account not found in credentials!', { level: 'error' }); + throw new ApplicationError('Account name not found in credentials!', { level: 'error' }); } const opts: IHttpRequestOptions = { @@ -154,9 +212,9 @@ export async function searchCollections( const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); const responseBody = responseData as { - Collections: IDataObject[]; + DocumentCollections: IDataObject[]; }; - const collections = responseBody.Collections; + const collections = responseBody.DocumentCollections; if (!collections) { return { results: [] }; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts index bf651f7e89..317acc1275 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts @@ -49,7 +49,7 @@ export const containerOperations: INodeProperties[] = [ url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}', }, }, - action: 'Get item', + action: 'Get container', }, { name: 'Get Many', @@ -159,12 +159,9 @@ export const createFields: INodeProperties[] = [ }, }, { - displayName: 'Additional Keys', - name: 'additionalKeys', - type: 'fixedCollection', + displayName: 'Additional Fields', + name: 'additionalFields', default: {}, - placeholder: '"paths": ["/AccountNumber"],"kind": "Hash", "Version": 2', - description: 'User-defined JSON object representing the document properties', displayOptions: { show: { resource: ['container'], @@ -189,6 +186,8 @@ export const createFields: INodeProperties[] = [ }, }, ], + placeholder: 'Add Option', + type: 'collection', }, ]; diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts index b794e145f5..4b08354a38 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts @@ -1,4 +1,6 @@ -import type { INodeProperties } from 'n8n-workflow'; +import type { IExecuteSingleFunctions, IHttpRequestOptions, INodeProperties } from 'n8n-workflow'; + +import { handlePagination } from '../GenericFunctions'; export const itemOperations: INodeProperties[] = [ { @@ -15,12 +17,12 @@ export const itemOperations: INodeProperties[] = [ { name: 'Create', value: 'create', - description: 'Create an item', + description: 'Create a new item', routing: { request: { ignoreHttpStatusErrors: true, method: 'POST', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs', }, }, action: 'Create item', @@ -28,12 +30,12 @@ export const itemOperations: INodeProperties[] = [ { name: 'Delete', value: 'delete', - description: 'Delete an item', + description: 'Delete an existing item', routing: { request: { ignoreHttpStatusErrors: true, method: 'DELETE', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item/{{ $parameter["id"] }}', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', }, }, action: 'Delete item', @@ -46,7 +48,7 @@ export const itemOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'GET', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item/{{ $parameter["id"] }}', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', }, }, action: 'Get item', @@ -56,6 +58,12 @@ export const itemOperations: INodeProperties[] = [ value: 'getAll', description: 'Retrieve a list of items', routing: { + send: { + paginate: true, + }, + operations: { + pagination: handlePagination, + }, request: { ignoreHttpStatusErrors: true, method: 'GET', @@ -72,7 +80,11 @@ export const itemOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'POST', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs', + headers: { + 'Content-Type': 'application/query+json', + 'x-ms-documentdb-isquery': 'True', + }, }, }, action: 'Query items', @@ -80,15 +92,15 @@ export const itemOperations: INodeProperties[] = [ { name: 'Update', value: 'update', - description: 'Update an item', + description: 'Update an existing item', routing: { request: { ignoreHttpStatusErrors: true, method: 'PATCH', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/item/{{ $parameter["id"] }}', + url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', }, }, - action: 'Create item', + action: 'Update item', }, ], default: 'getAll', @@ -538,9 +550,58 @@ export const getAllFields: INodeProperties[] = [ }, ], }, + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: ['item'], + operation: ['getAll'], + }, + }, + routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + return requestOptions; + }, + ], + }, + }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { + resource: ['item'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + send: { + property: 'x-ms-max-item-count', + type: 'query', + value: '={{ $value }}', + }, + }, + type: 'number', + typeOptions: { + minValue: 1, + }, + validateType: 'number', + }, ]; -//TO-DO-check-fields export const queryFields: INodeProperties[] = [ { displayName: 'Database ID', @@ -631,23 +692,63 @@ export const queryFields: INodeProperties[] = [ ], }, { - displayName: 'ID', - name: 'id', + displayName: 'Query', + name: 'query', type: 'string', default: '', - placeholder: 'e.g. AndersenFamily', - description: "Item's ID", required: true, + description: 'The SQL query text to execute', displayOptions: { show: { resource: ['item'], operation: ['query'], }, }, + placeholder: 'SELECT * FROM c WHERE c.name = @name', routing: { send: { type: 'body', - property: 'id', + property: 'query', + value: '={{$value}}', + }, + }, + }, + { + displayName: 'Parameters', + name: 'parameters', + type: 'fixedCollection', + required: true, + default: [], + placeholder: 'Add Parameter', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'parameters', + displayName: 'Parameter', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g., @name', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'e.g., John', + }, + ], + }, + ], + routing: { + send: { + type: 'body', + property: 'parameters', value: '={{$value}}', }, }, From 541f2894666ea2e6511598bd726054d86daac5c1 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Wed, 29 Jan 2025 10:00:34 +0200 Subject: [PATCH 05/19] Worked on credentials, tests and fields for both resources --- .../AzureCosmosDbSharedKeyApi.credentials.ts | 61 ++- .../AzureCosmosDB/AzureCosmosDb.node.ts | 6 +- .../AzureCosmosDB/GenericFunctions.ts | 86 ++--- .../descriptions/ContainerDescription.ts | 226 +++-------- .../descriptions/ItemDescription.ts | 358 +++--------------- .../test/AzureCosmosDbRequest.test.ts | 185 +++++++++ .../test/HandlePagination.test.ts | 116 ++++++ .../test/SearchCollections.test.ts | 122 ++++++ 8 files changed, 578 insertions(+), 582 deletions(-) create mode 100644 packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/AzureCosmosDbRequest.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/HandlePagination.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/SearchCollections.test.ts diff --git a/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts index 499779c20c..e601ad235e 100644 --- a/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts @@ -33,12 +33,27 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType { }, default: '', }, + { + displayName: 'Database', + name: 'database', + description: 'Database name', + type: 'string', + default: '', + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'hidden', + default: '=https://{{ $self["account"] }}.documents.azure.com/dbs/{{ $self["database"] }}', + }, ]; async authenticate( credentials: ICredentialDataDecryptedObject, requestOptions: IHttpRequestOptions, ): Promise { + console.log('Authenticate invoked with requestOptions:', requestOptions); + if (requestOptions.qs) { for (const [key, value] of Object.entries(requestOptions.qs)) { if (value === undefined) { @@ -52,7 +67,7 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType { requestOptions.headers = { ...requestOptions.headers, 'x-ms-date': date, - 'x-ms-version': '2020-04-08', + 'x-ms-version': '2018-12-31', }; if (credentials.sessionToken) { @@ -60,40 +75,16 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType { } let resourceType = ''; - let resourceLink = ''; - if (requestOptions.body && typeof requestOptions.body === 'object') { - const isCollectionRequest = 'colls' in requestOptions.body; - const isDocumentRequest = 'docs' in requestOptions.body; + const resourceLink = requestOptions.url; - if (isCollectionRequest) { - resourceType = 'dbs'; - resourceLink = `dbs/${credentials.database}/colls`; - } else if (isDocumentRequest) { - resourceType = 'colls'; - const collId = requestOptions.qs?.collId || ''; - if (!collId) { - throw new ApplicationError('Collection ID (collId) is required for document requests.'); - } - resourceLink = `dbs/${credentials.database}/colls/${collId}/docs`; - } - } else if (requestOptions.qs && typeof requestOptions.qs === 'object') { - const queryType = requestOptions.qs.queryType; - - if (queryType === 'colls') { - resourceType = 'dbs'; - resourceLink = `dbs/${credentials.database}/colls`; - } else if (queryType === 'docs') { - resourceType = 'colls'; - const collId = requestOptions.qs.collId || ''; - if (!collId) { - throw new ApplicationError('Collection ID (collId) is required for document queries.'); - } - resourceLink = `dbs/${credentials.database}/colls/${collId}/docs`; - } + if (resourceLink.includes('/colls')) { + resourceType = 'colls'; + } else if (resourceLink.includes('/docs')) { + resourceType = 'docs'; + } else if (resourceLink.includes('/dbs')) { + resourceType = 'dbs'; } else { - throw new ApplicationError( - 'Invalid requestOptions: Either body or query string (qs) is required.', - ); + throw new ApplicationError('Unable to determine resourceType'); } if (requestOptions.method) { @@ -105,9 +96,11 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType { credentials.key as string, ); - requestOptions.headers.authorization = authToken; + requestOptions.headers.Authorization = authToken; } + console.log('Final requestOptions headers:', requestOptions.headers); + return requestOptions; } } diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts index 0373f08d30..72e6a0bd4c 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts @@ -3,7 +3,7 @@ import { NodeConnectionType } from 'n8n-workflow'; import { containerFields, containerOperations } from './descriptions/ContainerDescription'; import { itemFields, itemOperations } from './descriptions/ItemDescription'; -import { searchCollections, searchDatabases } from './GenericFunctions'; +import { searchCollections } from './GenericFunctions'; export class AzureCosmosDb implements INodeType { description: INodeTypeDescription = { @@ -34,9 +34,10 @@ export class AzureCosmosDb implements INodeType { }, ], requestDefaults: { - baseURL: '=https://{$credentials.account}.documents.azure.com', + baseURL: '={{$credentials.baseUrl}}', headers: { Accept: 'application/json', + 'Content-Type': 'application/json', }, }, properties: [ @@ -79,7 +80,6 @@ export class AzureCosmosDb implements INodeType { methods = { listSearch: { searchCollections, - searchDatabases, }, }; } diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts index 51e3c1b187..368f4947c3 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts @@ -10,7 +10,6 @@ import type { INodeListSearchResult, } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow'; -import * as querystring from 'querystring'; // export const HeaderConstants = { // // Required @@ -49,7 +48,6 @@ export function getAuthorizationTokenUsingMasterKey( masterKey: string, ): string { const key = Buffer.from(masterKey, 'base64'); - const payload = `${verb.toLowerCase()}\n` + `${resourceType.toLowerCase()}\n` + @@ -60,7 +58,7 @@ export function getAuthorizationTokenUsingMasterKey( const hmacSha256 = crypto.createHmac('sha256', key); const hashPayload = hmacSha256.update(payload, 'utf8').digest('base64'); - const authorizationString = querystring.escape(`type=master&ver=1.0&sig=${hashPayload}`); + const authorizationString = `type=master&ver=1.0&sig=${hashPayload}`; return authorizationString; } @@ -134,6 +132,10 @@ export async function azureCosmosDbRequest( const requestOptions: IHttpRequestOptions = { ...opts, baseURL: `https://${databaseAccount}.documents.azure.com`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, json: true, }; @@ -150,6 +152,8 @@ export async function azureCosmosDbRequest( }; try { + console.log('Final Request Options before Request:', requestOptions); + return (await this.helpers.requestWithAuthentication.call( this, 'azureCosmosDbSharedKeyApi', @@ -188,25 +192,9 @@ export async function searchCollections( this: ILoadOptionsFunctions, filter?: string, ): Promise { - const dbId = this.getNodeParameter('dbId') as string; - if (!dbId) { - throw new ApplicationError('Database ID is required'); - } - - const credentials = await this.getCredentials('azureCosmosDbSharedKeyApi'); - const databaseAccount = credentials?.account; - - if (!databaseAccount) { - throw new ApplicationError('Account name not found in credentials!', { level: 'error' }); - } - const opts: IHttpRequestOptions = { method: 'GET', - url: `/dbs/${dbId}/colls`, - baseURL: `https://${databaseAccount}.documents.azure.com`, - headers: { - 'Content-Type': 'application/json', - }, + url: '/colls', }; const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); @@ -233,38 +221,36 @@ export async function searchCollections( }; } -export async function searchDatabases( - this: ILoadOptionsFunctions, - filter?: string, -): Promise { - const opts: IHttpRequestOptions = { - method: 'GET', - url: '/dbs', - headers: { - 'Content-Type': 'application/json', - }, - }; +// export async function searchDatabases( +// this: ILoadOptionsFunctions, +// filter?: string, +// ): Promise { - const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); +// const opts: IHttpRequestOptions = { +// method: 'GET', +// url: '/dbs', +// }; - const responseBody = responseData as { - Databases: IDataObject[]; - }; - const databases = responseBody.Databases; +// const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); +// console.log('Got this response', responseData) +// const responseBody = responseData as { +// Databases: IDataObject[]; +// }; +// const databases = responseBody.Databases; - if (!databases) { - return { results: [] }; - } +// if (!databases) { +// return { results: [] }; +// } - const results: INodeListSearchItems[] = databases - .map((database) => ({ - name: String(database.id), - value: String(database.id), - })) - .filter((database) => !filter || database.name.includes(filter)) - .sort((a, b) => a.name.localeCompare(b.name)); +// const results: INodeListSearchItems[] = databases +// .map((database) => ({ +// name: String(database.id), +// value: String(database.id), +// })) +// .filter((database) => !filter || database.name.includes(filter)) +// .sort((a, b) => a.name.localeCompare(b.name)); - return { - results, - }; -} +// return { +// results, +// }; +// } diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts index 317acc1275..079f30b74c 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts @@ -20,7 +20,7 @@ export const containerOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'POST', - url: '=/dbs/{{ $parameter["dbId"] }}/colls', + url: '/colls', }, }, action: 'Create container', @@ -33,7 +33,7 @@ export const containerOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'DELETE', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}', + url: '=/colls/{{ $parameter["collId"] }}', }, }, action: 'Delete container', @@ -46,7 +46,7 @@ export const containerOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'GET', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}', + url: '=/colls/{{ $parameter["collId"] }}', }, }, action: 'Get container', @@ -59,7 +59,7 @@ export const containerOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'GET', - url: '=/dbs/{{ $parameter["dbId"] }}/colls', + url: '/colls', }, }, action: 'Get many containers', @@ -70,50 +70,6 @@ export const containerOperations: INodeProperties[] = [ ]; export const createFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['container'], - operation: ['create'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, { displayName: 'ID', name: 'id', @@ -185,6 +141,45 @@ export const createFields: INodeProperties[] = [ }, }, }, + { + displayName: 'Max RU/s (for Autoscale)', + name: 'maxThroughput', + type: 'number', + default: 1000, + description: 'The user specified autoscale max RU/s', + displayOptions: { + show: { + offerThroughput: [undefined], + }, + }, + routing: { + send: { + type: 'query', + property: 'x-ms-cosmos-offer-autopilot-settings', + value: '={{"{"maxThroughput": " + $value + "}"}', + }, + }, + }, + { + displayName: 'Max RU/s (for Manual Throughput)', + name: 'offerThroughput', + type: 'number', + default: 400, + description: + 'The user specified manual throughput (RU/s) for the collection expressed in units of 100 request units per second', + displayOptions: { + show: { + maxThroughput: [undefined], + }, + }, + routing: { + send: { + type: 'query', + property: 'x-ms-offer-throughput', + value: '={{$value}}', + }, + }, + }, ], placeholder: 'Add Option', type: 'collection', @@ -192,50 +187,6 @@ export const createFields: INodeProperties[] = [ ]; export const getFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['container'], - operation: ['get'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, { displayName: 'Container ID', name: 'collId', @@ -282,98 +233,9 @@ export const getFields: INodeProperties[] = [ }, ]; -export const getAllFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['container'], - operation: ['getAll'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, -]; +export const getAllFields: INodeProperties[] = []; export const deleteFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['container'], - operation: ['delete'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, { displayName: 'Container ID', name: 'collId', diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts index 4b08354a38..bec1b6f964 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts @@ -22,7 +22,7 @@ export const itemOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'POST', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs', + url: '=/colls/{{ $parameter["collId"] }}/docs', }, }, action: 'Create item', @@ -35,7 +35,7 @@ export const itemOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'DELETE', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', + url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', }, }, action: 'Delete item', @@ -45,10 +45,20 @@ export const itemOperations: INodeProperties[] = [ value: 'get', description: 'Retrieve an item', routing: { + send: { + preSend: [ + async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + return requestOptions; + }, + ], + }, request: { ignoreHttpStatusErrors: true, method: 'GET', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', + url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', }, }, action: 'Get item', @@ -67,7 +77,7 @@ export const itemOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'GET', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs', + url: '=/colls/{{ $parameter["collId"] }}/docs', }, }, action: 'Get many items', @@ -80,7 +90,7 @@ export const itemOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'POST', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs', + url: '=/colls/{{ $parameter["collId"] }}/docs', headers: { 'Content-Type': 'application/query+json', 'x-ms-documentdb-isquery': 'True', @@ -97,7 +107,7 @@ export const itemOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'PATCH', - url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', + url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', }, }, action: 'Update item', @@ -108,50 +118,6 @@ export const itemOperations: INodeProperties[] = [ ]; export const createFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['item'], - operation: ['create'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, { displayName: 'Collection ID', name: 'collId', @@ -196,28 +162,28 @@ export const createFields: INodeProperties[] = [ }, ], }, - { - displayName: 'ID', - name: 'id', - type: 'string', - default: '', - placeholder: 'e.g. AndersenFamily', - description: "Item's ID", - required: true, - displayOptions: { - show: { - resource: ['item'], - operation: ['create'], - }, - }, - routing: { - send: { - type: 'body', - property: 'id', - value: '={{$value}}', - }, - }, - }, + // { + // displayName: 'ID', + // name: 'id', + // type: 'string', + // default: '', + // placeholder: 'e.g. AndersenFamily', + // description: "Item's ID", + // required: true, + // displayOptions: { + // show: { + // resource: ['item'], + // operation: ['create'], + // }, + // }, + // routing: { + // send: { + // type: 'body', + // property: 'id', + // value: '={{$value}}', + // }, + // }, + // }, { displayName: 'Custom Properties', name: 'customProperties', @@ -235,58 +201,13 @@ export const createFields: INodeProperties[] = [ routing: { send: { type: 'body', - property: '', - value: '={{$value}}', + value: '={{ $json["id"] ? Object.assign({ id: $json["id"] }, $value) : $value }}', }, }, }, ]; export const deleteFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['item'], - operation: ['delete'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, { displayName: 'Collection ID', name: 'collId', @@ -349,50 +270,6 @@ export const deleteFields: INodeProperties[] = [ ]; export const getFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['item'], - operation: ['get'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, { displayName: 'Collection ID', name: 'collId', @@ -451,61 +328,10 @@ export const getFields: INodeProperties[] = [ operation: ['get'], }, }, - routing: { - send: { - type: 'body', - property: 'id', - value: '={{$value}}', - }, - }, }, ]; export const getAllFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['item'], - operation: ['getAll'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, { displayName: 'Collection ID', name: 'collId', @@ -561,18 +387,6 @@ export const getAllFields: INodeProperties[] = [ operation: ['getAll'], }, }, - routing: { - send: { - preSend: [ - async function ( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, - ): Promise { - return requestOptions; - }, - ], - }, - }, type: 'boolean', }, { @@ -603,50 +417,6 @@ export const getAllFields: INodeProperties[] = [ ]; export const queryFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['item'], - operation: ['query'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, { displayName: 'Collection ID', name: 'collId', @@ -723,6 +493,12 @@ export const queryFields: INodeProperties[] = [ typeOptions: { multipleValues: true, }, + displayOptions: { + show: { + resource: ['item'], + operation: ['query'], + }, + }, options: [ { name: 'parameters', @@ -756,50 +532,6 @@ export const queryFields: INodeProperties[] = [ ]; export const updateFields: INodeProperties[] = [ - { - displayName: 'Database ID', - name: 'dbId', - type: 'resourceLocator', - required: true, - default: { - mode: 'list', - value: '', - }, - description: 'Select the database you want to use', - displayOptions: { - show: { - resource: ['item'], - operation: ['update'], - }, - }, - modes: [ - { - displayName: 'From list', - name: 'list', - type: 'list', - typeOptions: { - searchListMethod: 'searchDatabases', - searchable: true, - }, - }, - { - displayName: 'By Name', - name: 'databaseName', - type: 'string', - hint: 'Enter the database name', - validation: [ - { - type: 'regex', - properties: { - regex: '^[\\w+=,.@-]+$', - errorMessage: 'The database name must follow the allowed pattern.', - }, - }, - ], - placeholder: 'e.g. UsersDB', - }, - ], - }, { displayName: 'Collection ID', name: 'collId', diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/AzureCosmosDbRequest.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/AzureCosmosDbRequest.test.ts new file mode 100644 index 0000000000..d9a51aa9a3 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/AzureCosmosDbRequest.test.ts @@ -0,0 +1,185 @@ +import { azureCosmosDbRequest } from '../GenericFunctions'; + +describe('GenericFunctions - azureCosmosDbRequest', () => { + let mockContext: any; + let mockRequestWithAuthentication: jest.Mock; + + beforeEach(() => { + mockRequestWithAuthentication = jest.fn(); + mockContext = { + helpers: { + requestWithAuthentication: mockRequestWithAuthentication, + }, + getCredentials: jest.fn(), + }; + }); + + test('should make a successful request with correct options', async () => { + mockRequestWithAuthentication.mockResolvedValueOnce({ success: true }); + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + + const requestOptions = { + method: 'GET' as const, + url: '/example-endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + const result = await azureCosmosDbRequest.call(mockContext, requestOptions); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'azureCosmosDbSharedKeyApi', + expect.objectContaining({ + method: 'GET', + baseURL: 'https://us-east-1.documents.azure.com', + url: '/example-endpoint', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + json: true, + }), + ); + + expect(result).toEqual({ success: true }); + }); + + test('should throw an error if account is missing in credentials', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({}); + + const requestOptions = { + method: 'GET' as const, + url: '/example-endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + 'Database account not found in credentials!', + ); + + expect(mockRequestWithAuthentication).not.toHaveBeenCalled(); + }); + + test('should throw a descriptive error for invalid credentials (403)', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + mockRequestWithAuthentication.mockRejectedValueOnce({ + statusCode: 403, + response: { + body: { + message: 'The security token included in the request is invalid.', + }, + }, + }); + + const requestOptions = { + method: 'GET' as const, + url: '/example-endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + 'The Cosmos DB credentials are not valid!', + ); + + expect(mockRequestWithAuthentication).toHaveBeenCalled(); + }); + + test('should throw a descriptive error for invalid request signature (403)', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + mockRequestWithAuthentication.mockRejectedValueOnce({ + statusCode: 403, + response: { + body: { + message: 'The request signature we calculated does not match the signature you provided', + }, + }, + }); + + const requestOptions = { + method: 'GET' as const, + url: '/example-endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + 'The Cosmos DB credentials are not valid!', + ); + + expect(mockRequestWithAuthentication).toHaveBeenCalled(); + }); + + test('should throw an error for resource not found (404)', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + mockRequestWithAuthentication.mockRejectedValueOnce({ + statusCode: 404, + response: { + body: { + message: 'The specified resource does not exist.', + }, + }, + }); + + const requestOptions = { + method: 'GET' as const, + url: '/example-endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + 'The requested resource was not found!', + ); + + expect(mockRequestWithAuthentication).toHaveBeenCalled(); + }); + + test('should throw a generic error for unexpected response', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + mockRequestWithAuthentication.mockRejectedValueOnce({ + statusCode: 500, + message: 'Internal Server Error', + }); + + const requestOptions = { + method: 'GET' as const, + url: '/example-endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + 'Cosmos DB error response [500]: Internal Server Error', + ); + + expect(mockRequestWithAuthentication).toHaveBeenCalled(); + }); + + test('should handle unexpected error structures', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + mockRequestWithAuthentication.mockRejectedValueOnce({ + cause: { error: { message: 'Unexpected failure' } }, + }); + + const requestOptions = { + method: 'GET' as const, + url: '/example-endpoint', + headers: { + 'Content-Type': 'application/json', + }, + }; + + await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + 'Cosmos DB error response [undefined]: Unexpected failure', + ); + + expect(mockRequestWithAuthentication).toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/HandlePagination.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/HandlePagination.test.ts new file mode 100644 index 0000000000..0952ae09d1 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/HandlePagination.test.ts @@ -0,0 +1,116 @@ +import { handlePagination } from '../GenericFunctions'; + +describe('GenericFunctions - handlePagination', () => { + let mockContext: any; + let mockMakeRoutingRequest: jest.Mock; + let resultOptions: any; + + beforeEach(() => { + mockMakeRoutingRequest = jest.fn(); + mockContext = { + makeRoutingRequest: mockMakeRoutingRequest, + getNodeParameter: jest.fn(), + }; + + resultOptions = { + maxResults: 60, + options: { body: {} }, + }; + }); + + test('should aggregate results and handle pagination when returnAll is true', async () => { + mockMakeRoutingRequest + .mockResolvedValueOnce([ + { id: 1 }, + { id: 2 }, + { headers: { 'x-ms-continuation': 'token-1' } }, + ]) + .mockResolvedValueOnce([{ id: 3 }, { id: 4 }, { headers: {} }]); + + mockContext.getNodeParameter.mockImplementation((param: string) => { + if (param === 'returnAll') return true; + return undefined; + }); + + const result = await handlePagination.call(mockContext, resultOptions); + + expect(result).toEqual([ + { json: { id: 1 } }, + { json: { id: 2 } }, + { json: { id: 3 } }, + { json: { id: 4 } }, + ]); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2); + expect(resultOptions.options.headers).toEqual({ + 'x-ms-continuation': 'token-1', + }); + }); + + test('should stop pagination after reaching limit when returnAll is false', async () => { + mockMakeRoutingRequest + .mockResolvedValueOnce([ + { id: 1 }, + { id: 2 }, + { headers: { 'x-ms-continuation': 'token-1' } }, + ]) + .mockResolvedValueOnce([{ id: 3 }, { id: 4 }, { headers: {} }]); + + mockContext.getNodeParameter.mockImplementation((param: string) => { + if (param === 'returnAll') return false; + if (param === 'limit') return 3; + return undefined; + }); + + const result = await handlePagination.call(mockContext, resultOptions); + + expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }, { json: { id: 3 } }]); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2); + }); + + test('should handle cases with no continuation token gracefully', async () => { + mockMakeRoutingRequest.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); + + mockContext.getNodeParameter.mockImplementation((param: string) => { + if (param === 'returnAll') return true; + return undefined; + }); + + const result = await handlePagination.call(mockContext, resultOptions); + + expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }]); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1); + }); + + test('should respect the limit even if fewer results are available', async () => { + mockMakeRoutingRequest.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); + + mockContext.getNodeParameter.mockImplementation((param: string) => { + if (param === 'returnAll') return false; + if (param === 'limit') return 5; + return undefined; + }); + + const result = await handlePagination.call(mockContext, resultOptions); + + expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }]); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1); + }); + + test('should break the loop if no results are returned', async () => { + mockMakeRoutingRequest.mockResolvedValueOnce([]); + + mockContext.getNodeParameter.mockImplementation((param: string) => { + if (param === 'returnAll') return true; + return undefined; + }); + + const result = await handlePagination.call(mockContext, resultOptions); + + expect(result).toEqual([]); + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/SearchCollections.test.ts b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/SearchCollections.test.ts new file mode 100644 index 0000000000..abdfacd3ee --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/SearchCollections.test.ts @@ -0,0 +1,122 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchCollections } from '../GenericFunctions'; + +describe('GenericFunctions - searchCollections', () => { + const mockRequestWithAuthentication = jest.fn(); + + const mockContext = { + helpers: { + requestWithAuthentication: mockRequestWithAuthentication, + }, + getNodeParameter: jest.fn(), + getCredentials: jest.fn(), + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should make a GET request to fetch collections and return results', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + DocumentCollections: [{ id: 'Collection1' }, { id: 'Collection2' }], + }); + + const response = await searchCollections.call(mockContext); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'azureCosmosDbSharedKeyApi', + expect.objectContaining({ + baseURL: 'https://us-east-1.documents.azure.com', + method: 'GET', + url: '/colls', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + json: true, + }), + ); + + expect(response).toEqual({ + results: [ + { name: 'Collection1', value: 'Collection1' }, + { name: 'Collection2', value: 'Collection2' }, + ], + }); + }); + + it('should filter collections by the provided filter string', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + DocumentCollections: [{ id: 'Test-Col-1' }, { id: 'Prod-Col-1' }], + }); + + const response = await searchCollections.call(mockContext, 'Test'); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'azureCosmosDbSharedKeyApi', + expect.objectContaining({ + baseURL: 'https://us-east-1.documents.azure.com', + method: 'GET', + url: '/colls', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + json: true, + }), + ); + + expect(response).toEqual({ + results: [{ name: 'Test-Col-1', value: 'Test-Col-1' }], + }); + }); + + it('should sort collections alphabetically by name', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce('db-id-1'); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + DocumentCollections: [{ id: 'z-col' }, { id: 'a-col' }, { id: 'm-col' }], + }); + + const response = await searchCollections.call(mockContext); + + expect(response).toEqual({ + results: [ + { name: 'a-col', value: 'a-col' }, + { name: 'm-col', value: 'm-col' }, + { name: 'z-col', value: 'z-col' }, + ], + }); + }); + + it('should handle empty results when no collections are returned', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce('db-id-1'); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + DocumentCollections: [], + }); + + const response = await searchCollections.call(mockContext); + + expect(response).toEqual({ results: [] }); + }); + + it('should handle missing Collections property', async () => { + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce('db-id-1'); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + unexpectedkey: 'value', + }); + const response = await searchCollections.call(mockContext); + + expect(response).toEqual({ results: [] }); + }); +}); From 0a57678acc244a4ab9d76a1b132ba3997dbf4318 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Fri, 31 Jan 2025 10:07:38 +0200 Subject: [PATCH 06/19] Added tests and changed name --- ...rosoftCosmosDbSharedKeyApi.credentials.ts} | 19 ++-- .../{AzureCosmosDB => CosmosDB}/CosmosDB.svg | 0 .../CosmosDb.node.ts} | 6 +- .../GenericFunctions.ts | 100 ++++++------------ .../descriptions/ContainerDescription.ts | 0 .../descriptions/ItemDescription.ts | 0 .../test/HandlePagination.test.ts | 0 .../test/MicrosoftCosmosDbRequest.test.ts} | 28 +++-- .../test/SearchCollections.test.ts | 0 packages/nodes-base/package.json | 4 +- 10 files changed, 66 insertions(+), 91 deletions(-) rename packages/nodes-base/credentials/{AzureCosmosDbSharedKeyApi.credentials.ts => MicrosoftCosmosDbSharedKeyApi.credentials.ts} (79%) rename packages/nodes-base/nodes/Microsoft/{AzureCosmosDB => CosmosDB}/CosmosDB.svg (100%) rename packages/nodes-base/nodes/Microsoft/{AzureCosmosDB/AzureCosmosDb.node.ts => CosmosDB/CosmosDb.node.ts} (94%) rename packages/nodes-base/nodes/Microsoft/{AzureCosmosDB => CosmosDB}/GenericFunctions.ts (67%) rename packages/nodes-base/nodes/Microsoft/{AzureCosmosDB => CosmosDB}/descriptions/ContainerDescription.ts (100%) rename packages/nodes-base/nodes/Microsoft/{AzureCosmosDB => CosmosDB}/descriptions/ItemDescription.ts (100%) rename packages/nodes-base/nodes/Microsoft/{AzureCosmosDB => CosmosDB}/test/HandlePagination.test.ts (100%) rename packages/nodes-base/nodes/Microsoft/{AzureCosmosDB/test/AzureCosmosDbRequest.test.ts => CosmosDB/test/MicrosoftCosmosDbRequest.test.ts} (80%) rename packages/nodes-base/nodes/Microsoft/{AzureCosmosDB => CosmosDB}/test/SearchCollections.test.ts (100%) diff --git a/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts similarity index 79% rename from packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts rename to packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index e601ad235e..686ff14f98 100644 --- a/packages/nodes-base/credentials/AzureCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -6,14 +6,17 @@ import { type INodeProperties, } from 'n8n-workflow'; -import { getAuthorizationTokenUsingMasterKey } from '../nodes/Microsoft/AzureCosmosDB/GenericFunctions'; +import { + getAuthorizationTokenUsingMasterKey, + HeaderConstants, +} from '../nodes/Microsoft/CosmosDB/GenericFunctions'; -export class AzureCosmosDbSharedKeyApi implements ICredentialType { - name = 'azureCosmosDbSharedKeyApi'; +export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { + name = 'microsoftCosmosDbSharedKeyApi'; displayName = 'Azure Cosmos DB API'; - documentationUrl = 'azureCosmosDb'; + documentationUrl = 'microsoftCosmosDb'; properties: INodeProperties[] = [ { @@ -75,8 +78,9 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType { } let resourceType = ''; - const resourceLink = requestOptions.url; + const resourceLink = '/dbs/first_database_1' + requestOptions.url; + console.log('Link', resourceLink); if (resourceLink.includes('/colls')) { resourceType = 'colls'; } else if (resourceLink.includes('/docs')) { @@ -86,6 +90,7 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType { } else { throw new ApplicationError('Unable to determine resourceType'); } + console.log('Type', resourceType); if (requestOptions.method) { const authToken = getAuthorizationTokenUsingMasterKey( @@ -96,10 +101,10 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType { credentials.key as string, ); - requestOptions.headers.Authorization = authToken; + requestOptions.headers[HeaderConstants.AUTHORIZATION] = authToken; } - console.log('Final requestOptions headers:', requestOptions.headers); + console.log('Final requestOptions:', requestOptions); return requestOptions; } diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/CosmosDB.svg b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDB.svg similarity index 100% rename from packages/nodes-base/nodes/Microsoft/AzureCosmosDB/CosmosDB.svg rename to packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDB.svg diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts similarity index 94% rename from packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts rename to packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts index 72e6a0bd4c..566ea4b05d 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/AzureCosmosDb.node.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts @@ -7,8 +7,8 @@ import { searchCollections } from './GenericFunctions'; export class AzureCosmosDb implements INodeType { description: INodeTypeDescription = { - displayName: 'Azure Cosmos DB', - name: 'azureCosmosDb', + displayName: 'Cosmos DB', + name: 'cosmosDb', icon: { light: 'file:CosmosDB.svg', dark: 'file:CosmosDB.svg', @@ -24,7 +24,7 @@ export class AzureCosmosDb implements INodeType { outputs: [NodeConnectionType.Main], credentials: [ { - name: 'azureCosmosDbSharedKeyApi', + name: 'microsoftCosmosDbSharedKeyApi', required: true, displayOptions: { show: { diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts similarity index 67% rename from packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts rename to packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index 368f4947c3..1613e82729 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -11,34 +11,34 @@ import type { } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow'; -// export const HeaderConstants = { -// // Required -// AUTHORIZATION: 'Authorization', -// CONTENT_TYPE: 'Content-Type', -// X_MS_DATE: 'x-ms-date', -// X_MS_VERSION: 'x-ms-version', +export const HeaderConstants = { + // Required + AUTHORIZATION: 'Authorization', + CONTENT_TYPE: 'Content-Type', + X_MS_DATE: 'x-ms-date', + X_MS_VERSION: 'x-ms-version', -// //Required - for session consistency only -// X_MS_SESSION_TOKEN: 'x-ms-session-token', + //Required - for session consistency only + X_MS_SESSION_TOKEN: 'x-ms-session-token', -// // Optional -// IF_MATCH: 'If-Match', -// IF_NONE_MATCH: 'If-None-Match', -// IF_MODIFIED_SINCE: 'If-Modified-Since', -// USER_AGENT: 'User-Agent', -// X_MS_ACTIVITY_ID: 'x-ms-activity-id', -// X_MS_CONSISTENCY_LEVEL: 'x-ms-consistency-level', -// X_MS_CONTINUATION: 'x-ms-continuation', -// X_MS_MAX_ITEM_COUNT: 'x-ms-max-item-count', -// X_MS_DOCUMENTDB_PARTITIONKEY: 'x-ms-documentdb-partitionkey', -// X_MS_DOCUMENTDB_ISQUERY: 'x-ms-documentdb-isquery', -// X_MS_DOCUMENTDB_QUERY_ENABLECROSSPARTITION: 'x-ms-documentdb-query-enablecrosspartition', -// A_IM: 'A-IM', -// X_MS_DOCUMENTDB_PARTITIONKEYRANGEID: 'x-ms-documentdb-partitionkeyrangeid', -// X_MS_COSMOS_ALLOW_TENTATIVE_WRITES: 'x-ms-cosmos-allow-tentative-writes', + // Optional + IF_MATCH: 'If-Match', + IF_NONE_MATCH: 'If-None-Match', + IF_MODIFIED_SINCE: 'If-Modified-Since', + USER_AGENT: 'User-Agent', + X_MS_ACTIVITY_ID: 'x-ms-activity-id', + X_MS_CONSISTENCY_LEVEL: 'x-ms-consistency-level', + X_MS_CONTINUATION: 'x-ms-continuation', + X_MS_MAX_ITEM_COUNT: 'x-ms-max-item-count', + X_MS_DOCUMENTDB_PARTITIONKEY: 'x-ms-documentdb-partitionkey', + X_MS_DOCUMENTDB_ISQUERY: 'x-ms-documentdb-isquery', + X_MS_DOCUMENTDB_QUERY_ENABLECROSSPARTITION: 'x-ms-documentdb-query-enablecrosspartition', + A_IM: 'A-IM', + X_MS_DOCUMENTDB_PARTITIONKEYRANGEID: 'x-ms-documentdb-partitionkeyrangeid', + X_MS_COSMOS_ALLOW_TENTATIVE_WRITES: 'x-ms-cosmos-allow-tentative-writes', -// PREFIX_FOR_STORAGE: 'x-ms-', -// }; + PREFIX_FOR_STORAGE: 'x-ms-', +}; export function getAuthorizationTokenUsingMasterKey( verb: string, @@ -52,15 +52,13 @@ export function getAuthorizationTokenUsingMasterKey( `${verb.toLowerCase()}\n` + `${resourceType.toLowerCase()}\n` + `${resourceLink}\n` + - `${date.toLowerCase()}\n` + + `${date}\n` + '\n'; const hmacSha256 = crypto.createHmac('sha256', key); const hashPayload = hmacSha256.update(payload, 'utf8').digest('base64'); - const authorizationString = `type=master&ver=1.0&sig=${hashPayload}`; - - return authorizationString; + return encodeURIComponent('type=master&ver=1.0&sig=' + hashPayload); } export async function handlePagination( @@ -118,11 +116,11 @@ export async function handlePagination( return aggregatedResult.map((result) => ({ json: result })); } -export async function azureCosmosDbRequest( +export async function microsoftCosmosDbRequest( this: ILoadOptionsFunctions, opts: IHttpRequestOptions, ): Promise { - const credentials = await this.getCredentials('azureCosmosDbSharedKeyApi'); + const credentials = await this.getCredentials('microsoftCosmosDbSharedKeyApi'); const databaseAccount = credentials?.account; if (!databaseAccount) { @@ -131,7 +129,7 @@ export async function azureCosmosDbRequest( const requestOptions: IHttpRequestOptions = { ...opts, - baseURL: `https://${databaseAccount}.documents.azure.com`, + baseURL: `${credentials.baseUrl}`, headers: { Accept: 'application/json', 'Content-Type': 'application/json', @@ -156,7 +154,7 @@ export async function azureCosmosDbRequest( return (await this.helpers.requestWithAuthentication.call( this, - 'azureCosmosDbSharedKeyApi', + 'microsoftCosmosDbSharedKeyApi', requestOptions, )) as IDataObject; } catch (error) { @@ -197,7 +195,7 @@ export async function searchCollections( url: '/colls', }; - const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); + const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); const responseBody = responseData as { DocumentCollections: IDataObject[]; @@ -220,37 +218,3 @@ export async function searchCollections( results, }; } - -// export async function searchDatabases( -// this: ILoadOptionsFunctions, -// filter?: string, -// ): Promise { - -// const opts: IHttpRequestOptions = { -// method: 'GET', -// url: '/dbs', -// }; - -// const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); -// console.log('Got this response', responseData) -// const responseBody = responseData as { -// Databases: IDataObject[]; -// }; -// const databases = responseBody.Databases; - -// if (!databases) { -// return { results: [] }; -// } - -// const results: INodeListSearchItems[] = databases -// .map((database) => ({ -// name: String(database.id), -// value: String(database.id), -// })) -// .filter((database) => !filter || database.name.includes(filter)) -// .sort((a, b) => a.name.localeCompare(b.name)); - -// return { -// results, -// }; -// } diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ContainerDescription.ts rename to packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Microsoft/AzureCosmosDB/descriptions/ItemDescription.ts rename to packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/HandlePagination.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/HandlePagination.test.ts similarity index 100% rename from packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/HandlePagination.test.ts rename to packages/nodes-base/nodes/Microsoft/CosmosDB/test/HandlePagination.test.ts diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/AzureCosmosDbRequest.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts similarity index 80% rename from packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/AzureCosmosDbRequest.test.ts rename to packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts index d9a51aa9a3..5042bf4a20 100644 --- a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/AzureCosmosDbRequest.test.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts @@ -1,6 +1,6 @@ -import { azureCosmosDbRequest } from '../GenericFunctions'; +import { microsoftCosmosDbRequest } from '../GenericFunctions'; -describe('GenericFunctions - azureCosmosDbRequest', () => { +describe('GenericFunctions - microsoftCosmosDbRequest', () => { let mockContext: any; let mockRequestWithAuthentication: jest.Mock; @@ -17,6 +17,12 @@ describe('GenericFunctions - azureCosmosDbRequest', () => { test('should make a successful request with correct options', async () => { mockRequestWithAuthentication.mockResolvedValueOnce({ success: true }); (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + database: 'first_database_1', + }); + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + baseUrl: 'https://us-east-1.documents.azure.com/dbs', + }); const requestOptions = { method: 'GET' as const, @@ -26,13 +32,13 @@ describe('GenericFunctions - azureCosmosDbRequest', () => { }, }; - const result = await azureCosmosDbRequest.call(mockContext, requestOptions); + const result = await microsoftCosmosDbRequest.call(mockContext, requestOptions); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( - 'azureCosmosDbSharedKeyApi', + 'microsoftCosmosDbSharedKeyApi', expect.objectContaining({ method: 'GET', - baseURL: 'https://us-east-1.documents.azure.com', + baseURL: 'https://us-east-1.documents.azure.com/dbs/first_database_1', url: '/example-endpoint', headers: expect.objectContaining({ 'Content-Type': 'application/json', @@ -55,7 +61,7 @@ describe('GenericFunctions - azureCosmosDbRequest', () => { }, }; - await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + await expect(microsoftCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( 'Database account not found in credentials!', ); @@ -81,7 +87,7 @@ describe('GenericFunctions - azureCosmosDbRequest', () => { }, }; - await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + await expect(microsoftCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( 'The Cosmos DB credentials are not valid!', ); @@ -107,7 +113,7 @@ describe('GenericFunctions - azureCosmosDbRequest', () => { }, }; - await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + await expect(microsoftCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( 'The Cosmos DB credentials are not valid!', ); @@ -133,7 +139,7 @@ describe('GenericFunctions - azureCosmosDbRequest', () => { }, }; - await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + await expect(microsoftCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( 'The requested resource was not found!', ); @@ -155,7 +161,7 @@ describe('GenericFunctions - azureCosmosDbRequest', () => { }, }; - await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + await expect(microsoftCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( 'Cosmos DB error response [500]: Internal Server Error', ); @@ -176,7 +182,7 @@ describe('GenericFunctions - azureCosmosDbRequest', () => { }, }; - await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( + await expect(microsoftCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow( 'Cosmos DB error response [undefined]: Unexpected failure', ); diff --git a/packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/SearchCollections.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts similarity index 100% rename from packages/nodes-base/nodes/Microsoft/AzureCosmosDB/test/SearchCollections.test.ts rename to packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 6978635c64..9e9e965229 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -39,7 +39,6 @@ "dist/credentials/AutomizyApi.credentials.js", "dist/credentials/AutopilotApi.credentials.js", "dist/credentials/Aws.credentials.js", - "dist/credentials/AzureCosmosDbSharedKeyApi.credentials.js", "dist/credentials/BambooHrApi.credentials.js", "dist/credentials/BannerbearApi.credentials.js", "dist/credentials/BaserowApi.credentials.js", @@ -218,6 +217,7 @@ "dist/credentials/MetabaseApi.credentials.js", "dist/credentials/MessageBirdApi.credentials.js", "dist/credentials/MetabaseApi.credentials.js", + "dist/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.js", "dist/credentials/MicrosoftDynamicsOAuth2Api.credentials.js", "dist/credentials/MicrosoftEntraOAuth2Api.credentials.js", "dist/credentials/MicrosoftExcelOAuth2Api.credentials.js", @@ -626,7 +626,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/CosmosDB/CosmosDb.node.js", "dist/nodes/Microsoft/Dynamics/MicrosoftDynamicsCrm.node.js", "dist/nodes/Microsoft/Entra/MicrosoftEntra.node.js", "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js", From e488e6c0bb529f9db50790b2bc92514f8013aaab Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Mon, 3 Feb 2025 19:12:37 +0200 Subject: [PATCH 07/19] Worked on requests --- ...crosoftCosmosDbSharedKeyApi.credentials.ts | 41 +++++++++++++------ .../nodes/Microsoft/CosmosDB/CosmosDb.node.ts | 9 +++- .../Microsoft/CosmosDB/GenericFunctions.ts | 31 ++++++++++---- .../descriptions/ContainerDescription.ts | 3 ++ .../CosmosDB/descriptions/ItemDescription.ts | 9 +++- 5 files changed, 68 insertions(+), 25 deletions(-) diff --git a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index 686ff14f98..d0dfa8a6af 100644 --- a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -55,8 +55,7 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { credentials: ICredentialDataDecryptedObject, requestOptions: IHttpRequestOptions, ): Promise { - console.log('Authenticate invoked with requestOptions:', requestOptions); - + // Remove undefined query parameters? if (requestOptions.qs) { for (const [key, value] of Object.entries(requestOptions.qs)) { if (value === undefined) { @@ -65,38 +64,54 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { } } + // Add headers for date and version requestOptions.headers ??= {}; - const date = new Date().toUTCString(); + const date = new Date().toUTCString().toLowerCase(); requestOptions.headers = { ...requestOptions.headers, 'x-ms-date': date, 'x-ms-version': '2018-12-31', + 'x-ms-partitionkey': '[]', }; if (credentials.sessionToken) { requestOptions.headers['x-ms-session-token'] = credentials.sessionToken; } - let resourceType = ''; - const resourceLink = '/dbs/first_database_1' + requestOptions.url; + // This shouldn't be the full url + // Refer to https://stackoverflow.com/questions/45645389/documentdb-rest-api-authorization-token-error - console.log('Link', resourceLink); - if (resourceLink.includes('/colls')) { - resourceType = 'colls'; - } else if (resourceLink.includes('/docs')) { + const url = new URL(requestOptions.baseURL + requestOptions.url); + const pathSegments = url.pathname.split('/').filter((segment) => segment); + console.log('Filtered Path Segments:', pathSegments); + + let resourceType = ''; + let resourceId = ''; + + if (pathSegments.includes('docs')) { + const docsIndex = pathSegments.lastIndexOf('docs'); resourceType = 'docs'; - } else if (resourceLink.includes('/dbs')) { + resourceId = pathSegments.slice(0, docsIndex).join('/'); + } else if (pathSegments.includes('colls')) { + const collsIndex = pathSegments.lastIndexOf('colls'); + resourceType = 'colls'; + resourceId = pathSegments.slice(0, collsIndex).join('/'); + } else if (pathSegments.includes('dbs')) { + const dbsIndex = pathSegments.lastIndexOf('dbs'); resourceType = 'dbs'; + resourceId = pathSegments.slice(0, dbsIndex + 2).join('/'); } else { - throw new ApplicationError('Unable to determine resourceType'); + throw new ApplicationError('Unable to determine resourceType and resourceId from the URL.'); } - console.log('Type', resourceType); + + console.log('resourceId', resourceId); + console.log('resourceType', resourceType); if (requestOptions.method) { const authToken = getAuthorizationTokenUsingMasterKey( requestOptions.method, resourceType, - resourceLink, + resourceId, date, credentials.key as string, ); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts index 566ea4b05d..5e37f8605f 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts @@ -3,9 +3,9 @@ import { NodeConnectionType } from 'n8n-workflow'; import { containerFields, containerOperations } from './descriptions/ContainerDescription'; import { itemFields, itemOperations } from './descriptions/ItemDescription'; -import { searchCollections } from './GenericFunctions'; +import { presendStringifyBody, searchCollections } from './GenericFunctions'; -export class AzureCosmosDb implements INodeType { +export class CosmosDb implements INodeType { description: INodeTypeDescription = { displayName: 'Cosmos DB', name: 'cosmosDb', @@ -58,6 +58,11 @@ export class AzureCosmosDb implements INodeType { name: 'resource', type: 'options', noDataExpression: true, + routing: { + send: { + preSend: [presendStringifyBody], + }, + }, options: [ { name: 'Container', diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index 1613e82729..e6cf960e35 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -3,6 +3,7 @@ import type { DeclarativeRestApiSettings, IDataObject, IExecutePaginationFunctions, + IExecuteSingleFunctions, IHttpRequestOptions, ILoadOptionsFunctions, INodeExecutionData, @@ -43,7 +44,7 @@ export const HeaderConstants = { export function getAuthorizationTokenUsingMasterKey( verb: string, resourceType: string, - resourceLink: string, + resourceId: string, date: string, masterKey: string, ): string { @@ -51,14 +52,24 @@ export function getAuthorizationTokenUsingMasterKey( const payload = `${verb.toLowerCase()}\n` + `${resourceType.toLowerCase()}\n` + - `${resourceLink}\n` + - `${date}\n` + + `${resourceId}\n` + + `${date.toLowerCase()}\n` + '\n'; const hmacSha256 = crypto.createHmac('sha256', key); - const hashPayload = hmacSha256.update(payload, 'utf8').digest('base64'); + const signature = hmacSha256.update(payload, 'utf8').digest('base64'); - return encodeURIComponent('type=master&ver=1.0&sig=' + hashPayload); + return `type=master&ver=1.0&sig=${signature}`; +} + +export async function presendStringifyBody( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + if (requestOptions.body) { + requestOptions.body = JSON.stringify(requestOptions.body); + } + return requestOptions; } export async function handlePagination( @@ -207,10 +218,12 @@ export async function searchCollections( } const results: INodeListSearchItems[] = collections - .map((collection) => ({ - name: String(collection.id), - value: String(collection.id), - })) + .map((collection) => { + return { + name: String(collection.id), + value: String(collection.id), + }; + }) .filter((collection) => !filter || collection.name.includes(filter)) .sort((a, b) => a.name.localeCompare(b.name)); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts index 079f30b74c..2ceafa919f 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts @@ -21,6 +21,9 @@ export const containerOperations: INodeProperties[] = [ ignoreHttpStatusErrors: true, method: 'POST', url: '/colls', + headers: { + headers: {}, + }, }, }, action: 'Create container', diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index bec1b6f964..f7d44d86e2 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -23,6 +23,9 @@ export const itemOperations: INodeProperties[] = [ ignoreHttpStatusErrors: true, method: 'POST', url: '=/colls/{{ $parameter["collId"] }}/docs', + headers: { + // 'x-ms-documentdb-partitionkey': '={{$parameter["partitionKey"]}}', + }, }, }, action: 'Create item', @@ -108,6 +111,9 @@ export const itemOperations: INodeProperties[] = [ ignoreHttpStatusErrors: true, method: 'PATCH', url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', + headers: { + 'Content-Type': 'application/json-patch+json', + }, }, }, action: 'Update item', @@ -198,10 +204,11 @@ export const createFields: INodeProperties[] = [ operation: ['create'], }, }, + //To-Do-add preSend function routing: { send: { type: 'body', - value: '={{ $json["id"] ? Object.assign({ id: $json["id"] }, $value) : $value }}', + value: '={{$value}}', }, }, }, From 704f9aa81a326f54d27b5bbd63deeda421bca87a Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Tue, 4 Feb 2025 16:04:27 +0200 Subject: [PATCH 08/19] Worked on requests --- ...crosoftCosmosDbSharedKeyApi.credentials.ts | 2 +- .../nodes/Microsoft/CosmosDB/CosmosDb.node.ts | 9 +- .../Microsoft/CosmosDB/GenericFunctions.ts | 232 +++++++++++++++++ .../descriptions/ContainerDescription.ts | 28 +- .../CosmosDB/descriptions/ItemDescription.ts | 240 ++++++++++++++---- 5 files changed, 431 insertions(+), 80 deletions(-) diff --git a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index d0dfa8a6af..987fd13a9f 100644 --- a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -71,7 +71,6 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { ...requestOptions.headers, 'x-ms-date': date, 'x-ms-version': '2018-12-31', - 'x-ms-partitionkey': '[]', }; if (credentials.sessionToken) { @@ -80,6 +79,7 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { // This shouldn't be the full url // Refer to https://stackoverflow.com/questions/45645389/documentdb-rest-api-authorization-token-error + // const url = new URL (requestOptions.uri); const url = new URL(requestOptions.baseURL + requestOptions.url); const pathSegments = url.pathname.split('/').filter((segment) => segment); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts index 5e37f8605f..028374ef9a 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts @@ -3,7 +3,7 @@ import { NodeConnectionType } from 'n8n-workflow'; import { containerFields, containerOperations } from './descriptions/ContainerDescription'; import { itemFields, itemOperations } from './descriptions/ItemDescription'; -import { presendStringifyBody, searchCollections } from './GenericFunctions'; +import { searchCollections, searchItems } from './GenericFunctions'; export class CosmosDb implements INodeType { description: INodeTypeDescription = { @@ -59,9 +59,9 @@ export class CosmosDb implements INodeType { type: 'options', noDataExpression: true, routing: { - send: { - preSend: [presendStringifyBody], - }, + // send: { + // preSend: [presendStringifyBody], + // }, }, options: [ { @@ -85,6 +85,7 @@ export class CosmosDb implements INodeType { methods = { listSearch: { searchCollections, + searchItems, }, }; } diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index e6cf960e35..b4de6a5591 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -231,3 +231,235 @@ export async function searchCollections( results, }; } + +export async function searchItems( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const collection = this.getNodeParameter('collId') as { mode: string; value: string }; + + if (!collection?.value) { + throw new ApplicationError('Collection ID is required.'); + } + const opts: IHttpRequestOptions = { + method: 'GET', + url: `/colls/${collection.value}/docs`, + }; + + const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); + + const responseBody = responseData as { + Documents: IDataObject[]; + }; + const items = responseBody.Documents; + + if (!items) { + return { results: [] }; + } + + const results: INodeListSearchItems[] = items + .map((item) => { + return { + name: String(item.id), + value: String(item.id), + }; + }) + .filter((item) => !filter || item.name.includes(filter)) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + results, + }; +} + +export async function validateQueryParameters( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const params = this.getNodeParameter('parameters', {}) as { + parameters: Array<{ name: string; value: string }>; + }; + + if (!params || !Array.isArray(params.parameters)) { + throw new ApplicationError( + 'The "parameters" field cannot be empty. Please add at least one parameter.', + ); + } + + const parameters = params.parameters; + + for (const parameter of parameters) { + if (!parameter.name || parameter.name.trim() === '') { + throw new ApplicationError('Each parameter must have a non-empty "name".'); + } + + if (!parameter.value) { + throw new ApplicationError(`The parameter "${parameter.name}" must have a valid "value".`); + } + } + + return requestOptions; +} + +export async function validateOperations( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawOperations = this.getNodeParameter('operations', []) as IDataObject; + console.log('Operations', rawOperations); + if (!rawOperations || !Array.isArray(rawOperations.operations)) { + throw new ApplicationError('The "operations" field must contain at least one operation.'); + } + + const operations = rawOperations.operations as Array<{ + op: string; + path: string; + value?: string; + }>; + + for (const operation of operations) { + if (!['add', 'increment', 'move', 'remove', 'replace', 'set'].includes(operation.op)) { + throw new ApplicationError( + `Invalid operation type "${operation.op}". Allowed values are "add", "increment", "move", "remove", "replace", and "set".`, + ); + } + + if (!operation.path || operation.path.trim() === '') { + throw new ApplicationError('Each operation must have a valid "path".'); + } + + if ( + ['set', 'replace', 'add', 'increment'].includes(operation.op) && + (operation.value === undefined || operation.value === null) + ) { + throw new ApplicationError(`The operation "${operation.op}" must include a valid "value".`); + } + } + + return requestOptions; +} + +export async function formatCustomProperties( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawCustomProperties = this.getNodeParameter('customProperties', '{}') as string; + + let parsedProperties: Record; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedProperties = JSON.parse(rawCustomProperties); + } catch (error) { + throw new ApplicationError( + 'Invalid JSON format in "Custom Properties". Please provide a valid JSON object.', + ); + } + + if ( + typeof parsedProperties !== 'object' || + parsedProperties === null || + Array.isArray(parsedProperties) + ) { + throw new ApplicationError('The "Custom Properties" field must be a valid JSON object.'); + } + + if ( + !requestOptions.body || + typeof requestOptions.body !== 'object' || + requestOptions.body === null + ) { + requestOptions.body = {}; + } + + Object.assign(requestOptions.body as Record, parsedProperties); + + return requestOptions; +} + +export async function formatJSONFields( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawPartitionKey = this.getNodeParameter('partitionKey', '{}') as string; + const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; + const indexingPolicy = additionalFields.indexingPolicy as string; + + let parsedPartitionKey: Record; + let parsedIndexPolicy: Record | undefined; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedPartitionKey = JSON.parse(rawPartitionKey); + + if (indexingPolicy) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedIndexPolicy = JSON.parse(indexingPolicy); + } + } catch (error) { + throw new ApplicationError( + 'Invalid JSON format in either "Partition Key" or "Indexing Policy". Please provide valid JSON objects.', + ); + } + + if ( + !requestOptions.body || + typeof requestOptions.body !== 'object' || + requestOptions.body === null + ) { + requestOptions.body = {}; + } + + (requestOptions.body as Record).partitionKey = parsedPartitionKey; + + if (parsedIndexPolicy) { + (requestOptions.body as Record).indexingPolicy = parsedIndexPolicy; + } + + return requestOptions; +} + +export async function mapOperationsToRequest( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawOperations = this.getNodeParameter('operations', []) as { + operations: Array<{ + op: string; + path: string; + from?: string; + value?: string | number; + }>; + }; + + if (!rawOperations || !Array.isArray(rawOperations.operations)) { + throw new ApplicationError('Invalid operations format. Expected an array.'); + } + + // Map and validate operations + const formattedOperations = rawOperations.operations.map((operation) => { + const { op, path, from, value } = operation; + + // Validate required fields + if (!op || !path) { + throw new ApplicationError('Each operation must include "op" and "path".'); + } + + // Construct operation object + const formattedOperation: Record = { op, path }; + + // Add optional fields if they exist + if (from && op === 'move') { + formattedOperation.from = from; + } + if (value !== undefined && op !== 'remove') { + formattedOperation.value = value; + } + + return formattedOperation; + }); + + // Assign the formatted operations to the request body + requestOptions.body = { operations: formattedOperations }; + + return requestOptions; +} diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts index 2ceafa919f..d32a8b12e3 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts @@ -1,5 +1,7 @@ import type { INodeProperties } from 'n8n-workflow'; +import { formatJSONFields } from '../GenericFunctions'; + export const containerOperations: INodeProperties[] = [ { displayName: 'Operation', @@ -17,13 +19,13 @@ export const containerOperations: INodeProperties[] = [ value: 'create', description: 'Create a container', routing: { + send: { + preSend: [formatJSONFields], + }, request: { ignoreHttpStatusErrors: true, method: 'POST', url: '/colls', - headers: { - headers: {}, - }, }, }, action: 'Create container', @@ -75,7 +77,7 @@ export const containerOperations: INodeProperties[] = [ export const createFields: INodeProperties[] = [ { displayName: 'ID', - name: 'id', + name: 'newid', type: 'string', default: '', placeholder: 'e.g. AndersenFamily', @@ -109,13 +111,6 @@ export const createFields: INodeProperties[] = [ operation: ['create'], }, }, - routing: { - send: { - type: 'body', - property: 'partitionKey', - value: '={{$value}}', - }, - }, }, { displayName: 'Additional Fields', @@ -136,13 +131,6 @@ export const createFields: INodeProperties[] = [ placeholder: '"automatic": true, "indexingMode": "Consistent", "includedPaths": [{ "path": "/*", "indexes": [{ "dataType": "String", "precision": -1, "kind": "Range" }]}]', description: 'This value is used to configure indexing policy', - routing: { - send: { - type: 'body', - property: 'indexingPolicy', - value: '={{$value}}', - }, - }, }, { displayName: 'Max RU/s (for Autoscale)', @@ -191,7 +179,7 @@ export const createFields: INodeProperties[] = [ export const getFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -240,7 +228,7 @@ export const getAllFields: INodeProperties[] = []; export const deleteFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index f7d44d86e2..13f5ce611f 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -1,6 +1,11 @@ -import type { IExecuteSingleFunctions, IHttpRequestOptions, INodeProperties } from 'n8n-workflow'; +import type { INodeProperties } from 'n8n-workflow'; -import { handlePagination } from '../GenericFunctions'; +import { + formatCustomProperties, + handlePagination, + validateOperations, + validateQueryParameters, +} from '../GenericFunctions'; export const itemOperations: INodeProperties[] = [ { @@ -19,12 +24,17 @@ export const itemOperations: INodeProperties[] = [ value: 'create', description: 'Create a new item', routing: { + send: { + preSend: [formatCustomProperties], + }, request: { ignoreHttpStatusErrors: true, method: 'POST', url: '=/colls/{{ $parameter["collId"] }}/docs', headers: { - // 'x-ms-documentdb-partitionkey': '={{$parameter["partitionKey"]}}', + // 'x-ms-partitionkey': '=["{{$parameter["newId"]}}"]', + // 'x-ms-documentdb-partitionkey': '=["{{$parameter["newId"]}}"]', + 'x-ms-documentdb-is-upsert': 'True', }, }, }, @@ -48,16 +58,6 @@ export const itemOperations: INodeProperties[] = [ value: 'get', description: 'Retrieve an item', routing: { - send: { - preSend: [ - async function ( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, - ): Promise { - return requestOptions; - }, - ], - }, request: { ignoreHttpStatusErrors: true, method: 'GET', @@ -82,6 +82,16 @@ export const itemOperations: INodeProperties[] = [ method: 'GET', url: '=/colls/{{ $parameter["collId"] }}/docs', }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'json', + }, + }, + ], + }, }, action: 'Get many items', }, @@ -90,6 +100,9 @@ export const itemOperations: INodeProperties[] = [ value: 'query', description: 'Query items', routing: { + send: { + preSend: [validateQueryParameters], + }, request: { ignoreHttpStatusErrors: true, method: 'POST', @@ -107,12 +120,17 @@ export const itemOperations: INodeProperties[] = [ value: 'update', description: 'Update an existing item', routing: { + send: { + preSend: [validateOperations], + }, request: { ignoreHttpStatusErrors: true, method: 'PATCH', url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', headers: { 'Content-Type': 'application/json-patch+json', + 'x-ms-partitionkey': '=["{{$parameter["id"]}}"]', + 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', }, }, }, @@ -170,7 +188,7 @@ export const createFields: INodeProperties[] = [ }, // { // displayName: 'ID', - // name: 'id', + // name: 'newId', // type: 'string', // default: '', // placeholder: 'e.g. AndersenFamily', @@ -260,19 +278,48 @@ export const deleteFields: INodeProperties[] = [ ], }, { - displayName: 'ID', + displayName: 'Item', name: 'id', - type: 'string', - default: '', - placeholder: 'e.g. AndersenFamily', - description: 'Unique ID for the item', + type: 'resourceLocator', required: true, + default: { + mode: 'list', + value: '', + }, + description: "Select the item's ID", displayOptions: { show: { resource: ['item'], operation: ['delete'], }, }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchItems', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'itemName', + type: 'string', + hint: 'Enter the item name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The item name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. AndersenFamily', + }, + ], }, ]; @@ -322,19 +369,48 @@ export const getFields: INodeProperties[] = [ ], }, { - displayName: 'ID', + displayName: 'Item', name: 'id', - type: 'string', - default: '', - placeholder: 'e.g. AndersenFamily', - description: "Item's ID", + type: 'resourceLocator', required: true, + default: { + mode: 'list', + value: '', + }, + description: "Select the item's ID", displayOptions: { show: { resource: ['item'], operation: ['get'], }, }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchItems', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'itemName', + type: 'string', + hint: 'Enter the item name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The item name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. AndersenFamily', + }, + ], }, ]; @@ -474,7 +550,6 @@ export const queryFields: INodeProperties[] = [ type: 'string', default: '', required: true, - description: 'The SQL query text to execute', displayOptions: { show: { resource: ['item'], @@ -532,7 +607,8 @@ export const queryFields: INodeProperties[] = [ send: { type: 'body', property: 'parameters', - value: '={{$value}}', + value: + '={{$parameter["parameters"] && $parameter["parameters"].parameters ? $parameter["parameters"].parameters : []}}', }, }, }, @@ -584,56 +660,110 @@ export const updateFields: INodeProperties[] = [ ], }, { - displayName: 'ID', + displayName: 'Item', name: 'id', - type: 'string', - default: '', - placeholder: 'e.g. AndersenFamily', - description: 'Unique ID for the document', + type: 'resourceLocator', required: true, + default: { + mode: 'list', + value: '', + }, + description: "Select the item's ID", displayOptions: { show: { resource: ['item'], operation: ['update'], }, }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchItems', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'itemName', + type: 'string', + hint: 'Enter the item name', + validation: [ + { + type: 'regex', + properties: { + regex: '^[\\w+=,.@-]+$', + errorMessage: 'The item name must follow the allowed pattern.', + }, + }, + ], + placeholder: 'e.g. AndersenFamily', + }, + ], }, - //TO-DO-check-this { displayName: 'Operations', name: 'operations', - type: 'resourceMapper', - default: { - mappingMode: 'defineBelow', - value: null, - }, + type: 'fixedCollection', + placeholder: 'Add Operation', + description: 'Patch operations to apply to the document', required: true, + default: [], typeOptions: { - resourceMapper: { - resourceMapperMethod: 'getMappingColumns', - mode: 'update', - fieldWords: { - singular: 'operation', - plural: 'operations', - }, - addAllFields: true, - multiKeyMatch: false, - supportAutoMap: true, - matchingFieldsLabels: { - title: 'Custom Matching Operations', - description: 'Define the operations to perform, such as "set", "delete", or "add".', - hint: 'Map input data to the expected structure of the operations array.', - }, - }, + multipleValues: true, }, - description: 'Define the operations to perform, such as setting or updating document fields', displayOptions: { show: { resource: ['item'], operation: ['update'], }, }, - //TO-DO-presend-function + options: [ + { + name: 'operations', + displayName: 'Operation', + values: [ + { + displayName: 'Operation', + name: 'op', + type: 'options', + options: [ + { name: 'Add', value: 'add' }, + { name: 'Increment', value: 'increment' }, + { name: 'Move', value: 'move' }, + { name: 'Remove', value: 'remove' }, + { name: 'Replace', value: 'replace' }, + { name: 'Set', value: 'set' }, + ], + default: 'set', + }, + { + displayName: 'Path', + name: 'path', + type: 'string', + default: '', + placeholder: '/Parents/0/FamilyName', + description: 'The path to the document field to be updated', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'The value to set (if applicable)', + }, + ], + }, + ], + routing: { + send: { + type: 'body', + property: 'operations', + value: '={{ $parameter["operations"].operations }}', + }, + }, }, ]; From 1a51a0fdb37f5465e98fcab923467b7652a1ff33 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Tue, 4 Feb 2025 18:10:38 +0200 Subject: [PATCH 09/19] Extracted documents from response --- .../Microsoft/CosmosDB/GenericFunctions.ts | 31 +++++++++- .../CosmosDB/descriptions/ItemDescription.ts | 57 ++++++++----------- 2 files changed, 54 insertions(+), 34 deletions(-) diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index b4de6a5591..d19f58843c 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -6,6 +6,7 @@ import type { IExecuteSingleFunctions, IHttpRequestOptions, ILoadOptionsFunctions, + IN8nHttpFullResponse, INodeExecutionData, INodeListSearchItems, INodeListSearchResult, @@ -344,6 +345,7 @@ export async function formatCustomProperties( requestOptions: IHttpRequestOptions, ): Promise { const rawCustomProperties = this.getNodeParameter('customProperties', '{}') as string; + const newId = this.getNodeParameter('newId') as string; let parsedProperties: Record; try { @@ -371,7 +373,7 @@ export async function formatCustomProperties( requestOptions.body = {}; } - Object.assign(requestOptions.body as Record, parsedProperties); + Object.assign(requestOptions.body as Record, { id: newId }, parsedProperties); return requestOptions; } @@ -418,6 +420,32 @@ export async function formatJSONFields( return requestOptions; } +export async function processResponse( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (!response || typeof response !== 'object' || !Array.isArray(items)) { + throw new ApplicationError('Invalid response format from Cosmos DB.'); + } + + const extractedDocuments: IDataObject[] = items.flatMap((item) => { + if ( + item.json && + typeof item.json === 'object' && + 'Documents' in item.json && + Array.isArray(item.json.Documents) + ) { + return item.json.Documents as IDataObject[]; + } + + return []; + }); + + return extractedDocuments; +} + +//WIP export async function mapOperationsToRequest( this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions, @@ -458,7 +486,6 @@ export async function mapOperationsToRequest( return formattedOperation; }); - // Assign the formatted operations to the request body requestOptions.body = { operations: formattedOperations }; return requestOptions; diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index 13f5ce611f..868e8e0ce8 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -3,6 +3,7 @@ import type { INodeProperties } from 'n8n-workflow'; import { formatCustomProperties, handlePagination, + processResponse, validateOperations, validateQueryParameters, } from '../GenericFunctions'; @@ -32,8 +33,7 @@ export const itemOperations: INodeProperties[] = [ method: 'POST', url: '=/colls/{{ $parameter["collId"] }}/docs', headers: { - // 'x-ms-partitionkey': '=["{{$parameter["newId"]}}"]', - // 'x-ms-documentdb-partitionkey': '=["{{$parameter["newId"]}}"]', + 'x-ms-documentdb-partitionkey': '=["{{$parameter["newId"]}}"]', 'x-ms-documentdb-is-upsert': 'True', }, }, @@ -83,14 +83,7 @@ export const itemOperations: INodeProperties[] = [ url: '=/colls/{{ $parameter["collId"] }}/docs', }, output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'json', - }, - }, - ], + postReceive: [processResponse], }, }, action: 'Get many items', @@ -186,28 +179,28 @@ export const createFields: INodeProperties[] = [ }, ], }, - // { - // displayName: 'ID', - // name: 'newId', - // type: 'string', - // default: '', - // placeholder: 'e.g. AndersenFamily', - // description: "Item's ID", - // required: true, - // displayOptions: { - // show: { - // resource: ['item'], - // operation: ['create'], - // }, - // }, - // routing: { - // send: { - // type: 'body', - // property: 'id', - // value: '={{$value}}', - // }, - // }, - // }, + { + displayName: 'ID', + name: 'newId', + type: 'string', + default: '', + placeholder: 'e.g. AndersenFamily', + description: "Item's ID", + required: true, + displayOptions: { + show: { + resource: ['item'], + operation: ['create'], + }, + }, + routing: { + send: { + type: 'body', + property: 'id', + value: '={{$value}}', + }, + }, + }, { displayName: 'Custom Properties', name: 'customProperties', From 91e5f4cabf600fde0d478d0baf28786b0d226b82 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Wed, 5 Feb 2025 15:58:27 +0200 Subject: [PATCH 10/19] Fixed get and delete requests for both resources --- ...crosoftCosmosDbSharedKeyApi.credentials.ts | 14 +- .../Microsoft/CosmosDB/GenericFunctions.ts | 90 +++++----- .../descriptions/ContainerDescription.ts | 37 ++-- .../CosmosDB/descriptions/ItemDescription.ts | 161 ++++++++++-------- 4 files changed, 172 insertions(+), 130 deletions(-) diff --git a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index 987fd13a9f..37fea92edf 100644 --- a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -91,11 +91,21 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { if (pathSegments.includes('docs')) { const docsIndex = pathSegments.lastIndexOf('docs'); resourceType = 'docs'; - resourceId = pathSegments.slice(0, docsIndex).join('/'); + if (pathSegments[docsIndex + 1]) { + const docsId = pathSegments[docsIndex + 1]; + resourceId = pathSegments.slice(0, docsIndex).join('/') + `/docs/${docsId}`; + } else { + resourceId = pathSegments.slice(0, docsIndex).join('/'); + } } else if (pathSegments.includes('colls')) { const collsIndex = pathSegments.lastIndexOf('colls'); resourceType = 'colls'; - resourceId = pathSegments.slice(0, collsIndex).join('/'); + if (pathSegments[collsIndex + 1]) { + const collId = pathSegments[collsIndex + 1]; + resourceId = pathSegments.slice(0, collsIndex).join('/') + `/colls/${collId}`; + } else { + resourceId = pathSegments.slice(0, collsIndex).join('/'); + } } else if (pathSegments.includes('dbs')) { const dbsIndex = pathSegments.lastIndexOf('dbs'); resourceType = 'dbs'; diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index d19f58843c..d63f68c7ab 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -260,8 +260,9 @@ export async function searchItems( const results: INodeListSearchItems[] = items .map((item) => { + const idWithoutSpaces = String(item.id).replace(/ /g, ''); return { - name: String(item.id), + name: String(idWithoutSpaces), value: String(item.id), }; }) @@ -347,6 +348,12 @@ export async function formatCustomProperties( const rawCustomProperties = this.getNodeParameter('customProperties', '{}') as string; const newId = this.getNodeParameter('newId') as string; + if (/\s/.test(newId)) { + throw new ApplicationError( + 'Invalid ID: IDs cannot contain spaces. Use an underscore (_) or another separator instead.', + ); + } + let parsedProperties: Record; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -420,7 +427,36 @@ export async function formatJSONFields( return requestOptions; } -export async function processResponse( +export async function validateFields( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; + const indexingPolicy = additionalFields.indexingPolicy; + const manualThroughput = additionalFields.offerThroughput; + const autoscaleThroughput = additionalFields.maxThroughput; + + if (manualThroughput && autoscaleThroughput) { + throw new ApplicationError( + 'You cannot set both "Max RU/s (Autoscale)" and "Max RU/s (Manual Throughput)". Please choose only one.', + ); + } + if (autoscaleThroughput && requestOptions?.qs) { + requestOptions.qs['x-ms-cosmos-offer-autopilot-settings'] = { + maxThroughput: autoscaleThroughput, + }; + } + + if (!indexingPolicy || Object.keys(indexingPolicy).length === 0) { + throw new ApplicationError( + 'Invalid Indexing Policy: Please provide a valid indexingPolicy JSON.', + ); + } + + return requestOptions; +} + +export async function processResponseItems( this: IExecuteSingleFunctions, items: INodeExecutionData[], response: IN8nHttpFullResponse, @@ -445,48 +481,20 @@ export async function processResponse( return extractedDocuments; } -//WIP -export async function mapOperationsToRequest( +export async function processResponseContainers( this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, -): Promise { - const rawOperations = this.getNodeParameter('operations', []) as { - operations: Array<{ - op: string; - path: string; - from?: string; - value?: string | number; - }>; - }; - - if (!rawOperations || !Array.isArray(rawOperations.operations)) { - throw new ApplicationError('Invalid operations format. Expected an array.'); + items: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (!response || typeof response !== 'object' || !Array.isArray(items)) { + throw new ApplicationError('Invalid response format from Cosmos DB.'); } - // Map and validate operations - const formattedOperations = rawOperations.operations.map((operation) => { - const { op, path, from, value } = operation; + const data = response.body as { DocumentCollections: IDataObject[] }; - // Validate required fields - if (!op || !path) { - throw new ApplicationError('Each operation must include "op" and "path".'); - } + if (data.DocumentCollections.length > 0) { + return data.DocumentCollections.map((doc) => ({ json: doc })); + } - // Construct operation object - const formattedOperation: Record = { op, path }; - - // Add optional fields if they exist - if (from && op === 'move') { - formattedOperation.from = from; - } - if (value !== undefined && op !== 'remove') { - formattedOperation.value = value; - } - - return formattedOperation; - }); - - requestOptions.body = { operations: formattedOperations }; - - return requestOptions; + return []; } diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts index d32a8b12e3..330ffb7de4 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts @@ -1,6 +1,6 @@ import type { INodeProperties } from 'n8n-workflow'; -import { formatJSONFields } from '../GenericFunctions'; +import { formatJSONFields, processResponseContainers, validateFields } from '../GenericFunctions'; export const containerOperations: INodeProperties[] = [ { @@ -20,7 +20,7 @@ export const containerOperations: INodeProperties[] = [ description: 'Create a container', routing: { send: { - preSend: [formatJSONFields], + preSend: [formatJSONFields, validateFields], }, request: { ignoreHttpStatusErrors: true, @@ -40,6 +40,16 @@ export const containerOperations: INodeProperties[] = [ method: 'DELETE', url: '=/colls/{{ $parameter["collId"] }}', }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', + }, + }, + ], + }, }, action: 'Delete container', }, @@ -66,6 +76,9 @@ export const containerOperations: INodeProperties[] = [ method: 'GET', url: '/colls', }, + output: { + postReceive: [processResponseContainers], + }, }, action: 'Get many containers', }, @@ -136,20 +149,11 @@ export const createFields: INodeProperties[] = [ displayName: 'Max RU/s (for Autoscale)', name: 'maxThroughput', type: 'number', + typeOptions: { + minValue: 1000, + }, default: 1000, description: 'The user specified autoscale max RU/s', - displayOptions: { - show: { - offerThroughput: [undefined], - }, - }, - routing: { - send: { - type: 'query', - property: 'x-ms-cosmos-offer-autopilot-settings', - value: '={{"{"maxThroughput": " + $value + "}"}', - }, - }, }, { displayName: 'Max RU/s (for Manual Throughput)', @@ -158,11 +162,6 @@ export const createFields: INodeProperties[] = [ default: 400, description: 'The user specified manual throughput (RU/s) for the collection expressed in units of 100 request units per second', - displayOptions: { - show: { - maxThroughput: [undefined], - }, - }, routing: { send: { type: 'query', diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index 868e8e0ce8..89e3639f10 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -3,7 +3,7 @@ import type { INodeProperties } from 'n8n-workflow'; import { formatCustomProperties, handlePagination, - processResponse, + processResponseItems, validateOperations, validateQueryParameters, } from '../GenericFunctions'; @@ -32,6 +32,7 @@ export const itemOperations: INodeProperties[] = [ ignoreHttpStatusErrors: true, method: 'POST', url: '=/colls/{{ $parameter["collId"] }}/docs', + //To-Do-do it based on the partition key of collection and only one headers: { 'x-ms-documentdb-partitionkey': '=["{{$parameter["newId"]}}"]', 'x-ms-documentdb-is-upsert': 'True', @@ -49,6 +50,20 @@ export const itemOperations: INodeProperties[] = [ ignoreHttpStatusErrors: true, method: 'DELETE', url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', + //To-Do-do it based on the partition key of collection and only one + headers: { + 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', + }, + }, + output: { + postReceive: [ + { + type: 'set', + properties: { + value: '={{ { "success": true } }}', + }, + }, + ], }, }, action: 'Delete item', @@ -61,7 +76,12 @@ export const itemOperations: INodeProperties[] = [ request: { ignoreHttpStatusErrors: true, method: 'GET', - url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', + url: '=/colls/{{ $parameter["collId"]}}/docs/{{$parameter["id"]}}', + headers: { + //To-Do-do it based on the partition key of collection and only one + 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', + 'x-ms-documentdb-is-upsert': 'True', + }, }, }, action: 'Get item', @@ -83,7 +103,7 @@ export const itemOperations: INodeProperties[] = [ url: '=/colls/{{ $parameter["collId"] }}/docs', }, output: { - postReceive: [processResponse], + postReceive: [processResponseItems], }, }, action: 'Get many items', @@ -136,7 +156,7 @@ export const itemOperations: INodeProperties[] = [ export const createFields: INodeProperties[] = [ { - displayName: 'Collection ID', + displayName: 'Container ID', name: 'collId', type: 'resourceLocator', required: true, @@ -144,7 +164,7 @@ export const createFields: INodeProperties[] = [ mode: 'list', value: '', }, - description: 'Select the collection you want to use', + description: 'Select the container you want to use', displayOptions: { show: { resource: ['item'], @@ -162,20 +182,20 @@ export const createFields: INodeProperties[] = [ }, }, { - displayName: 'By Name', - name: 'collectionName', + displayName: 'By ID', + name: 'containerId', type: 'string', - hint: 'Enter the collection name', + hint: 'Enter the container ID', validation: [ { type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The collection name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], - placeholder: 'e.g. UsersCollection', + placeholder: 'e.g. UsersContainer', }, ], }, @@ -215,19 +235,12 @@ export const createFields: INodeProperties[] = [ operation: ['create'], }, }, - //To-Do-add preSend function - routing: { - send: { - type: 'body', - value: '={{$value}}', - }, - }, }, ]; export const deleteFields: INodeProperties[] = [ { - displayName: 'Collection ID', + displayName: 'Container ID', name: 'collId', type: 'resourceLocator', required: true, @@ -235,7 +248,7 @@ export const deleteFields: INodeProperties[] = [ mode: 'list', value: '', }, - description: 'Select the collection you want to use', + description: 'Select the container you want to use', displayOptions: { show: { resource: ['item'], @@ -253,20 +266,20 @@ export const deleteFields: INodeProperties[] = [ }, }, { - displayName: 'By Name', - name: 'collectionName', + displayName: 'By ID', + name: 'containerId', type: 'string', - hint: 'Enter the collection name', + hint: 'Enter the container id', validation: [ { type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The collection name must follow the allowed pattern.', + errorMessage: 'The container name must follow the allowed pattern.', }, }, ], - placeholder: 'e.g. UsersCollection', + placeholder: 'e.g. UsersContainer', }, ], }, @@ -297,16 +310,16 @@ export const deleteFields: INodeProperties[] = [ }, }, { - displayName: 'By Name', - name: 'itemName', + displayName: 'By ID', + name: 'itemId', type: 'string', - hint: 'Enter the item name', + hint: 'Enter the item id', validation: [ { type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The item name must follow the allowed pattern.', + errorMessage: 'The item id must follow the allowed pattern.', }, }, ], @@ -318,7 +331,7 @@ export const deleteFields: INodeProperties[] = [ export const getFields: INodeProperties[] = [ { - displayName: 'Collection ID', + displayName: 'Container ID', name: 'collId', type: 'resourceLocator', required: true, @@ -326,7 +339,7 @@ export const getFields: INodeProperties[] = [ mode: 'list', value: '', }, - description: 'Select the collection you want to use', + description: 'Select the container you want to use', displayOptions: { show: { resource: ['item'], @@ -344,20 +357,20 @@ export const getFields: INodeProperties[] = [ }, }, { - displayName: 'By Name', - name: 'collectionName', + displayName: 'By ID', + name: 'containerId', type: 'string', - hint: 'Enter the collection name', + hint: 'Enter the container id', validation: [ { type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The collection name must follow the allowed pattern.', + errorMessage: 'The container name must follow the allowed pattern.', }, }, ], - placeholder: 'e.g. UsersCollection', + placeholder: 'e.g. UsersContainer', }, ], }, @@ -388,16 +401,16 @@ export const getFields: INodeProperties[] = [ }, }, { - displayName: 'By Name', - name: 'itemName', + displayName: 'By ID', + name: 'itemId', type: 'string', - hint: 'Enter the item name', + hint: 'Enter the item id', validation: [ { type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The item name must follow the allowed pattern.', + errorMessage: 'The item id must follow the allowed pattern.', }, }, ], @@ -409,7 +422,7 @@ export const getFields: INodeProperties[] = [ export const getAllFields: INodeProperties[] = [ { - displayName: 'Collection ID', + displayName: 'Container ID', name: 'collId', type: 'resourceLocator', required: true, @@ -417,7 +430,7 @@ export const getAllFields: INodeProperties[] = [ mode: 'list', value: '', }, - description: 'Select the collection you want to use', + description: 'Select the container you want to use', displayOptions: { show: { resource: ['item'], @@ -435,20 +448,20 @@ export const getAllFields: INodeProperties[] = [ }, }, { - displayName: 'By Name', - name: 'collectionName', + displayName: 'By ID', + name: 'containerId', type: 'string', - hint: 'Enter the collection name', + hint: 'Enter the container id', validation: [ { type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The collection name must follow the allowed pattern.', + errorMessage: 'The container name must follow the allowed pattern.', }, }, ], - placeholder: 'e.g. UsersCollection', + placeholder: 'e.g. UsersContainer', }, ], }, @@ -494,7 +507,7 @@ export const getAllFields: INodeProperties[] = [ export const queryFields: INodeProperties[] = [ { - displayName: 'Collection ID', + displayName: 'Container ID', name: 'collId', type: 'resourceLocator', required: true, @@ -502,7 +515,7 @@ export const queryFields: INodeProperties[] = [ mode: 'list', value: '', }, - description: 'Select the collection you want to use', + description: 'Select the container you want to use', displayOptions: { show: { resource: ['item'], @@ -520,20 +533,20 @@ export const queryFields: INodeProperties[] = [ }, }, { - displayName: 'By Name', - name: 'collectionName', + displayName: 'By ID', + name: 'containerId', type: 'string', - hint: 'Enter the collection name', + hint: 'Enter the container id', validation: [ { type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The collection name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], - placeholder: 'e.g. UsersCollection', + placeholder: 'e.g. UsersContainer', }, ], }, @@ -609,7 +622,7 @@ export const queryFields: INodeProperties[] = [ export const updateFields: INodeProperties[] = [ { - displayName: 'Collection ID', + displayName: 'Container ID', name: 'collId', type: 'resourceLocator', required: true, @@ -617,7 +630,7 @@ export const updateFields: INodeProperties[] = [ mode: 'list', value: '', }, - description: 'Select the collection you want to use', + description: 'Select the container you want to use', displayOptions: { show: { resource: ['item'], @@ -635,20 +648,20 @@ export const updateFields: INodeProperties[] = [ }, }, { - displayName: 'By Name', - name: 'collectionName', + displayName: 'By ID', + name: 'containerId', type: 'string', - hint: 'Enter the collection name', + hint: 'Enter the container id', validation: [ { type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The collection name must follow the allowed pattern.', + errorMessage: 'The container name must follow the allowed pattern.', }, }, ], - placeholder: 'e.g. UsersCollection', + placeholder: 'e.g. UsersContainer', }, ], }, @@ -679,16 +692,16 @@ export const updateFields: INodeProperties[] = [ }, }, { - displayName: 'By Name', - name: 'itemName', + displayName: 'By ID', + name: 'itemId', type: 'string', - hint: 'Enter the item name', + hint: 'Enter the item id', validation: [ { type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The item name must follow the allowed pattern.', + errorMessage: 'The item id must follow the allowed pattern.', }, }, ], @@ -727,25 +740,37 @@ export const updateFields: INodeProperties[] = [ { name: 'Increment', value: 'increment' }, { name: 'Move', value: 'move' }, { name: 'Remove', value: 'remove' }, - { name: 'Replace', value: 'replace' }, { name: 'Set', value: 'set' }, ], default: 'set', }, + { + displayName: 'From', + name: 'from', + type: 'string', + default: '', + displayOptions: { + show: { + op: ['move'], + }, + }, + }, { displayName: 'Path', name: 'path', type: 'string', default: '', - placeholder: '/Parents/0/FamilyName', - description: 'The path to the document field to be updated', }, { displayName: 'Value', name: 'value', type: 'string', default: '', - description: 'The value to set (if applicable)', + displayOptions: { + show: { + op: ['add', 'set', 'increment'], + }, + }, }, ], }, From 396f7c1b8ecb8b24bbbb66151304317778a1a692 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Wed, 5 Feb 2025 16:08:56 +0200 Subject: [PATCH 11/19] Added new tests --- .../CosmosDB/test/HandlePagination.test.ts | 116 ---------------- .../test/MicrosoftCosmosDbRequest.test.ts | 7 +- .../CosmosDB/test/SearchCollections.test.ts | 16 ++- .../CosmosDB/test/SearchItems.test.ts | 124 ++++++++++++++++++ 4 files changed, 139 insertions(+), 124 deletions(-) delete mode 100644 packages/nodes-base/nodes/Microsoft/CosmosDB/test/HandlePagination.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/HandlePagination.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/HandlePagination.test.ts deleted file mode 100644 index 0952ae09d1..0000000000 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/HandlePagination.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { handlePagination } from '../GenericFunctions'; - -describe('GenericFunctions - handlePagination', () => { - let mockContext: any; - let mockMakeRoutingRequest: jest.Mock; - let resultOptions: any; - - beforeEach(() => { - mockMakeRoutingRequest = jest.fn(); - mockContext = { - makeRoutingRequest: mockMakeRoutingRequest, - getNodeParameter: jest.fn(), - }; - - resultOptions = { - maxResults: 60, - options: { body: {} }, - }; - }); - - test('should aggregate results and handle pagination when returnAll is true', async () => { - mockMakeRoutingRequest - .mockResolvedValueOnce([ - { id: 1 }, - { id: 2 }, - { headers: { 'x-ms-continuation': 'token-1' } }, - ]) - .mockResolvedValueOnce([{ id: 3 }, { id: 4 }, { headers: {} }]); - - mockContext.getNodeParameter.mockImplementation((param: string) => { - if (param === 'returnAll') return true; - return undefined; - }); - - const result = await handlePagination.call(mockContext, resultOptions); - - expect(result).toEqual([ - { json: { id: 1 } }, - { json: { id: 2 } }, - { json: { id: 3 } }, - { json: { id: 4 } }, - ]); - - expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2); - expect(resultOptions.options.headers).toEqual({ - 'x-ms-continuation': 'token-1', - }); - }); - - test('should stop pagination after reaching limit when returnAll is false', async () => { - mockMakeRoutingRequest - .mockResolvedValueOnce([ - { id: 1 }, - { id: 2 }, - { headers: { 'x-ms-continuation': 'token-1' } }, - ]) - .mockResolvedValueOnce([{ id: 3 }, { id: 4 }, { headers: {} }]); - - mockContext.getNodeParameter.mockImplementation((param: string) => { - if (param === 'returnAll') return false; - if (param === 'limit') return 3; - return undefined; - }); - - const result = await handlePagination.call(mockContext, resultOptions); - - expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }, { json: { id: 3 } }]); - - expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2); - }); - - test('should handle cases with no continuation token gracefully', async () => { - mockMakeRoutingRequest.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); - - mockContext.getNodeParameter.mockImplementation((param: string) => { - if (param === 'returnAll') return true; - return undefined; - }); - - const result = await handlePagination.call(mockContext, resultOptions); - - expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }]); - - expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1); - }); - - test('should respect the limit even if fewer results are available', async () => { - mockMakeRoutingRequest.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); - - mockContext.getNodeParameter.mockImplementation((param: string) => { - if (param === 'returnAll') return false; - if (param === 'limit') return 5; - return undefined; - }); - - const result = await handlePagination.call(mockContext, resultOptions); - - expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }]); - - expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1); - }); - - test('should break the loop if no results are returned', async () => { - mockMakeRoutingRequest.mockResolvedValueOnce([]); - - mockContext.getNodeParameter.mockImplementation((param: string) => { - if (param === 'returnAll') return true; - return undefined; - }); - - const result = await handlePagination.call(mockContext, resultOptions); - - expect(result).toEqual([]); - expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts index 5042bf4a20..523cfea187 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts @@ -16,12 +16,11 @@ describe('GenericFunctions - microsoftCosmosDbRequest', () => { test('should make a successful request with correct options', async () => { mockRequestWithAuthentication.mockResolvedValueOnce({ success: true }); - (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', database: 'first_database_1', - }); - (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ - baseUrl: 'https://us-east-1.documents.azure.com/dbs', + baseUrl: 'https://us-east-1.documents.azure.com/dbs/first_database_1', }); const requestOptions = { diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts index abdfacd3ee..7e75d847bb 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts @@ -18,7 +18,11 @@ describe('GenericFunctions - searchCollections', () => { }); it('should make a GET request to fetch collections and return results', async () => { - (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'first_database_1', + baseUrl: 'https://us-east-1.documents.azure.com', + }); mockRequestWithAuthentication.mockResolvedValueOnce({ DocumentCollections: [{ id: 'Collection1' }, { id: 'Collection2' }], @@ -27,7 +31,7 @@ describe('GenericFunctions - searchCollections', () => { const response = await searchCollections.call(mockContext); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( - 'azureCosmosDbSharedKeyApi', + 'microsoftCosmosDbSharedKeyApi', expect.objectContaining({ baseURL: 'https://us-east-1.documents.azure.com', method: 'GET', @@ -49,7 +53,11 @@ describe('GenericFunctions - searchCollections', () => { }); it('should filter collections by the provided filter string', async () => { - (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'first_database_1', + baseUrl: 'https://us-east-1.documents.azure.com', + }); mockRequestWithAuthentication.mockResolvedValueOnce({ DocumentCollections: [{ id: 'Test-Col-1' }, { id: 'Prod-Col-1' }], @@ -58,7 +66,7 @@ describe('GenericFunctions - searchCollections', () => { const response = await searchCollections.call(mockContext, 'Test'); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( - 'azureCosmosDbSharedKeyApi', + 'microsoftCosmosDbSharedKeyApi', expect.objectContaining({ baseURL: 'https://us-east-1.documents.azure.com', method: 'GET', diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts new file mode 100644 index 0000000000..e1d4510275 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts @@ -0,0 +1,124 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchItems } from '../GenericFunctions'; + +describe('GenericFunctions - searchItems', () => { + const mockRequestWithAuthentication = jest.fn(); + + const mockContext = { + helpers: { + requestWithAuthentication: mockRequestWithAuthentication, + }, + getNodeParameter: jest.fn(), + getCredentials: jest.fn(), + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch documents and return formatted results', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'first_database_1', + baseUrl: 'https://us-east-1.documents.azure.com', + }); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + Documents: [{ id: 'Item 1' }, { id: 'Item 2' }], + }); + + const response = await searchItems.call(mockContext); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'microsoftCosmosDbSharedKeyApi', + expect.objectContaining({ + method: 'GET', + url: '/colls/coll-1/docs', + }), + ); + + expect(response).toEqual({ + results: [ + { name: 'Item1', value: 'Item 1' }, // Space removed from 'Item 1' + { name: 'Item2', value: 'Item 2' }, + ], + }); + }); + + it('should filter results based on the provided filter string', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'first_database_1', + baseUrl: 'https://us-east-1.documents.azure.com', + }); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + Documents: [{ id: 'TestItem' }, { id: 'ProdItem' }], + }); + + const response = await searchItems.call(mockContext, 'Test'); + + expect(response).toEqual({ + results: [{ name: 'TestItem', value: 'TestItem' }], + }); + }); + + it('should return an empty array if no documents are found', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'first_database_1', + baseUrl: 'https://us-east-1.documents.azure.com', + }); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + Documents: [], + }); + + const response = await searchItems.call(mockContext); + + expect(response).toEqual({ results: [] }); + }); + + it('should handle missing Documents property gracefully', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'first_database_1', + baseUrl: 'https://us-east-1.documents.azure.com', + }); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + unexpectedKey: 'value', + }); + + const response = await searchItems.call(mockContext); + + expect(response).toEqual({ results: [] }); + }); + + it('should throw an error when collection ID is missing', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ mode: 'list', value: '' }); + + await expect(searchItems.call(mockContext)).rejects.toThrow('Collection ID is required.'); + }); +}); From def6634a5b133e1cb4a9a7998138ae3963127d9d Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Wed, 5 Feb 2025 16:32:08 +0200 Subject: [PATCH 12/19] Fixed one auth method --- ...crosoftCosmosDbSharedKeyApi.credentials.ts | 2 +- .../nodes/Microsoft/CosmosDB/CosmosDb.node.ts | 26 ++----------------- .../Microsoft/CosmosDB/GenericFunctions.ts | 10 ------- 3 files changed, 3 insertions(+), 35 deletions(-) diff --git a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index 37fea92edf..4518abaaa4 100644 --- a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -14,7 +14,7 @@ import { export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { name = 'microsoftCosmosDbSharedKeyApi'; - displayName = 'Azure Cosmos DB API'; + displayName = 'Cosmos DB API'; documentationUrl = 'microsoftCosmosDb'; diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts index 028374ef9a..a0fdf917ed 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts @@ -16,9 +16,9 @@ export class CosmosDb implements INodeType { group: ['transform'], version: 1, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Interact with Azure Cosmos DB API', + description: 'Interact with Cosmos DB API', defaults: { - name: 'Azure Cosmos Db', + name: 'Cosmos Db', }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], @@ -26,11 +26,6 @@ export class CosmosDb implements INodeType { { name: 'microsoftCosmosDbSharedKeyApi', required: true, - displayOptions: { - show: { - authentication: ['sharedKey'], - }, - }, }, ], requestDefaults: { @@ -41,28 +36,11 @@ export class CosmosDb implements INodeType { }, }, properties: [ - { - displayName: 'Authentication', - name: 'authentication', - type: 'options', - options: [ - { - name: 'Shared Key', - value: 'sharedKey', - }, - ], - default: 'sharedKey', - }, { displayName: 'Resource', name: 'resource', type: 'options', noDataExpression: true, - routing: { - // send: { - // preSend: [presendStringifyBody], - // }, - }, options: [ { name: 'Container', diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index d63f68c7ab..e32bc036da 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -63,16 +63,6 @@ export function getAuthorizationTokenUsingMasterKey( return `type=master&ver=1.0&sig=${signature}`; } -export async function presendStringifyBody( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, -): Promise { - if (requestOptions.body) { - requestOptions.body = JSON.stringify(requestOptions.body); - } - return requestOptions; -} - export async function handlePagination( this: IExecutePaginationFunctions, resultOptions: DeclarativeRestApiSettings.ResultOptions, From 6f0c98289b819ce489546919804e0b7c100b7fc2 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Wed, 5 Feb 2025 16:48:55 +0200 Subject: [PATCH 13/19] Fixed 'update' action request --- .../credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts | 4 ---- .../nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts | 1 - 2 files changed, 5 deletions(-) diff --git a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index 4518abaaa4..6445158366 100644 --- a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -55,7 +55,6 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { credentials: ICredentialDataDecryptedObject, requestOptions: IHttpRequestOptions, ): Promise { - // Remove undefined query parameters? if (requestOptions.qs) { for (const [key, value] of Object.entries(requestOptions.qs)) { if (value === undefined) { @@ -64,7 +63,6 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { } } - // Add headers for date and version requestOptions.headers ??= {}; const date = new Date().toUTCString().toLowerCase(); requestOptions.headers = { @@ -77,8 +75,6 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { requestOptions.headers['x-ms-session-token'] = credentials.sessionToken; } - // This shouldn't be the full url - // Refer to https://stackoverflow.com/questions/45645389/documentdb-rest-api-authorization-token-error // const url = new URL (requestOptions.uri); const url = new URL(requestOptions.baseURL + requestOptions.url); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index 89e3639f10..aff7def5ec 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -142,7 +142,6 @@ export const itemOperations: INodeProperties[] = [ url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', headers: { 'Content-Type': 'application/json-patch+json', - 'x-ms-partitionkey': '=["{{$parameter["id"]}}"]', 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', }, }, From 5780596cb14f4640dcbc10921665dec736df9f77 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Thu, 6 Feb 2025 13:29:03 +0200 Subject: [PATCH 14/19] Worked on 'update' action --- .../nodes/Microsoft/CosmosDB/CosmosDb.node.ts | 3 +- .../Microsoft/CosmosDB/GenericFunctions.ts | 180 ++++++++++++++++-- .../CosmosDB/descriptions/ItemDescription.ts | 130 ++++++++++--- 3 files changed, 273 insertions(+), 40 deletions(-) diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts index a0fdf917ed..f34561a96a 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts @@ -3,7 +3,7 @@ import { NodeConnectionType } from 'n8n-workflow'; import { containerFields, containerOperations } from './descriptions/ContainerDescription'; import { itemFields, itemOperations } from './descriptions/ItemDescription'; -import { searchCollections, searchItems } from './GenericFunctions'; +import { getDynamicFields, searchCollections, searchItems } from './GenericFunctions'; export class CosmosDb implements INodeType { description: INodeTypeDescription = { @@ -64,6 +64,7 @@ export class CosmosDb implements INodeType { listSearch: { searchCollections, searchItems, + getDynamicFields, }, }; } diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index e32bc036da..f22a1c342a 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -118,6 +118,41 @@ export async function handlePagination( return aggregatedResult.map((result) => ({ json: result })); } +export async function handleErrorPostReceive( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { + const responseBody = response.body as IDataObject; + + let errorMessage = 'Unknown error occurred'; + + if (typeof responseBody.message === 'string') { + try { + const jsonMatch = responseBody.message.match(/Message: (\{.*\})/); + + if (jsonMatch && jsonMatch[1]) { + const parsedMessage = JSON.parse(jsonMatch[1]); + + if ( + parsedMessage.Errors && + Array.isArray(parsedMessage.Errors) && + parsedMessage.Errors.length > 0 + ) { + errorMessage = parsedMessage.Errors[0].split(' Learn more:')[0].trim(); + } + } + } catch (error) { + errorMessage = 'Failed to extract error message'; + } + } + + throw new ApplicationError(errorMessage); + } + return data; +} + export async function microsoftCosmosDbRequest( this: ILoadOptionsFunctions, opts: IHttpRequestOptions, @@ -133,6 +168,7 @@ export async function microsoftCosmosDbRequest( ...opts, baseURL: `${credentials.baseUrl}`, headers: { + ...opts.headers, Accept: 'application/json', 'Content-Type': 'application/json', }, @@ -298,35 +334,69 @@ export async function validateOperations( requestOptions: IHttpRequestOptions, ): Promise { const rawOperations = this.getNodeParameter('operations', []) as IDataObject; - console.log('Operations', rawOperations); + if (!rawOperations || !Array.isArray(rawOperations.operations)) { throw new ApplicationError('The "operations" field must contain at least one operation.'); } const operations = rawOperations.operations as Array<{ op: string; - path: string; - value?: string; + path?: { mode: string; value: string }; + toPath?: { mode: string; value: string }; + from?: { mode: string; value: string }; + value?: string | number; }>; - for (const operation of operations) { - if (!['add', 'increment', 'move', 'remove', 'replace', 'set'].includes(operation.op)) { - throw new ApplicationError( - `Invalid operation type "${operation.op}". Allowed values are "add", "increment", "move", "remove", "replace", and "set".`, - ); - } - - if (!operation.path || operation.path.trim() === '') { + const transformedOperations = operations.map((operation) => { + if ( + operation.op !== 'move' && + (!operation.path?.value || + typeof operation.path.value !== 'string' || + operation.path.value.trim() === '') + ) { throw new ApplicationError('Each operation must have a valid "path".'); } if ( - ['set', 'replace', 'add', 'increment'].includes(operation.op) && + ['set', 'replace', 'add', 'incr'].includes(operation.op) && (operation.value === undefined || operation.value === null) ) { - throw new ApplicationError(`The operation "${operation.op}" must include a valid "value".`); + throw new ApplicationError(`The "${operation.op}" operation must include a valid "value".`); } - } + + if (operation.op === 'move') { + if ( + !operation.from?.value || + typeof operation.from.value !== 'string' || + operation.from.value.trim() === '' + ) { + throw new ApplicationError('The "move" operation must have a valid "from" path.'); + } + + if ( + !operation.toPath?.value || + typeof operation.toPath.value !== 'string' || + operation.toPath.value.trim() === '' + ) { + throw new ApplicationError('The "move" operation must have a valid "toPath".'); + } + } + + if (operation.op === 'incr' && isNaN(Number(operation.value))) { + throw new ApplicationError('The "increment" operation must have a numeric value.'); + } + + return { + op: operation.op, + path: operation.op === 'move' ? operation.toPath?.value : operation.path?.value, + ...(operation.from ? { from: operation.from.value } : {}), + ...(operation.op === 'incr' + ? { value: Number(operation.value) } + : { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) }), + }; + }); + + requestOptions.body = transformedOperations; return requestOptions; } @@ -488,3 +558,85 @@ export async function processResponseContainers( return []; } + +function extractFieldPaths(obj: any, prefix = ''): string[] { + let paths: string[] = []; + + Object.entries(obj).forEach(([key, value]) => { + if (key.startsWith('_') || key === 'id') { + return; + } + const newPath = prefix ? `${prefix}/${key}` : `/${key}`; + if (Array.isArray(value) && value.length > 0) { + value.forEach((item, index) => { + if (typeof item === 'object' && item !== null) { + paths = paths.concat(extractFieldPaths(item, `${newPath}/${index}`)); + } else { + paths.push(`${newPath}/${index}`); + } + }); + } else if (typeof value === 'object' && value !== null) { + paths = paths.concat(extractFieldPaths(value, newPath)); + } else { + paths.push(newPath); + } + }); + + return paths; +} + +export async function searchItemById( + this: ILoadOptionsFunctions, + itemId: string, +): Promise { + const collection = this.getNodeParameter('collId') as { mode: string; value: string }; + + if (!collection?.value) { + throw new ApplicationError('Collection ID is required.'); + } + + if (!itemId) { + throw new ApplicationError('Item ID is required.'); + } + + const opts: IHttpRequestOptions = { + method: 'GET', + url: `/colls/${collection.value}/docs/${itemId}`, + headers: { + 'x-ms-documentdb-partitionkey': `["${itemId}"]`, + }, + }; + + const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); + + if (!responseData) { + return null; + } + + return responseData; +} + +export async function getDynamicFields( + this: ILoadOptionsFunctions, +): Promise { + const itemId = this.getNodeParameter('id', '') as { mode: string; value: string }; + + if (!itemId) { + throw new ApplicationError('Item ID is required to fetch fields.'); + } + + const itemData = await searchItemById.call(this, itemId.value); + + if (!itemData) { + throw new ApplicationError(`Item with ID "${itemId.value}" not found.`); + } + + const fieldPaths = extractFieldPaths(itemData); + + return { + results: fieldPaths.map((path) => ({ + name: path, + value: path, + })), + }; +} diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index aff7def5ec..140c1d9196 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -2,6 +2,7 @@ import type { INodeProperties } from 'n8n-workflow'; import { formatCustomProperties, + handleErrorPostReceive, handlePagination, processResponseItems, validateOperations, @@ -145,6 +146,9 @@ export const itemOperations: INodeProperties[] = [ 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', }, }, + output: { + postReceive: [handleErrorPostReceive], + }, }, action: 'Update item', }, @@ -155,7 +159,7 @@ export const itemOperations: INodeProperties[] = [ export const createFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -184,7 +188,7 @@ export const createFields: INodeProperties[] = [ displayName: 'By ID', name: 'containerId', type: 'string', - hint: 'Enter the container ID', + hint: 'Enter the container Id', validation: [ { type: 'regex', @@ -239,7 +243,7 @@ export const createFields: INodeProperties[] = [ export const deleteFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -274,7 +278,7 @@ export const deleteFields: INodeProperties[] = [ type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The container name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], @@ -330,7 +334,7 @@ export const deleteFields: INodeProperties[] = [ export const getFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -365,7 +369,7 @@ export const getFields: INodeProperties[] = [ type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The container name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], @@ -421,7 +425,7 @@ export const getFields: INodeProperties[] = [ export const getAllFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -456,7 +460,7 @@ export const getAllFields: INodeProperties[] = [ type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The container name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], @@ -506,7 +510,7 @@ export const getAllFields: INodeProperties[] = [ export const queryFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -621,7 +625,7 @@ export const queryFields: INodeProperties[] = [ export const updateFields: INodeProperties[] = [ { - displayName: 'Container ID', + displayName: 'Container', name: 'collId', type: 'resourceLocator', required: true, @@ -656,7 +660,7 @@ export const updateFields: INodeProperties[] = [ type: 'regex', properties: { regex: '^[\\w+=,.@-]+$', - errorMessage: 'The container name must follow the allowed pattern.', + errorMessage: 'The container id must follow the allowed pattern.', }, }, ], @@ -736,29 +740,112 @@ export const updateFields: INodeProperties[] = [ type: 'options', options: [ { name: 'Add', value: 'add' }, - { name: 'Increment', value: 'increment' }, + { name: 'Increment', value: 'incr' }, { name: 'Move', value: 'move' }, { name: 'Remove', value: 'remove' }, + { name: 'Replace', value: 'replace' }, { name: 'Set', value: 'set' }, ], default: 'set', }, { - displayName: 'From', + displayName: 'From Path', name: 'from', - type: 'string', - default: '', + type: 'resourceLocator', + description: 'Select a field from the list or enter it manually', displayOptions: { show: { op: ['move'], }, }, + default: { + mode: 'list', + value: '', + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getDynamicFields', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'manual', + type: 'string', + hint: 'Enter the field name manually', + placeholder: 'e.g. /Parents/0/FamilyName', + }, + ], + }, + { + displayName: 'To Path', + name: 'toPath', + type: 'resourceLocator', + description: 'Select a field from the list or enter it manually', + displayOptions: { + show: { + op: ['move'], + }, + }, + default: { + mode: 'list', + value: '', + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getDynamicFields', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'manual', + type: 'string', + hint: 'Enter the field name manually', + placeholder: 'e.g. /Parents/0/FamilyName', + }, + ], }, { displayName: 'Path', name: 'path', - type: 'string', - default: '', + type: 'resourceLocator', + description: 'Select a field from the list or enter it manually', + default: { + mode: 'list', + value: '', + }, + displayOptions: { + show: { + op: ['add', 'remove', 'set', 'incr', 'replace'], + }, + }, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'getDynamicFields', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'manual', + type: 'string', + hint: 'Enter the field name manually', + placeholder: 'e.g. /Parents/0/FamilyName', + }, + ], }, { displayName: 'Value', @@ -767,20 +854,13 @@ export const updateFields: INodeProperties[] = [ default: '', displayOptions: { show: { - op: ['add', 'set', 'increment'], + op: ['add', 'set', 'replace', 'incr'], }, }, }, ], }, ], - routing: { - send: { - type: 'body', - property: 'operations', - value: '={{ $parameter["operations"].operations }}', - }, - }, }, ]; From 81ce5c69d6377a43f11cbf60831cb4683b0a8b5e Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Thu, 6 Feb 2025 20:11:01 +0200 Subject: [PATCH 15/19] Worked on validation and errors --- ...crosoftCosmosDbSharedKeyApi.credentials.ts | 1 - .../Microsoft/CosmosDB/GenericFunctions.ts | 160 +++++++++++++++++- .../CosmosDB/descriptions/ItemDescription.ts | 104 ++++++++++-- 3 files changed, 244 insertions(+), 21 deletions(-) diff --git a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index 6445158366..a4a429b67f 100644 --- a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -79,7 +79,6 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { const url = new URL(requestOptions.baseURL + requestOptions.url); const pathSegments = url.pathname.split('/').filter((segment) => segment); - console.log('Filtered Path Segments:', pathSegments); let resourceType = ''; let resourceId = ''; diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index f22a1c342a..50dec6fbb8 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -11,7 +11,7 @@ import type { INodeListSearchItems, INodeListSearchResult, } from 'n8n-workflow'; -import { ApplicationError } from 'n8n-workflow'; +import { ApplicationError, NodeApiError } from 'n8n-workflow'; export const HeaderConstants = { // Required @@ -123,9 +123,11 @@ export async function handleErrorPostReceive( data: INodeExecutionData[], response: IN8nHttpFullResponse, ): Promise { + console.log('Status code❌', response.statusCode); + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { const responseBody = response.body as IDataObject; - + console.log('Got here ❌', responseBody); let errorMessage = 'Unknown error occurred'; if (typeof responseBody.message === 'string') { @@ -386,6 +388,7 @@ export async function validateOperations( throw new ApplicationError('The "increment" operation must have a numeric value.'); } + //To-Do-check to not send properties it doesn't need return { op: operation.op, path: operation.op === 'move' ? operation.toPath?.value : operation.path?.value, @@ -640,3 +643,156 @@ export async function getDynamicFields( })), }; } + +export async function fetchPartitionKeyField( + this: ILoadOptionsFunctions, +): Promise { + const collection = this.getNodeParameter('collId', '') as { mode: string; value: string }; + + if (!collection?.value) { + throw new ApplicationError('Collection ID is required to determine the partition key.'); + } + + const opts: IHttpRequestOptions = { + method: 'GET', + url: `/colls/${collection.value}`, + }; + + const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); + + const partitionKey = responseData.partitionKey as + | { + paths: string[]; + kind: string; + version: number; + } + | undefined; + + const partitionKeyPaths = partitionKey?.paths ?? []; + + if (partitionKeyPaths.length === 0) { + return { results: [] }; + } + + const partitionKeyField = partitionKeyPaths[0].replace('/', ''); + + return { + results: [ + { + name: partitionKeyField, + value: partitionKeyField, + }, + ], + }; +} + +export async function validatePartitionKey( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const operation = this.getNodeParameter('operation') as string; + const customProperties = this.getNodeParameter('customProperties', {}) as IDataObject; + + const partitionKeyResult = await fetchPartitionKeyField.call( + this as unknown as ILoadOptionsFunctions, + ); + const partitionKeyField = + partitionKeyResult.results.length > 0 ? partitionKeyResult.results[0].value : ''; + + if (!partitionKeyField) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Partition key not found', + description: 'Failed to determine the partition key for this collection.', + }, + ); + } + + if (!(typeof partitionKeyField === 'string' || typeof partitionKeyField === 'number')) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Invalid partition key', + description: `Partition key must be a string or number, but got ${typeof partitionKeyField}.`, + }, + ); + } + + let parsedProperties: Record; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedProperties = + typeof customProperties === 'string' ? JSON.parse(customProperties) : customProperties; + } catch (error) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Invalid custom properties format', + description: 'Custom properties must be a valid JSON object.', + }, + ); + } + let id: string | undefined | { mode: string; value: string }; + let partitionKeyValue: string | undefined; + + if (operation === 'create') { + if (partitionKeyField === 'id') { + partitionKeyValue = this.getNodeParameter('newId', '') as string; + } else { + if (!Object.prototype.hasOwnProperty.call(parsedProperties, partitionKeyField)) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Partition key not found in custom properties', + description: `Partition key "${partitionKeyField}" must be present and have a valid, non-empty value in custom properties.`, + }, + ); + } + partitionKeyValue = parsedProperties[partitionKeyField] as string; + } + } else { + if (partitionKeyField === 'id') { + id = this.getNodeParameter('id', {}) as { mode: string; value: string }; + + if (!id?.value) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Item ID is missing or invalid', + description: "The item must have a valid value selected from 'Item'", + }, + ); + } + + partitionKeyValue = id.value; + } else { + const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; + partitionKeyValue = additionalFields.partitionKey as string; + } + } + + if (partitionKeyValue === undefined || partitionKeyValue === null || partitionKeyValue === '') { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Partition key value is missing or empty', + description: `Provide a value for partition key "${partitionKeyField}" in "Partition Key" field.`, + }, + ); + } + + requestOptions.headers = { + ...requestOptions.headers, + 'x-ms-documentdb-partitionkey': `["${partitionKeyValue}"]`, + }; + + return requestOptions; +} diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index 140c1d9196..60c2f422df 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -6,6 +6,7 @@ import { handlePagination, processResponseItems, validateOperations, + validatePartitionKey, validateQueryParameters, } from '../GenericFunctions'; @@ -27,18 +28,18 @@ export const itemOperations: INodeProperties[] = [ description: 'Create a new item', routing: { send: { - preSend: [formatCustomProperties], + preSend: [formatCustomProperties, validatePartitionKey], }, request: { - ignoreHttpStatusErrors: true, method: 'POST', url: '=/colls/{{ $parameter["collId"] }}/docs', - //To-Do-do it based on the partition key of collection and only one headers: { - 'x-ms-documentdb-partitionkey': '=["{{$parameter["newId"]}}"]', 'x-ms-documentdb-is-upsert': 'True', }, }, + output: { + postReceive: [handleErrorPostReceive], + }, }, action: 'Create item', }, @@ -47,17 +48,16 @@ export const itemOperations: INodeProperties[] = [ value: 'delete', description: 'Delete an existing item', routing: { + send: { + preSend: [validatePartitionKey], + }, request: { - ignoreHttpStatusErrors: true, method: 'DELETE', url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', - //To-Do-do it based on the partition key of collection and only one - headers: { - 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', - }, }, output: { postReceive: [ + handleErrorPostReceive, { type: 'set', properties: { @@ -74,16 +74,19 @@ export const itemOperations: INodeProperties[] = [ value: 'get', description: 'Retrieve an item', routing: { + send: { + preSend: [validatePartitionKey], + }, request: { - ignoreHttpStatusErrors: true, method: 'GET', url: '=/colls/{{ $parameter["collId"]}}/docs/{{$parameter["id"]}}', headers: { - //To-Do-do it based on the partition key of collection and only one - 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', 'x-ms-documentdb-is-upsert': 'True', }, }, + output: { + postReceive: [handleErrorPostReceive], + }, }, action: 'Get item', }, @@ -99,12 +102,11 @@ export const itemOperations: INodeProperties[] = [ pagination: handlePagination, }, request: { - ignoreHttpStatusErrors: true, method: 'GET', url: '=/colls/{{ $parameter["collId"] }}/docs', }, output: { - postReceive: [processResponseItems], + postReceive: [processResponseItems, handleErrorPostReceive], }, }, action: 'Get many items', @@ -118,7 +120,6 @@ export const itemOperations: INodeProperties[] = [ preSend: [validateQueryParameters], }, request: { - ignoreHttpStatusErrors: true, method: 'POST', url: '=/colls/{{ $parameter["collId"] }}/docs', headers: { @@ -126,6 +127,9 @@ export const itemOperations: INodeProperties[] = [ 'x-ms-documentdb-isquery': 'True', }, }, + output: { + postReceive: [handleErrorPostReceive], + }, }, action: 'Query items', }, @@ -135,15 +139,13 @@ export const itemOperations: INodeProperties[] = [ description: 'Update an existing item', routing: { send: { - preSend: [validateOperations], + preSend: [validateOperations, validatePartitionKey], }, request: { - ignoreHttpStatusErrors: true, method: 'PATCH', url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', headers: { 'Content-Type': 'application/json-patch+json', - 'x-ms-documentdb-partitionkey': '=["{{$parameter["id"]}}"]', }, }, output: { @@ -330,6 +332,28 @@ export const deleteFields: INodeProperties[] = [ }, ], }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Partition Key', + default: {}, + displayOptions: { + show: { + resource: ['item'], + operation: ['delete'], + }, + }, + options: [ + { + displayName: 'Partition Key', + name: 'partitionKey', + type: 'string', + default: '', + description: 'Specify the partition key for this item', + }, + ], + }, ]; export const getFields: INodeProperties[] = [ @@ -421,6 +445,28 @@ export const getFields: INodeProperties[] = [ }, ], }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Partition Key', + default: {}, + displayOptions: { + show: { + resource: ['item'], + operation: ['get'], + }, + }, + options: [ + { + displayName: 'Partition Key', + name: 'partitionKey', + type: 'string', + default: '', + description: 'Specify the partition key for this item', + }, + ], + }, ]; export const getAllFields: INodeProperties[] = [ @@ -862,6 +908,28 @@ export const updateFields: INodeProperties[] = [ }, ], }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Partition Key', + default: {}, + displayOptions: { + show: { + resource: ['item'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Partition Key', + name: 'partitionKey', + type: 'string', + default: '', + description: 'Specify the partition key for this item', + }, + ], + }, ]; export const itemFields: INodeProperties[] = [ From 9801c907fce592db92f87e4845e584f3e667eb6e Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Fri, 7 Feb 2025 10:02:58 +0200 Subject: [PATCH 16/19] Added more specific errors --- .../nodes/Microsoft/CosmosDB/CosmosDb.node.ts | 6 +- .../Microsoft/CosmosDB/GenericFunctions.ts | 1154 +++++++++-------- .../descriptions/ContainerDescription.ts | 4 +- .../CosmosDB/descriptions/ItemDescription.ts | 18 +- .../CosmosDB/test/SearchCollections.test.ts | 24 +- .../CosmosDB/test/SearchItems.test.ts | 4 +- 6 files changed, 666 insertions(+), 544 deletions(-) diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts index f34561a96a..7e48aef3d7 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/CosmosDb.node.ts @@ -3,7 +3,7 @@ import { NodeConnectionType } from 'n8n-workflow'; import { containerFields, containerOperations } from './descriptions/ContainerDescription'; import { itemFields, itemOperations } from './descriptions/ItemDescription'; -import { getDynamicFields, searchCollections, searchItems } from './GenericFunctions'; +import { getProperties, searchContainers, searchItems } from './GenericFunctions'; export class CosmosDb implements INodeType { description: INodeTypeDescription = { @@ -62,9 +62,9 @@ export class CosmosDb implements INodeType { methods = { listSearch: { - searchCollections, + searchContainers, searchItems, - getDynamicFields, + getProperties, }, }; } diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index 50dec6fbb8..554909e8b4 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -14,16 +14,11 @@ import type { import { ApplicationError, NodeApiError } from 'n8n-workflow'; export const HeaderConstants = { - // Required AUTHORIZATION: 'Authorization', CONTENT_TYPE: 'Content-Type', X_MS_DATE: 'x-ms-date', X_MS_VERSION: 'x-ms-version', - - //Required - for session consistency only X_MS_SESSION_TOKEN: 'x-ms-session-token', - - // Optional IF_MATCH: 'If-Match', IF_NONE_MATCH: 'If-None-Match', IF_MODIFIED_SINCE: 'If-Modified-Since', @@ -63,98 +58,6 @@ export function getAuthorizationTokenUsingMasterKey( return `type=master&ver=1.0&sig=${signature}`; } -export async function handlePagination( - this: IExecutePaginationFunctions, - resultOptions: DeclarativeRestApiSettings.ResultOptions, -): Promise { - const aggregatedResult: IDataObject[] = []; - let nextPageToken: string | undefined; - const returnAll = this.getNodeParameter('returnAll') as boolean; - let limit = 60; - - if (!returnAll) { - limit = this.getNodeParameter('limit') as number; - resultOptions.maxResults = limit; - } - - resultOptions.paginate = true; - - do { - if (nextPageToken) { - resultOptions.options.headers = resultOptions.options.headers ?? {}; - resultOptions.options.headers['x-ms-continuation'] = nextPageToken; - } - - const responseData = await this.makeRoutingRequest(resultOptions); - - if (Array.isArray(responseData)) { - for (const responsePage of responseData) { - aggregatedResult.push(responsePage); - - if (!returnAll && aggregatedResult.length >= limit) { - return aggregatedResult.slice(0, limit).map((result) => ({ json: result })); - } - } - } - - //TO-DO-check-if-works - if (responseData.length > 0) { - const lastItem = responseData[responseData.length - 1]; - - if ('headers' in lastItem) { - const headers = (lastItem as unknown as { headers: { [key: string]: string } }).headers; - - if (headers) { - nextPageToken = headers['x-ms-continuation'] as string | undefined; - } - } - } - - if (!nextPageToken) { - break; - } - } while (nextPageToken); - - return aggregatedResult.map((result) => ({ json: result })); -} - -export async function handleErrorPostReceive( - this: IExecuteSingleFunctions, - data: INodeExecutionData[], - response: IN8nHttpFullResponse, -): Promise { - console.log('Status code❌', response.statusCode); - - if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { - const responseBody = response.body as IDataObject; - console.log('Got here ❌', responseBody); - let errorMessage = 'Unknown error occurred'; - - if (typeof responseBody.message === 'string') { - try { - const jsonMatch = responseBody.message.match(/Message: (\{.*\})/); - - if (jsonMatch && jsonMatch[1]) { - const parsedMessage = JSON.parse(jsonMatch[1]); - - if ( - parsedMessage.Errors && - Array.isArray(parsedMessage.Errors) && - parsedMessage.Errors.length > 0 - ) { - errorMessage = parsedMessage.Errors[0].split(' Learn more:')[0].trim(); - } - } - } catch (error) { - errorMessage = 'Failed to extract error message'; - } - } - - throw new ApplicationError(errorMessage); - } - return data; -} - export async function microsoftCosmosDbRequest( this: ILoadOptionsFunctions, opts: IHttpRequestOptions, @@ -178,6 +81,12 @@ export async function microsoftCosmosDbRequest( }; const errorMapping: Record> = { + 401: { + 'The security token included in the request is invalid.': + 'The Cosmos DB credentials are not valid!', + 'The request signature we calculated does not match the signature you provided': + 'The Cosmos DB credentials are not valid!', + }, 403: { 'The security token included in the request is invalid.': 'The Cosmos DB credentials are not valid!', @@ -226,431 +135,20 @@ export async function microsoftCosmosDbRequest( } } -export async function searchCollections( - this: ILoadOptionsFunctions, - filter?: string, -): Promise { - const opts: IHttpRequestOptions = { - method: 'GET', - url: '/colls', - }; - - const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); - - const responseBody = responseData as { - DocumentCollections: IDataObject[]; - }; - const collections = responseBody.DocumentCollections; - - if (!collections) { - return { results: [] }; - } - - const results: INodeListSearchItems[] = collections - .map((collection) => { - return { - name: String(collection.id), - value: String(collection.id), - }; - }) - .filter((collection) => !filter || collection.name.includes(filter)) - .sort((a, b) => a.name.localeCompare(b.name)); - - return { - results, - }; -} - -export async function searchItems( - this: ILoadOptionsFunctions, - filter?: string, -): Promise { - const collection = this.getNodeParameter('collId') as { mode: string; value: string }; - - if (!collection?.value) { - throw new ApplicationError('Collection ID is required.'); - } - const opts: IHttpRequestOptions = { - method: 'GET', - url: `/colls/${collection.value}/docs`, - }; - - const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); - - const responseBody = responseData as { - Documents: IDataObject[]; - }; - const items = responseBody.Documents; - - if (!items) { - return { results: [] }; - } - - const results: INodeListSearchItems[] = items - .map((item) => { - const idWithoutSpaces = String(item.id).replace(/ /g, ''); - return { - name: String(idWithoutSpaces), - value: String(item.id), - }; - }) - .filter((item) => !filter || item.name.includes(filter)) - .sort((a, b) => a.name.localeCompare(b.name)); - - return { - results, - }; -} - -export async function validateQueryParameters( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, -): Promise { - const params = this.getNodeParameter('parameters', {}) as { - parameters: Array<{ name: string; value: string }>; - }; - - if (!params || !Array.isArray(params.parameters)) { - throw new ApplicationError( - 'The "parameters" field cannot be empty. Please add at least one parameter.', - ); - } - - const parameters = params.parameters; - - for (const parameter of parameters) { - if (!parameter.name || parameter.name.trim() === '') { - throw new ApplicationError('Each parameter must have a non-empty "name".'); - } - - if (!parameter.value) { - throw new ApplicationError(`The parameter "${parameter.name}" must have a valid "value".`); - } - } - - return requestOptions; -} - -export async function validateOperations( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, -): Promise { - const rawOperations = this.getNodeParameter('operations', []) as IDataObject; - - if (!rawOperations || !Array.isArray(rawOperations.operations)) { - throw new ApplicationError('The "operations" field must contain at least one operation.'); - } - - const operations = rawOperations.operations as Array<{ - op: string; - path?: { mode: string; value: string }; - toPath?: { mode: string; value: string }; - from?: { mode: string; value: string }; - value?: string | number; - }>; - - const transformedOperations = operations.map((operation) => { - if ( - operation.op !== 'move' && - (!operation.path?.value || - typeof operation.path.value !== 'string' || - operation.path.value.trim() === '') - ) { - throw new ApplicationError('Each operation must have a valid "path".'); - } - - if ( - ['set', 'replace', 'add', 'incr'].includes(operation.op) && - (operation.value === undefined || operation.value === null) - ) { - throw new ApplicationError(`The "${operation.op}" operation must include a valid "value".`); - } - - if (operation.op === 'move') { - if ( - !operation.from?.value || - typeof operation.from.value !== 'string' || - operation.from.value.trim() === '' - ) { - throw new ApplicationError('The "move" operation must have a valid "from" path.'); - } - - if ( - !operation.toPath?.value || - typeof operation.toPath.value !== 'string' || - operation.toPath.value.trim() === '' - ) { - throw new ApplicationError('The "move" operation must have a valid "toPath".'); - } - } - - if (operation.op === 'incr' && isNaN(Number(operation.value))) { - throw new ApplicationError('The "increment" operation must have a numeric value.'); - } - - //To-Do-check to not send properties it doesn't need - return { - op: operation.op, - path: operation.op === 'move' ? operation.toPath?.value : operation.path?.value, - ...(operation.from ? { from: operation.from.value } : {}), - ...(operation.op === 'incr' - ? { value: Number(operation.value) } - : { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) }), - }; - }); - - requestOptions.body = transformedOperations; - - return requestOptions; -} - -export async function formatCustomProperties( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, -): Promise { - const rawCustomProperties = this.getNodeParameter('customProperties', '{}') as string; - const newId = this.getNodeParameter('newId') as string; - - if (/\s/.test(newId)) { - throw new ApplicationError( - 'Invalid ID: IDs cannot contain spaces. Use an underscore (_) or another separator instead.', - ); - } - - let parsedProperties: Record; - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - parsedProperties = JSON.parse(rawCustomProperties); - } catch (error) { - throw new ApplicationError( - 'Invalid JSON format in "Custom Properties". Please provide a valid JSON object.', - ); - } - - if ( - typeof parsedProperties !== 'object' || - parsedProperties === null || - Array.isArray(parsedProperties) - ) { - throw new ApplicationError('The "Custom Properties" field must be a valid JSON object.'); - } - - if ( - !requestOptions.body || - typeof requestOptions.body !== 'object' || - requestOptions.body === null - ) { - requestOptions.body = {}; - } - - Object.assign(requestOptions.body as Record, { id: newId }, parsedProperties); - - return requestOptions; -} - -export async function formatJSONFields( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, -): Promise { - const rawPartitionKey = this.getNodeParameter('partitionKey', '{}') as string; - const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; - const indexingPolicy = additionalFields.indexingPolicy as string; - - let parsedPartitionKey: Record; - let parsedIndexPolicy: Record | undefined; - - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - parsedPartitionKey = JSON.parse(rawPartitionKey); - - if (indexingPolicy) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - parsedIndexPolicy = JSON.parse(indexingPolicy); - } - } catch (error) { - throw new ApplicationError( - 'Invalid JSON format in either "Partition Key" or "Indexing Policy". Please provide valid JSON objects.', - ); - } - - if ( - !requestOptions.body || - typeof requestOptions.body !== 'object' || - requestOptions.body === null - ) { - requestOptions.body = {}; - } - - (requestOptions.body as Record).partitionKey = parsedPartitionKey; - - if (parsedIndexPolicy) { - (requestOptions.body as Record).indexingPolicy = parsedIndexPolicy; - } - - return requestOptions; -} - -export async function validateFields( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, -): Promise { - const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; - const indexingPolicy = additionalFields.indexingPolicy; - const manualThroughput = additionalFields.offerThroughput; - const autoscaleThroughput = additionalFields.maxThroughput; - - if (manualThroughput && autoscaleThroughput) { - throw new ApplicationError( - 'You cannot set both "Max RU/s (Autoscale)" and "Max RU/s (Manual Throughput)". Please choose only one.', - ); - } - if (autoscaleThroughput && requestOptions?.qs) { - requestOptions.qs['x-ms-cosmos-offer-autopilot-settings'] = { - maxThroughput: autoscaleThroughput, - }; - } - - if (!indexingPolicy || Object.keys(indexingPolicy).length === 0) { - throw new ApplicationError( - 'Invalid Indexing Policy: Please provide a valid indexingPolicy JSON.', - ); - } - - return requestOptions; -} - -export async function processResponseItems( - this: IExecuteSingleFunctions, - items: INodeExecutionData[], - response: IN8nHttpFullResponse, -): Promise { - if (!response || typeof response !== 'object' || !Array.isArray(items)) { - throw new ApplicationError('Invalid response format from Cosmos DB.'); - } - - const extractedDocuments: IDataObject[] = items.flatMap((item) => { - if ( - item.json && - typeof item.json === 'object' && - 'Documents' in item.json && - Array.isArray(item.json.Documents) - ) { - return item.json.Documents as IDataObject[]; - } - - return []; - }); - - return extractedDocuments; -} - -export async function processResponseContainers( - this: IExecuteSingleFunctions, - items: INodeExecutionData[], - response: IN8nHttpFullResponse, -): Promise { - if (!response || typeof response !== 'object' || !Array.isArray(items)) { - throw new ApplicationError('Invalid response format from Cosmos DB.'); - } - - const data = response.body as { DocumentCollections: IDataObject[] }; - - if (data.DocumentCollections.length > 0) { - return data.DocumentCollections.map((doc) => ({ json: doc })); - } - - return []; -} - -function extractFieldPaths(obj: any, prefix = ''): string[] { - let paths: string[] = []; - - Object.entries(obj).forEach(([key, value]) => { - if (key.startsWith('_') || key === 'id') { - return; - } - const newPath = prefix ? `${prefix}/${key}` : `/${key}`; - if (Array.isArray(value) && value.length > 0) { - value.forEach((item, index) => { - if (typeof item === 'object' && item !== null) { - paths = paths.concat(extractFieldPaths(item, `${newPath}/${index}`)); - } else { - paths.push(`${newPath}/${index}`); - } - }); - } else if (typeof value === 'object' && value !== null) { - paths = paths.concat(extractFieldPaths(value, newPath)); - } else { - paths.push(newPath); - } - }); - - return paths; -} - -export async function searchItemById( - this: ILoadOptionsFunctions, - itemId: string, -): Promise { - const collection = this.getNodeParameter('collId') as { mode: string; value: string }; - - if (!collection?.value) { - throw new ApplicationError('Collection ID is required.'); - } - - if (!itemId) { - throw new ApplicationError('Item ID is required.'); - } - - const opts: IHttpRequestOptions = { - method: 'GET', - url: `/colls/${collection.value}/docs/${itemId}`, - headers: { - 'x-ms-documentdb-partitionkey': `["${itemId}"]`, - }, - }; - - const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); - - if (!responseData) { - return null; - } - - return responseData; -} - -export async function getDynamicFields( - this: ILoadOptionsFunctions, -): Promise { - const itemId = this.getNodeParameter('id', '') as { mode: string; value: string }; - - if (!itemId) { - throw new ApplicationError('Item ID is required to fetch fields.'); - } - - const itemData = await searchItemById.call(this, itemId.value); - - if (!itemData) { - throw new ApplicationError(`Item with ID "${itemId.value}" not found.`); - } - - const fieldPaths = extractFieldPaths(itemData); - - return { - results: fieldPaths.map((path) => ({ - name: path, - value: path, - })), - }; -} - export async function fetchPartitionKeyField( this: ILoadOptionsFunctions, ): Promise { const collection = this.getNodeParameter('collId', '') as { mode: string; value: string }; if (!collection?.value) { - throw new ApplicationError('Collection ID is required to determine the partition key.'); + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Container is required to determine the partition key.', + description: 'Please provide a value for "Container" field', + }, + ); } const opts: IHttpRequestOptions = { @@ -686,6 +184,54 @@ export async function fetchPartitionKeyField( }; } +export async function validateQueryParameters( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const params = this.getNodeParameter('parameters', {}) as { + parameters: Array<{ name: string; value: string }>; + }; + + if (!params || !Array.isArray(params.parameters)) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'The "parameters" field cannot be empty', + description: 'Please provide at least one parameter', + }, + ); + } + + const parameters = params.parameters; + + for (const parameter of parameters) { + if (!parameter.name || parameter.name.trim() === '') { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Each parameter must have a non-empty "Name".', + description: 'Please provide a value for "Name" field', + }, + ); + } + + if (!parameter.value) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: `Invalid value for parameter "${parameter.name}"`, + description: 'Please provide a value for "value" field', + }, + ); + } + } + + return requestOptions; +} + export async function validatePartitionKey( this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions, @@ -796,3 +342,579 @@ export async function validatePartitionKey( return requestOptions; } + +export async function validateOperations( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawOperations = this.getNodeParameter('operations', []) as IDataObject; + + if (!rawOperations || !Array.isArray(rawOperations.operations)) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'No operation provided', + description: 'The "Operations" field must contain at least one operation.', + }, + ); + } + + const operations = rawOperations.operations as Array<{ + op: string; + path?: { mode: string; value: string }; + toPath?: { mode: string; value: string }; + from?: { mode: string; value: string }; + value?: string | number; + }>; + + const transformedOperations = operations.map((operation) => { + if ( + operation.op !== 'move' && + (!operation.path?.value || + typeof operation.path.value !== 'string' || + operation.path.value.trim() === '') + ) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Each operation must have a valid "path".', + description: 'Please provide a value for path', + }, + ); + } + + if ( + ['set', 'replace', 'add', 'incr'].includes(operation.op) && + (operation.value === undefined || operation.value === null) + ) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Invalid value', + description: `The "${operation.op}" operation must include a valid "value".`, + }, + ); + } + + if (operation.op === 'move') { + if ( + !operation.from?.value || + typeof operation.from.value !== 'string' || + operation.from.value.trim() === '' + ) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'The "move" operation must have a valid path.', + description: 'Please provide a valid value for field "From Path"', + }, + ); + } + + if ( + !operation.toPath?.value || + typeof operation.toPath.value !== 'string' || + operation.toPath.value.trim() === '' + ) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'The "move" operation must have a valid path.', + description: 'Please provide a valid value for field "To Path"', + }, + ); + } + } + + if (operation.op === 'incr' && isNaN(Number(operation.value))) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Invalid value', + description: 'Please provide a numeric value for field "Value"', + }, + ); + } + + //To-Do-check to not send properties it doesn't need + return { + op: operation.op, + path: operation.op === 'move' ? operation.toPath?.value : operation.path?.value, + ...(operation.from ? { from: operation.from.value } : {}), + ...(operation.op === 'incr' + ? { value: Number(operation.value) } + : { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) }), + }; + }); + + requestOptions.body = transformedOperations; + + return requestOptions; +} + +export async function validateFields( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; + const indexingPolicy = additionalFields.indexingPolicy; + const manualThroughput = additionalFields.offerThroughput; + const autoscaleThroughput = additionalFields.maxThroughput; + + if (manualThroughput && autoscaleThroughput) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Bad parameter', + description: + 'Please choose only one of Max RU/s (Autoscale) and Max RU/s (Manual Throughput)', + }, + ); + } + if (autoscaleThroughput && requestOptions?.qs) { + requestOptions.qs['x-ms-cosmos-offer-autopilot-settings'] = { + maxThroughput: autoscaleThroughput, + }; + } + + if (!indexingPolicy || Object.keys(indexingPolicy).length === 0) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Invalid Indexing Policy', + description: 'Please provide a valid indexingPolicy JSON.', + }, + ); + } + + return requestOptions; +} + +//WIP +export async function handlePagination( + this: IExecutePaginationFunctions, + resultOptions: DeclarativeRestApiSettings.ResultOptions, +): Promise { + const aggregatedResult: IDataObject[] = []; + let nextPageToken: string | undefined; + const returnAll = this.getNodeParameter('returnAll') as boolean; + let limit = 60; + + if (!returnAll) { + limit = this.getNodeParameter('limit') as number; + resultOptions.maxResults = limit; + } + + resultOptions.paginate = true; + + do { + if (nextPageToken) { + resultOptions.options.headers = resultOptions.options.headers ?? {}; + resultOptions.options.headers['x-ms-continuation'] = nextPageToken; + } + + const responseData = await this.makeRoutingRequest(resultOptions); + + if (Array.isArray(responseData)) { + for (const responsePage of responseData) { + aggregatedResult.push(responsePage); + + if (!returnAll && aggregatedResult.length >= limit) { + return aggregatedResult.slice(0, limit).map((result) => ({ json: result })); + } + } + } + + //TO-DO-check-if-works + if (responseData.length > 0) { + const lastItem = responseData[responseData.length - 1]; + + if ('headers' in lastItem) { + const headers = (lastItem as unknown as { headers: { [key: string]: string } }).headers; + + if (headers) { + nextPageToken = headers['x-ms-continuation'] as string | undefined; + } + } + } + + if (!nextPageToken) { + break; + } + } while (nextPageToken); + + return aggregatedResult.map((result) => ({ json: result })); +} + +//WIP +export async function handleErrorPostReceive( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + console.log('Status code❌', response.statusCode); + + if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { + const responseBody = response.body as IDataObject; + console.log('Got here ❌', responseBody); + let errorMessage = 'Unknown error occurred'; + + if (typeof responseBody.message === 'string') { + try { + const jsonMatch = responseBody.message.match(/Message: (\{.*\})/); + + if (jsonMatch && jsonMatch[1]) { + const parsedMessage = JSON.parse(jsonMatch[1]); + + if ( + parsedMessage.Errors && + Array.isArray(parsedMessage.Errors) && + parsedMessage.Errors.length > 0 + ) { + errorMessage = parsedMessage.Errors[0].split(' Learn more:')[0].trim(); + } + } + } catch (error) { + errorMessage = 'Failed to extract error message'; + } + } + + throw new ApplicationError(errorMessage); + } + return data; +} + +export async function searchContainers( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const opts: IHttpRequestOptions = { + method: 'GET', + url: '/colls', + }; + + const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); + + const responseBody = responseData as { + DocumentCollections: IDataObject[]; + }; + const collections = responseBody.DocumentCollections; + + if (!collections) { + return { results: [] }; + } + + const results: INodeListSearchItems[] = collections + .map((collection) => { + return { + name: String(collection.id), + value: String(collection.id), + }; + }) + .filter((collection) => !filter || collection.name.includes(filter)) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + results, + }; +} + +export async function searchItems( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const collection = this.getNodeParameter('collId') as { mode: string; value: string }; + + if (!collection?.value) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Container is required', + description: 'Please provide a value for container in "Container" field', + }, + ); + } + const opts: IHttpRequestOptions = { + method: 'GET', + url: `/colls/${collection.value}/docs`, + }; + + const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); + + const responseBody = responseData as { + Documents: IDataObject[]; + }; + const items = responseBody.Documents; + + if (!items) { + return { results: [] }; + } + + const results: INodeListSearchItems[] = items + .map((item) => { + const idWithoutSpaces = String(item.id).replace(/ /g, ''); + return { + name: String(idWithoutSpaces), + value: String(item.id), + }; + }) + .filter((item) => !filter || item.name.includes(filter)) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { + results, + }; +} + +function extractFieldPaths(obj: any, prefix = ''): string[] { + let paths: string[] = []; + + Object.entries(obj).forEach(([key, value]) => { + if (key.startsWith('_') || key === 'id') { + return; + } + const newPath = prefix ? `${prefix}/${key}` : `/${key}`; + if (Array.isArray(value) && value.length > 0) { + value.forEach((item, index) => { + if (typeof item === 'object' && item !== null) { + paths = paths.concat(extractFieldPaths(item, `${newPath}/${index}`)); + } else { + paths.push(`${newPath}/${index}`); + } + }); + } else if (typeof value === 'object' && value !== null) { + paths = paths.concat(extractFieldPaths(value, newPath)); + } else { + paths.push(newPath); + } + }); + + return paths; +} + +export async function searchItemById( + this: ILoadOptionsFunctions, + itemId: string, +): Promise { + const collection = this.getNodeParameter('collId') as { mode: string; value: string }; + + if (!collection?.value) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Container is required', + description: 'Please provide a value for container in "Container" field', + }, + ); + } + + if (!itemId) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Item is required', + description: 'Please provide a value for item in "Item" field', + }, + ); + } + + const opts: IHttpRequestOptions = { + method: 'GET', + url: `/colls/${collection.value}/docs/${itemId}`, + headers: { + 'x-ms-documentdb-partitionkey': `["${itemId}"]`, + }, + }; + + const responseData: IDataObject = await microsoftCosmosDbRequest.call(this, opts); + + if (!responseData) { + return null; + } + + return responseData; +} + +export async function getProperties(this: ILoadOptionsFunctions): Promise { + const itemId = this.getNodeParameter('id', '') as { mode: string; value: string }; + + if (!itemId) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Item is required', + description: 'Please provide a value for item in "Item" field', + }, + ); + } + + const itemData = await searchItemById.call(this, itemId.value); + + if (!itemData) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Item not found', + description: `Item with ID "${itemId.value}" not found.`, + }, + ); + } + + const fieldPaths = extractFieldPaths(itemData); + + return { + results: fieldPaths.map((path) => ({ + name: path, + value: path, + })), + }; +} + +export async function formatCustomProperties( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawCustomProperties = this.getNodeParameter('customProperties', '{}') as string; + const newId = this.getNodeParameter('newId') as string; + + if (/\s/.test(newId)) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Invalid ID format: IDs cannot contain spaces.', + description: 'Use an underscore (_) or another separator instead.', + }, + ); + } + + let parsedProperties: Record; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedProperties = JSON.parse(rawCustomProperties); + } catch (error) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Invalid format in "Custom Properties".', + description: ' Please provide a valid JSON object.', + }, + ); + } + + if ( + !requestOptions.body || + typeof requestOptions.body !== 'object' || + requestOptions.body === null + ) { + requestOptions.body = {}; + } + + Object.assign(requestOptions.body as Record, { id: newId }, parsedProperties); + + return requestOptions; +} + +export async function formatJSONFields( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const rawPartitionKey = this.getNodeParameter('partitionKey', '{}') as string; + const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; + const indexingPolicy = additionalFields.indexingPolicy as string; + + let parsedPartitionKey: Record; + let parsedIndexPolicy: Record | undefined; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedPartitionKey = JSON.parse(rawPartitionKey); + + if (indexingPolicy) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + parsedIndexPolicy = JSON.parse(indexingPolicy); + } + } catch (error) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Invalid JSON format in either "Partition Key" or "Indexing Policy".', + description: 'Please provide valid JSON objects.', + }, + ); + } + + if ( + !requestOptions.body || + typeof requestOptions.body !== 'object' || + requestOptions.body === null + ) { + requestOptions.body = {}; + } + + (requestOptions.body as Record).partitionKey = parsedPartitionKey; + + if (parsedIndexPolicy) { + (requestOptions.body as Record).indexingPolicy = parsedIndexPolicy; + } + + return requestOptions; +} + +export async function processResponseItems( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (!response || typeof response !== 'object' || !Array.isArray(items)) { + throw new ApplicationError('Invalid response format from Cosmos DB.'); + } + + const extractedDocuments: IDataObject[] = items.flatMap((item) => { + if ( + item.json && + typeof item.json === 'object' && + 'Documents' in item.json && + Array.isArray(item.json.Documents) + ) { + return item.json.Documents as IDataObject[]; + } + + return []; + }); + + return extractedDocuments; +} + +export async function processResponseContainers( + this: IExecuteSingleFunctions, + items: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (!response || typeof response !== 'object' || !Array.isArray(items)) { + throw new ApplicationError('Invalid response format from Cosmos DB.'); + } + + const data = response.body as { DocumentCollections: IDataObject[] }; + + if (data.DocumentCollections.length > 0) { + return data.DocumentCollections.map((doc) => ({ json: doc })); + } + + return []; +} diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts index 330ffb7de4..8c91e6659c 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts @@ -199,7 +199,7 @@ export const getFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'searchCollections', + searchListMethod: 'searchContainers', searchable: true, }, }, @@ -248,7 +248,7 @@ export const deleteFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'searchCollections', + searchListMethod: 'searchContainers', searchable: true, }, }, diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index 60c2f422df..a42552ffd1 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -182,7 +182,7 @@ export const createFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'searchCollections', + searchListMethod: 'searchContainers', searchable: true, }, }, @@ -266,7 +266,7 @@ export const deleteFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'searchCollections', + searchListMethod: 'searchContainers', searchable: true, }, }, @@ -379,7 +379,7 @@ export const getFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'searchCollections', + searchListMethod: 'searchContainers', searchable: true, }, }, @@ -492,7 +492,7 @@ export const getAllFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'searchCollections', + searchListMethod: 'searchContainers', searchable: true, }, }, @@ -577,7 +577,7 @@ export const queryFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'searchCollections', + searchListMethod: 'searchContainers', searchable: true, }, }, @@ -692,7 +692,7 @@ export const updateFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'searchCollections', + searchListMethod: 'searchContainers', searchable: true, }, }, @@ -814,7 +814,7 @@ export const updateFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'getDynamicFields', + searchListMethod: 'getProperties', searchable: true, }, }, @@ -847,7 +847,7 @@ export const updateFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'getDynamicFields', + searchListMethod: 'getProperties', searchable: true, }, }, @@ -880,7 +880,7 @@ export const updateFields: INodeProperties[] = [ name: 'list', type: 'list', typeOptions: { - searchListMethod: 'getDynamicFields', + searchListMethod: 'getProperties', searchable: true, }, }, diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts index 7e75d847bb..2cfe3de3ce 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts @@ -1,8 +1,8 @@ import type { ILoadOptionsFunctions } from 'n8n-workflow'; -import { searchCollections } from '../GenericFunctions'; +import { searchContainers } from '../GenericFunctions'; -describe('GenericFunctions - searchCollections', () => { +describe('GenericFunctions - searchContainers', () => { const mockRequestWithAuthentication = jest.fn(); const mockContext = { @@ -17,7 +17,7 @@ describe('GenericFunctions - searchCollections', () => { jest.clearAllMocks(); }); - it('should make a GET request to fetch collections and return results', async () => { + it('should make a GET request to fetch containers and return results', async () => { (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1', database: 'first_database_1', @@ -28,7 +28,7 @@ describe('GenericFunctions - searchCollections', () => { DocumentCollections: [{ id: 'Collection1' }, { id: 'Collection2' }], }); - const response = await searchCollections.call(mockContext); + const response = await searchContainers.call(mockContext); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( 'microsoftCosmosDbSharedKeyApi', @@ -52,7 +52,7 @@ describe('GenericFunctions - searchCollections', () => { }); }); - it('should filter collections by the provided filter string', async () => { + it('should filter containers by the provided filter string', async () => { (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1', database: 'first_database_1', @@ -63,7 +63,7 @@ describe('GenericFunctions - searchCollections', () => { DocumentCollections: [{ id: 'Test-Col-1' }, { id: 'Prod-Col-1' }], }); - const response = await searchCollections.call(mockContext, 'Test'); + const response = await searchContainers.call(mockContext, 'Test'); expect(mockRequestWithAuthentication).toHaveBeenCalledWith( 'microsoftCosmosDbSharedKeyApi', @@ -84,7 +84,7 @@ describe('GenericFunctions - searchCollections', () => { }); }); - it('should sort collections alphabetically by name', async () => { + it('should sort containers alphabetically by name', async () => { (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce('db-id-1'); @@ -92,7 +92,7 @@ describe('GenericFunctions - searchCollections', () => { DocumentCollections: [{ id: 'z-col' }, { id: 'a-col' }, { id: 'm-col' }], }); - const response = await searchCollections.call(mockContext); + const response = await searchContainers.call(mockContext); expect(response).toEqual({ results: [ @@ -103,7 +103,7 @@ describe('GenericFunctions - searchCollections', () => { }); }); - it('should handle empty results when no collections are returned', async () => { + it('should handle empty results when no containers are returned', async () => { (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce('db-id-1'); @@ -111,19 +111,19 @@ describe('GenericFunctions - searchCollections', () => { DocumentCollections: [], }); - const response = await searchCollections.call(mockContext); + const response = await searchContainers.call(mockContext); expect(response).toEqual({ results: [] }); }); - it('should handle missing Collections property', async () => { + it('should handle missing DocumentCollections property', async () => { (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' }); (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce('db-id-1'); mockRequestWithAuthentication.mockResolvedValueOnce({ unexpectedkey: 'value', }); - const response = await searchCollections.call(mockContext); + const response = await searchContainers.call(mockContext); expect(response).toEqual({ results: [] }); }); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts index e1d4510275..71633f8c8a 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts @@ -116,9 +116,9 @@ describe('GenericFunctions - searchItems', () => { expect(response).toEqual({ results: [] }); }); - it('should throw an error when collection ID is missing', async () => { + it('should throw an error when container ID is missing', async () => { (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ mode: 'list', value: '' }); - await expect(searchItems.call(mockContext)).rejects.toThrow('Collection ID is required.'); + await expect(searchItems.call(mockContext)).rejects.toThrow('Container is required'); }); }); From d210fcc1726ffce6dc749930f0454e2be2ce5131 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Mon, 10 Feb 2025 11:24:12 +0200 Subject: [PATCH 17/19] Fixed requests --- ...crosoftCosmosDbSharedKeyApi.credentials.ts | 19 ++- .../Microsoft/CosmosDB/GenericFunctions.ts | 125 ++++++++++-------- .../descriptions/ContainerDescription.ts | 30 +++-- .../CosmosDB/descriptions/ItemDescription.ts | 13 +- .../test/FetchPartitionKeyField.test.ts | 103 +++++++++++++++ .../test/MicrosoftCosmosDbRequest.test.ts | 3 + ...tions.test.ts => SearchContainers.test.ts} | 0 .../CosmosDB/test/SearchItemById.test.ts | 119 +++++++++++++++++ .../CosmosDB/test/SearchItems.test.ts | 10 +- 9 files changed, 340 insertions(+), 82 deletions(-) create mode 100644 packages/nodes-base/nodes/Microsoft/CosmosDB/test/FetchPartitionKeyField.test.ts rename packages/nodes-base/nodes/Microsoft/CosmosDB/test/{SearchCollections.test.ts => SearchContainers.test.ts} (100%) create mode 100644 packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItemById.test.ts diff --git a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index a4a429b67f..66c4248df7 100644 --- a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -75,15 +75,22 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { requestOptions.headers['x-ms-session-token'] = credentials.sessionToken; } - // const url = new URL (requestOptions.uri); + let url; - const url = new URL(requestOptions.baseURL + requestOptions.url); - const pathSegments = url.pathname.split('/').filter((segment) => segment); + if (requestOptions.url) { + url = new URL(requestOptions.baseURL + requestOptions.url); + //@ts-ignore + } else if (requestOptions.uri) { + //@ts-ignore + url = new URL(requestOptions.uri); + } + + const pathSegments = url?.pathname.split('/').filter((segment) => segment); let resourceType = ''; let resourceId = ''; - if (pathSegments.includes('docs')) { + if (pathSegments?.includes('docs')) { const docsIndex = pathSegments.lastIndexOf('docs'); resourceType = 'docs'; if (pathSegments[docsIndex + 1]) { @@ -92,7 +99,7 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { } else { resourceId = pathSegments.slice(0, docsIndex).join('/'); } - } else if (pathSegments.includes('colls')) { + } else if (pathSegments?.includes('colls')) { const collsIndex = pathSegments.lastIndexOf('colls'); resourceType = 'colls'; if (pathSegments[collsIndex + 1]) { @@ -101,7 +108,7 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { } else { resourceId = pathSegments.slice(0, collsIndex).join('/'); } - } else if (pathSegments.includes('dbs')) { + } else if (pathSegments?.includes('dbs')) { const dbsIndex = pathSegments.lastIndexOf('dbs'); resourceType = 'dbs'; resourceId = pathSegments.slice(0, dbsIndex + 2).join('/'); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index 554909e8b4..ae66d76aca 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -1,3 +1,7 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-argument import * as crypto from 'crypto'; import type { DeclarativeRestApiSettings, @@ -71,6 +75,7 @@ export async function microsoftCosmosDbRequest( const requestOptions: IHttpRequestOptions = { ...opts, + // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions baseURL: `${credentials.baseUrl}`, headers: { ...opts.headers, @@ -99,8 +104,6 @@ export async function microsoftCosmosDbRequest( }; try { - console.log('Final Request Options before Request:', requestOptions); - return (await this.helpers.requestWithAuthentication.call( this, 'microsoftCosmosDbSharedKeyApi', @@ -442,14 +445,15 @@ export async function validateOperations( ); } - //To-Do-check to not send properties it doesn't need return { op: operation.op, path: operation.op === 'move' ? operation.toPath?.value : operation.path?.value, ...(operation.from ? { from: operation.from.value } : {}), ...(operation.op === 'incr' ? { value: Number(operation.value) } - : { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) }), + : operation.value !== undefined + ? { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) } + : {}), }; }); @@ -458,12 +462,11 @@ export async function validateOperations( return requestOptions; } -export async function validateFields( +export async function validateContainerFields( this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions, ): Promise { const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject; - const indexingPolicy = additionalFields.indexingPolicy; const manualThroughput = additionalFields.offerThroughput; const autoscaleThroughput = additionalFields.maxThroughput; @@ -473,32 +476,28 @@ export async function validateFields( {}, { message: 'Bad parameter', - description: - 'Please choose only one of Max RU/s (Autoscale) and Max RU/s (Manual Throughput)', + description: 'Please choose only one of Max RU/s (Autoscale) and Manual Throughput RU/s', }, ); } - if (autoscaleThroughput && requestOptions?.qs) { - requestOptions.qs['x-ms-cosmos-offer-autopilot-settings'] = { - maxThroughput: autoscaleThroughput, + + if (autoscaleThroughput) { + requestOptions.headers = { + ...requestOptions.headers, + 'x-ms-cosmos-offer-autopilot-setting': { maxThroughput: autoscaleThroughput }, }; } - if (!indexingPolicy || Object.keys(indexingPolicy).length === 0) { - throw new NodeApiError( - this.getNode(), - {}, - { - message: 'Invalid Indexing Policy', - description: 'Please provide a valid indexingPolicy JSON.', - }, - ); + if (manualThroughput) { + requestOptions.headers = { + ...requestOptions.headers, + 'x-ms-offer-throughput': manualThroughput, + }; } return requestOptions; } -//WIP export async function handlePagination( this: IExecutePaginationFunctions, resultOptions: DeclarativeRestApiSettings.ResultOptions, @@ -533,7 +532,6 @@ export async function handlePagination( } } - //TO-DO-check-if-works if (responseData.length > 0) { const lastItem = responseData[responseData.length - 1]; @@ -554,40 +552,34 @@ export async function handlePagination( return aggregatedResult.map((result) => ({ json: result })); } -//WIP export async function handleErrorPostReceive( this: IExecuteSingleFunctions, data: INodeExecutionData[], response: IN8nHttpFullResponse, ): Promise { - console.log('Status code❌', response.statusCode); - if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) { const responseBody = response.body as IDataObject; - console.log('Got here ❌', responseBody); + let errorMessage = 'Unknown error occurred'; + let errorDescription = 'An unexpected error was encountered.'; - if (typeof responseBody.message === 'string') { - try { - const jsonMatch = responseBody.message.match(/Message: (\{.*\})/); - - if (jsonMatch && jsonMatch[1]) { - const parsedMessage = JSON.parse(jsonMatch[1]); - - if ( - parsedMessage.Errors && - Array.isArray(parsedMessage.Errors) && - parsedMessage.Errors.length > 0 - ) { - errorMessage = parsedMessage.Errors[0].split(' Learn more:')[0].trim(); - } - } - } catch (error) { - errorMessage = 'Failed to extract error message'; + if (typeof responseBody === 'object' && responseBody !== null) { + if (typeof responseBody.code === 'string') { + errorMessage = responseBody.code; + } + if (typeof responseBody.message === 'string') { + errorDescription = responseBody.message; } } - throw new ApplicationError(errorMessage); + throw new NodeApiError( + this.getNode(), + {}, + { + message: errorMessage, + description: errorDescription, + }, + ); } return data; } @@ -675,7 +667,7 @@ export async function searchItems( }; } -function extractFieldPaths(obj: any, prefix = ''): string[] { +function extractFieldPaths(obj: IDataObject, prefix = ''): string[] { let paths: string[] = []; Object.entries(obj).forEach(([key, value]) => { @@ -692,7 +684,7 @@ function extractFieldPaths(obj: any, prefix = ''): string[] { } }); } else if (typeof value === 'object' && value !== null) { - paths = paths.concat(extractFieldPaths(value, newPath)); + paths = paths.concat(extractFieldPaths(value as IDataObject, newPath)); } else { paths.push(newPath); } @@ -783,6 +775,34 @@ export async function getProperties(this: ILoadOptionsFunctions): Promise { + const returnAll = this.getNodeParameter('returnAll'); + let limit; + if (!returnAll) { + limit = this.getNodeParameter('limit'); + if (!limit) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: 'Limit value not found', + description: + ' Please provide a value for "Limit" or set "Return All" to true to return all results', + }, + ); + } + } + requestOptions.headers = { + ...requestOptions.headers, + 'x-ms-max-item-count': limit, + }; + + return requestOptions; +} + export async function formatCustomProperties( this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions, @@ -803,7 +823,6 @@ export async function formatCustomProperties( let parsedProperties: Record; try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment parsedProperties = JSON.parse(rawCustomProperties); } catch (error) { throw new NodeApiError( @@ -841,11 +860,9 @@ export async function formatJSONFields( let parsedIndexPolicy: Record | undefined; try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment parsedPartitionKey = JSON.parse(rawPartitionKey); if (indexingPolicy) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment parsedIndexPolicy = JSON.parse(indexingPolicy); } } catch (error) { @@ -880,32 +897,32 @@ export async function processResponseItems( this: IExecuteSingleFunctions, items: INodeExecutionData[], response: IN8nHttpFullResponse, -): Promise { +): Promise { if (!response || typeof response !== 'object' || !Array.isArray(items)) { throw new ApplicationError('Invalid response format from Cosmos DB.'); } - const extractedDocuments: IDataObject[] = items.flatMap((item) => { + const extractedDocuments: INodeExecutionData[] = items.flatMap((item) => { if ( item.json && typeof item.json === 'object' && 'Documents' in item.json && Array.isArray(item.json.Documents) ) { - return item.json.Documents as IDataObject[]; + return item.json.Documents.map((doc) => ({ json: doc })); } return []; }); - return extractedDocuments; + return extractedDocuments.length ? extractedDocuments : [{ json: {} }]; } export async function processResponseContainers( this: IExecuteSingleFunctions, items: INodeExecutionData[], response: IN8nHttpFullResponse, -): Promise { +): Promise { if (!response || typeof response !== 'object' || !Array.isArray(items)) { throw new ApplicationError('Invalid response format from Cosmos DB.'); } diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts index 8c91e6659c..487356b376 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ContainerDescription.ts @@ -1,6 +1,11 @@ import type { INodeProperties } from 'n8n-workflow'; -import { formatJSONFields, processResponseContainers, validateFields } from '../GenericFunctions'; +import { + formatJSONFields, + handleErrorPostReceive, + processResponseContainers, + validateContainerFields, +} from '../GenericFunctions'; export const containerOperations: INodeProperties[] = [ { @@ -20,13 +25,16 @@ export const containerOperations: INodeProperties[] = [ description: 'Create a container', routing: { send: { - preSend: [formatJSONFields, validateFields], + preSend: [formatJSONFields, validateContainerFields], }, request: { ignoreHttpStatusErrors: true, method: 'POST', url: '/colls', }, + output: { + postReceive: [handleErrorPostReceive], + }, }, action: 'Create container', }, @@ -42,6 +50,7 @@ export const containerOperations: INodeProperties[] = [ }, output: { postReceive: [ + handleErrorPostReceive, { type: 'set', properties: { @@ -63,6 +72,9 @@ export const containerOperations: INodeProperties[] = [ method: 'GET', url: '=/colls/{{ $parameter["collId"] }}', }, + output: { + postReceive: [handleErrorPostReceive], + }, }, action: 'Get container', }, @@ -77,7 +89,7 @@ export const containerOperations: INodeProperties[] = [ url: '/colls', }, output: { - postReceive: [processResponseContainers], + postReceive: [handleErrorPostReceive, processResponseContainers], }, }, action: 'Get many containers', @@ -156,19 +168,15 @@ export const createFields: INodeProperties[] = [ description: 'The user specified autoscale max RU/s', }, { - displayName: 'Max RU/s (for Manual Throughput)', + displayName: 'Manual Throughput RU/s', name: 'offerThroughput', type: 'number', default: 400, + typeOptions: { + minValue: 400, + }, description: 'The user specified manual throughput (RU/s) for the collection expressed in units of 100 request units per second', - routing: { - send: { - type: 'query', - property: 'x-ms-offer-throughput', - value: '={{$value}}', - }, - }, }, ], placeholder: 'Add Option', diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts index a42552ffd1..9102831d89 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/descriptions/ItemDescription.ts @@ -4,6 +4,7 @@ import { formatCustomProperties, handleErrorPostReceive, handlePagination, + presendLimitField, processResponseItems, validateOperations, validatePartitionKey, @@ -97,6 +98,7 @@ export const itemOperations: INodeProperties[] = [ routing: { send: { paginate: true, + preSend: [presendLimitField], }, operations: { pagination: handlePagination, @@ -128,7 +130,7 @@ export const itemOperations: INodeProperties[] = [ }, }, output: { - postReceive: [handleErrorPostReceive], + postReceive: [processResponseItems, handleErrorPostReceive], }, }, action: 'Query items', @@ -539,13 +541,6 @@ export const getAllFields: INodeProperties[] = [ returnAll: [false], }, }, - routing: { - send: { - property: 'x-ms-max-item-count', - type: 'query', - value: '={{ $value }}', - }, - }, type: 'number', typeOptions: { minValue: 1, @@ -611,7 +606,7 @@ export const queryFields: INodeProperties[] = [ operation: ['query'], }, }, - placeholder: 'SELECT * FROM c WHERE c.name = @name', + placeholder: 'SELECT * FROM c WHERE c.name = @Name', routing: { send: { type: 'body', diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/FetchPartitionKeyField.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/FetchPartitionKeyField.test.ts new file mode 100644 index 0000000000..7845580f3a --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/FetchPartitionKeyField.test.ts @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { fetchPartitionKeyField } from '../GenericFunctions'; + +describe('GenericFunctions - fetchPartitionKeyField', () => { + const mockMicrosoftCosmosDbRequest = jest.fn(); + + const mockContext = { + helpers: { + requestWithAuthentication: mockMicrosoftCosmosDbRequest, + }, + getNodeParameter: jest.fn(), + getCredentials: jest.fn(), + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + jest.clearAllMocks(); + mockContext.getNode = jest.fn().mockReturnValue({}); + + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'test_database', + baseUrl: 'https://us-east-1.documents.azure.com', + }); + }); + + it('should fetch the partition key successfully', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + + mockMicrosoftCosmosDbRequest.mockResolvedValueOnce({ + partitionKey: { + paths: ['/PartitionKey'], + kind: 'Hash', + version: 2, + }, + }); + + const response = await fetchPartitionKeyField.call(mockContext); + + expect(mockMicrosoftCosmosDbRequest).toHaveBeenCalledWith( + 'microsoftCosmosDbSharedKeyApi', + expect.objectContaining({ + method: 'GET', + url: '/colls/coll-1', + }), + ); + + expect(response).toEqual({ + results: [ + { + name: 'PartitionKey', + value: 'PartitionKey', + }, + ], + }); + }); + + it('should throw an error when container ID is missing', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ mode: 'list', value: '' }); + + await expect(fetchPartitionKeyField.call(mockContext)).rejects.toThrowError( + expect.objectContaining({ + message: 'Container is required to determine the partition key.', + }), + ); + }); + + it('should return an empty array if no partition key is found', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + + mockMicrosoftCosmosDbRequest.mockResolvedValueOnce({ + partitionKey: { + paths: [], + kind: 'Hash', + version: 2, + }, + }); + + const response = await fetchPartitionKeyField.call(mockContext); + + expect(response).toEqual({ results: [] }); + }); + + it('should handle unexpected response format gracefully', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + + mockMicrosoftCosmosDbRequest.mockResolvedValueOnce({ unexpectedKey: 'value' }); + + const response = await fetchPartitionKeyField.call(mockContext); + + expect(response).toEqual({ results: [] }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts index 523cfea187..788d23886d 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/MicrosoftCosmosDbRequest.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { microsoftCosmosDbRequest } from '../GenericFunctions'; describe('GenericFunctions - microsoftCosmosDbRequest', () => { diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchContainers.test.ts similarity index 100% rename from packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchCollections.test.ts rename to packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchContainers.test.ts diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItemById.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItemById.test.ts new file mode 100644 index 0000000000..76fea195c0 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItemById.test.ts @@ -0,0 +1,119 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchItemById } from '../GenericFunctions'; + +describe('GenericFunctions - searchItemById', () => { + const mockRequestWithAuthentication = jest.fn(); + + const mockContext = { + helpers: { + requestWithAuthentication: mockRequestWithAuthentication, + }, + getNodeParameter: jest.fn(), + getCredentials: jest.fn(), + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + jest.clearAllMocks(); + mockContext.getNode = jest.fn().mockReturnValue({}); + }); + + it('should fetch the item successfully', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + const itemId = 'item-123'; + + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'first_database_1', + baseUrl: 'https://us-east-1.documents.azure.com', + }); + + mockRequestWithAuthentication.mockResolvedValueOnce({ + id: itemId, + name: 'Test Item', + }); + + const response = await searchItemById.call(mockContext, itemId); + + expect(mockRequestWithAuthentication).toHaveBeenCalledWith( + 'microsoftCosmosDbSharedKeyApi', + expect.objectContaining({ + method: 'GET', + url: '/colls/coll-1/docs/item-123', + }), + ); + + expect(response).toEqual({ + id: itemId, + name: 'Test Item', + }); + }); + + it('should throw an error when container ID is missing', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ mode: 'list', value: '' }); + + await expect(searchItemById.call(mockContext, 'item-123')).rejects.toThrowError( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect.objectContaining({ + message: 'Container is required', + }), + ); + }); + + it('should throw an error when item ID is missing', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + + await expect(searchItemById.call(mockContext, '')).rejects.toThrowError( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect.objectContaining({ + message: 'Item is required', + }), + ); + }); + + it('should return null if the response is empty', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + const itemId = 'item-123'; + + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'first_database_1', + baseUrl: 'https://us-east-1.documents.azure.com', + }); + + mockRequestWithAuthentication.mockResolvedValueOnce(null); + + const response = await searchItemById.call(mockContext, itemId); + + expect(response).toBeNull(); + }); + + it('should handle unexpected response format gracefully', async () => { + (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ + mode: 'list', + value: 'coll-1', + }); + const itemId = 'item-123'; + + (mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ + account: 'us-east-1', + database: 'first_database_1', + baseUrl: 'https://us-east-1.documents.azure.com', + }); + + mockRequestWithAuthentication.mockResolvedValueOnce({ unexpectedKey: 'value' }); + + const response = await searchItemById.call(mockContext, itemId); + + expect(response).toEqual({ unexpectedKey: 'value' }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts index 71633f8c8a..9fc1f1af4f 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/test/SearchItems.test.ts @@ -15,6 +15,7 @@ describe('GenericFunctions - searchItems', () => { beforeEach(() => { jest.clearAllMocks(); + mockContext.getNode = jest.fn().mockReturnValue({}); }); it('should fetch documents and return formatted results', async () => { @@ -45,7 +46,7 @@ describe('GenericFunctions - searchItems', () => { expect(response).toEqual({ results: [ - { name: 'Item1', value: 'Item 1' }, // Space removed from 'Item 1' + { name: 'Item1', value: 'Item 1' }, { name: 'Item2', value: 'Item 2' }, ], }); @@ -119,6 +120,11 @@ describe('GenericFunctions - searchItems', () => { it('should throw an error when container ID is missing', async () => { (mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ mode: 'list', value: '' }); - await expect(searchItems.call(mockContext)).rejects.toThrow('Container is required'); + await expect(searchItems.call(mockContext)).rejects.toThrowError( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect.objectContaining({ + message: 'Container is required', + }), + ); }); }); From aa6eca12efe23b1d30ad4e795e359c58645161dd Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Mon, 10 Feb 2025 13:27:48 +0200 Subject: [PATCH 18/19] Fixed authentication error --- ...crosoftCosmosDbSharedKeyApi.credentials.ts | 30 +++++++++---------- .../Microsoft/CosmosDB/GenericFunctions.ts | 11 ++----- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts index 66c4248df7..581dc24449 100644 --- a/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftCosmosDbSharedKeyApi.credentials.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ import { ApplicationError, type ICredentialDataDecryptedObject, @@ -64,11 +65,13 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { } requestOptions.headers ??= {}; + const date = new Date().toUTCString().toLowerCase(); requestOptions.headers = { ...requestOptions.headers, 'x-ms-date': date, 'x-ms-version': '2018-12-31', + 'Cache-Control': 'no-cache', }; if (credentials.sessionToken) { @@ -76,7 +79,6 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { } let url; - if (requestOptions.url) { url = new URL(requestOptions.baseURL + requestOptions.url); //@ts-ignore @@ -86,7 +88,6 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { } const pathSegments = url?.pathname.split('/').filter((segment) => segment); - let resourceType = ''; let resourceId = ''; @@ -116,23 +117,22 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType { throw new ApplicationError('Unable to determine resourceType and resourceId from the URL.'); } - console.log('resourceId', resourceId); - console.log('resourceType', resourceType); - if (requestOptions.method) { - const authToken = getAuthorizationTokenUsingMasterKey( - requestOptions.method, - resourceType, - resourceId, - date, - credentials.key as string, - ); + let authToken = ''; - requestOptions.headers[HeaderConstants.AUTHORIZATION] = authToken; + if (credentials.key) { + authToken = getAuthorizationTokenUsingMasterKey( + requestOptions.method, + resourceType, + resourceId, + credentials.key as string, + ); + } + + requestOptions.headers[HeaderConstants.AUTHORIZATION] = encodeURIComponent(authToken); + await new Promise((resolve) => setTimeout(resolve, 500)); } - console.log('Final requestOptions:', requestOptions); - return requestOptions; } } diff --git a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts index ae66d76aca..c92e5c9489 100644 --- a/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Microsoft/CosmosDB/GenericFunctions.ts @@ -45,17 +45,12 @@ export function getAuthorizationTokenUsingMasterKey( verb: string, resourceType: string, resourceId: string, - date: string, masterKey: string, ): string { - const key = Buffer.from(masterKey, 'base64'); - const payload = - `${verb.toLowerCase()}\n` + - `${resourceType.toLowerCase()}\n` + - `${resourceId}\n` + - `${date.toLowerCase()}\n` + - '\n'; + const date = new Date().toUTCString().toLowerCase(); + const key = Buffer.from(masterKey, 'base64'); + const payload = `${verb.toLowerCase()}\n${resourceType.toLowerCase()}\n${resourceId}\n${date.toLowerCase()}\n\n`; const hmacSha256 = crypto.createHmac('sha256', key); const signature = hmacSha256.update(payload, 'utf8').digest('base64'); From 86775da09f856e687ca6627e73c1340c2bffe367 Mon Sep 17 00:00:00 2001 From: Adina Totorean Date: Mon, 10 Feb 2025 14:11:10 +0200 Subject: [PATCH 19/19] Missing file --- pnpm-lock.yaml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae1b8913a5..a2f95dc8c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,9 +201,6 @@ importers: lefthook: specifier: ^1.7.15 version: 1.7.15 - loader: - specifier: ^2.1.1 - version: 2.1.1 nock: specifier: ^14.0.0 version: 14.0.0 @@ -21616,7 +21613,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) is-core-module: 2.13.1 resolve: 1.22.8 transitivePeerDependencies: @@ -21641,7 +21638,7 @@ snapshots: eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) optionalDependencies: '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.7.2) eslint: 8.57.0 @@ -21661,7 +21658,7 @@ snapshots: array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 @@ -22445,7 +22442,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 4.0.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22757,7 +22754,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.7.4(debug@4.3.7)) + retry-axios: 2.6.0(axios@1.7.4) tough-cookie: 4.1.3 transitivePeerDependencies: - supports-color @@ -25316,7 +25313,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -26130,7 +26127,7 @@ snapshots: ret@0.1.15: {} - retry-axios@2.6.0(axios@1.7.4(debug@4.3.7)): + retry-axios@2.6.0(axios@1.7.4): dependencies: axios: 1.7.4 @@ -26157,7 +26154,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color