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: [] }); + }); +});