Worked on credentials, tests and fields for both resources

This commit is contained in:
Adina Totorean 2025-01-29 10:00:34 +02:00
parent 6307ac162f
commit 541f289466
8 changed files with 578 additions and 582 deletions

View file

@ -33,12 +33,27 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType {
}, },
default: '', 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( async authenticate(
credentials: ICredentialDataDecryptedObject, credentials: ICredentialDataDecryptedObject,
requestOptions: IHttpRequestOptions, requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> { ): Promise<IHttpRequestOptions> {
console.log('Authenticate invoked with requestOptions:', requestOptions);
if (requestOptions.qs) { if (requestOptions.qs) {
for (const [key, value] of Object.entries(requestOptions.qs)) { for (const [key, value] of Object.entries(requestOptions.qs)) {
if (value === undefined) { if (value === undefined) {
@ -52,7 +67,7 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType {
requestOptions.headers = { requestOptions.headers = {
...requestOptions.headers, ...requestOptions.headers,
'x-ms-date': date, 'x-ms-date': date,
'x-ms-version': '2020-04-08', 'x-ms-version': '2018-12-31',
}; };
if (credentials.sessionToken) { if (credentials.sessionToken) {
@ -60,40 +75,16 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType {
} }
let resourceType = ''; let resourceType = '';
let resourceLink = ''; const resourceLink = requestOptions.url;
if (requestOptions.body && typeof requestOptions.body === 'object') {
const isCollectionRequest = 'colls' in requestOptions.body;
const isDocumentRequest = 'docs' in requestOptions.body;
if (isCollectionRequest) { if (resourceLink.includes('/colls')) {
resourceType = 'dbs';
resourceLink = `dbs/${credentials.database}/colls`;
} else if (isDocumentRequest) {
resourceType = 'colls'; resourceType = 'colls';
const collId = requestOptions.qs?.collId || ''; } else if (resourceLink.includes('/docs')) {
if (!collId) { resourceType = 'docs';
throw new ApplicationError('Collection ID (collId) is required for document requests.'); } else if (resourceLink.includes('/dbs')) {
}
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'; 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 { } else {
throw new ApplicationError( throw new ApplicationError('Unable to determine resourceType');
'Invalid requestOptions: Either body or query string (qs) is required.',
);
} }
if (requestOptions.method) { if (requestOptions.method) {
@ -105,9 +96,11 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType {
credentials.key as string, credentials.key as string,
); );
requestOptions.headers.authorization = authToken; requestOptions.headers.Authorization = authToken;
} }
console.log('Final requestOptions headers:', requestOptions.headers);
return requestOptions; return requestOptions;
} }
} }

View file

@ -3,7 +3,7 @@ import { NodeConnectionType } from 'n8n-workflow';
import { containerFields, containerOperations } from './descriptions/ContainerDescription'; import { containerFields, containerOperations } from './descriptions/ContainerDescription';
import { itemFields, itemOperations } from './descriptions/ItemDescription'; import { itemFields, itemOperations } from './descriptions/ItemDescription';
import { searchCollections, searchDatabases } from './GenericFunctions'; import { searchCollections } from './GenericFunctions';
export class AzureCosmosDb implements INodeType { export class AzureCosmosDb implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@ -34,9 +34,10 @@ export class AzureCosmosDb implements INodeType {
}, },
], ],
requestDefaults: { requestDefaults: {
baseURL: '=https://{$credentials.account}.documents.azure.com', baseURL: '={{$credentials.baseUrl}}',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json',
}, },
}, },
properties: [ properties: [
@ -79,7 +80,6 @@ export class AzureCosmosDb implements INodeType {
methods = { methods = {
listSearch: { listSearch: {
searchCollections, searchCollections,
searchDatabases,
}, },
}; };
} }

View file

@ -10,7 +10,6 @@ import type {
INodeListSearchResult, INodeListSearchResult,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow';
import * as querystring from 'querystring';
// export const HeaderConstants = { // export const HeaderConstants = {
// // Required // // Required
@ -49,7 +48,6 @@ export function getAuthorizationTokenUsingMasterKey(
masterKey: string, masterKey: string,
): string { ): string {
const key = Buffer.from(masterKey, 'base64'); const key = Buffer.from(masterKey, 'base64');
const payload = const payload =
`${verb.toLowerCase()}\n` + `${verb.toLowerCase()}\n` +
`${resourceType.toLowerCase()}\n` + `${resourceType.toLowerCase()}\n` +
@ -60,7 +58,7 @@ export function getAuthorizationTokenUsingMasterKey(
const hmacSha256 = crypto.createHmac('sha256', key); const hmacSha256 = crypto.createHmac('sha256', key);
const hashPayload = hmacSha256.update(payload, 'utf8').digest('base64'); 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; return authorizationString;
} }
@ -134,6 +132,10 @@ export async function azureCosmosDbRequest(
const requestOptions: IHttpRequestOptions = { const requestOptions: IHttpRequestOptions = {
...opts, ...opts,
baseURL: `https://${databaseAccount}.documents.azure.com`, baseURL: `https://${databaseAccount}.documents.azure.com`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
json: true, json: true,
}; };
@ -150,6 +152,8 @@ export async function azureCosmosDbRequest(
}; };
try { try {
console.log('Final Request Options before Request:', requestOptions);
return (await this.helpers.requestWithAuthentication.call( return (await this.helpers.requestWithAuthentication.call(
this, this,
'azureCosmosDbSharedKeyApi', 'azureCosmosDbSharedKeyApi',
@ -188,25 +192,9 @@ export async function searchCollections(
this: ILoadOptionsFunctions, this: ILoadOptionsFunctions,
filter?: string, filter?: string,
): Promise<INodeListSearchResult> { ): Promise<INodeListSearchResult> {
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 = { const opts: IHttpRequestOptions = {
method: 'GET', method: 'GET',
url: `/dbs/${dbId}/colls`, url: '/colls',
baseURL: `https://${databaseAccount}.documents.azure.com`,
headers: {
'Content-Type': 'application/json',
},
}; };
const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts);
@ -233,38 +221,36 @@ export async function searchCollections(
}; };
} }
export async function searchDatabases( // export async function searchDatabases(
this: ILoadOptionsFunctions, // this: ILoadOptionsFunctions,
filter?: string, // filter?: string,
): Promise<INodeListSearchResult> { // ): Promise<INodeListSearchResult> {
const opts: IHttpRequestOptions = {
method: 'GET',
url: '/dbs',
headers: {
'Content-Type': 'application/json',
},
};
const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts); // const opts: IHttpRequestOptions = {
// method: 'GET',
// url: '/dbs',
// };
const responseBody = responseData as { // const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts);
Databases: IDataObject[]; // console.log('Got this response', responseData)
}; // const responseBody = responseData as {
const databases = responseBody.Databases; // Databases: IDataObject[];
// };
// const databases = responseBody.Databases;
if (!databases) { // if (!databases) {
return { results: [] }; // return { results: [] };
} // }
const results: INodeListSearchItems[] = databases // const results: INodeListSearchItems[] = databases
.map((database) => ({ // .map((database) => ({
name: String(database.id), // name: String(database.id),
value: String(database.id), // value: String(database.id),
})) // }))
.filter((database) => !filter || database.name.includes(filter)) // .filter((database) => !filter || database.name.includes(filter))
.sort((a, b) => a.name.localeCompare(b.name)); // .sort((a, b) => a.name.localeCompare(b.name));
return { // return {
results, // results,
}; // };
} // }

View file

@ -20,7 +20,7 @@ export const containerOperations: INodeProperties[] = [
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'POST', method: 'POST',
url: '=/dbs/{{ $parameter["dbId"] }}/colls', url: '/colls',
}, },
}, },
action: 'Create container', action: 'Create container',
@ -33,7 +33,7 @@ export const containerOperations: INodeProperties[] = [
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'DELETE', method: 'DELETE',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}', url: '=/colls/{{ $parameter["collId"] }}',
}, },
}, },
action: 'Delete container', action: 'Delete container',
@ -46,7 +46,7 @@ export const containerOperations: INodeProperties[] = [
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'GET', method: 'GET',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}', url: '=/colls/{{ $parameter["collId"] }}',
}, },
}, },
action: 'Get container', action: 'Get container',
@ -59,7 +59,7 @@ export const containerOperations: INodeProperties[] = [
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'GET', method: 'GET',
url: '=/dbs/{{ $parameter["dbId"] }}/colls', url: '/colls',
}, },
}, },
action: 'Get many containers', action: 'Get many containers',
@ -70,50 +70,6 @@ export const containerOperations: INodeProperties[] = [
]; ];
export const createFields: 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', displayName: 'ID',
name: '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', placeholder: 'Add Option',
type: 'collection', type: 'collection',
@ -192,50 +187,6 @@ export const createFields: INodeProperties[] = [
]; ];
export const getFields: 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', displayName: 'Container ID',
name: 'collId', name: 'collId',
@ -282,98 +233,9 @@ export const getFields: INodeProperties[] = [
}, },
]; ];
export const getAllFields: 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 deleteFields: 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', displayName: 'Container ID',
name: 'collId', name: 'collId',

View file

@ -22,7 +22,7 @@ export const itemOperations: INodeProperties[] = [
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'POST', method: 'POST',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs', url: '=/colls/{{ $parameter["collId"] }}/docs',
}, },
}, },
action: 'Create item', action: 'Create item',
@ -35,7 +35,7 @@ export const itemOperations: INodeProperties[] = [
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'DELETE', method: 'DELETE',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
}, },
}, },
action: 'Delete item', action: 'Delete item',
@ -45,10 +45,20 @@ export const itemOperations: INodeProperties[] = [
value: 'get', value: 'get',
description: 'Retrieve an item', description: 'Retrieve an item',
routing: { routing: {
send: {
preSend: [
async function (
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
return requestOptions;
},
],
},
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'GET', method: 'GET',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
}, },
}, },
action: 'Get item', action: 'Get item',
@ -67,7 +77,7 @@ export const itemOperations: INodeProperties[] = [
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'GET', method: 'GET',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs', url: '=/colls/{{ $parameter["collId"] }}/docs',
}, },
}, },
action: 'Get many items', action: 'Get many items',
@ -80,7 +90,7 @@ export const itemOperations: INodeProperties[] = [
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'POST', method: 'POST',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs', url: '=/colls/{{ $parameter["collId"] }}/docs',
headers: { headers: {
'Content-Type': 'application/query+json', 'Content-Type': 'application/query+json',
'x-ms-documentdb-isquery': 'True', 'x-ms-documentdb-isquery': 'True',
@ -97,7 +107,7 @@ export const itemOperations: INodeProperties[] = [
request: { request: {
ignoreHttpStatusErrors: true, ignoreHttpStatusErrors: true,
method: 'PATCH', method: 'PATCH',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}', url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
}, },
}, },
action: 'Update item', action: 'Update item',
@ -108,50 +118,6 @@ export const itemOperations: INodeProperties[] = [
]; ];
export const createFields: 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', displayName: 'Collection ID',
name: 'collId', name: 'collId',
@ -196,28 +162,28 @@ export const createFields: INodeProperties[] = [
}, },
], ],
}, },
{ // {
displayName: 'ID', // displayName: 'ID',
name: 'id', // name: 'id',
type: 'string', // type: 'string',
default: '', // default: '',
placeholder: 'e.g. AndersenFamily', // placeholder: 'e.g. AndersenFamily',
description: "Item's ID", // description: "Item's ID",
required: true, // required: true,
displayOptions: { // displayOptions: {
show: { // show: {
resource: ['item'], // resource: ['item'],
operation: ['create'], // operation: ['create'],
}, // },
}, // },
routing: { // routing: {
send: { // send: {
type: 'body', // type: 'body',
property: 'id', // property: 'id',
value: '={{$value}}', // value: '={{$value}}',
}, // },
}, // },
}, // },
{ {
displayName: 'Custom Properties', displayName: 'Custom Properties',
name: 'customProperties', name: 'customProperties',
@ -235,58 +201,13 @@ export const createFields: INodeProperties[] = [
routing: { routing: {
send: { send: {
type: 'body', type: 'body',
property: '', value: '={{ $json["id"] ? Object.assign({ id: $json["id"] }, $value) : $value }}',
value: '={{$value}}',
}, },
}, },
}, },
]; ];
export const deleteFields: 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: ['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', displayName: 'Collection ID',
name: 'collId', name: 'collId',
@ -349,50 +270,6 @@ export const deleteFields: INodeProperties[] = [
]; ];
export const getFields: 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', displayName: 'Collection ID',
name: 'collId', name: 'collId',
@ -451,61 +328,10 @@ export const getFields: INodeProperties[] = [
operation: ['get'], operation: ['get'],
}, },
}, },
routing: {
send: {
type: 'body',
property: 'id',
value: '={{$value}}',
},
},
}, },
]; ];
export const getAllFields: 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: ['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', displayName: 'Collection ID',
name: 'collId', name: 'collId',
@ -561,18 +387,6 @@ export const getAllFields: INodeProperties[] = [
operation: ['getAll'], operation: ['getAll'],
}, },
}, },
routing: {
send: {
preSend: [
async function (
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
return requestOptions;
},
],
},
},
type: 'boolean', type: 'boolean',
}, },
{ {
@ -603,50 +417,6 @@ export const getAllFields: INodeProperties[] = [
]; ];
export const queryFields: 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', displayName: 'Collection ID',
name: 'collId', name: 'collId',
@ -723,6 +493,12 @@ export const queryFields: INodeProperties[] = [
typeOptions: { typeOptions: {
multipleValues: true, multipleValues: true,
}, },
displayOptions: {
show: {
resource: ['item'],
operation: ['query'],
},
},
options: [ options: [
{ {
name: 'parameters', name: 'parameters',
@ -756,50 +532,6 @@ export const queryFields: INodeProperties[] = [
]; ];
export const updateFields: 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', displayName: 'Collection ID',
name: 'collId', name: 'collId',

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

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