This commit is contained in:
AdinaTotorean 2025-03-05 17:40:54 +01:00 committed by GitHub
commit 3151f6bfb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 3043 additions and 1 deletions

View file

@ -69,7 +69,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": {

View file

@ -0,0 +1,138 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import {
ApplicationError,
type ICredentialDataDecryptedObject,
type ICredentialType,
type IHttpRequestOptions,
type INodeProperties,
} from 'n8n-workflow';
import {
getAuthorizationTokenUsingMasterKey,
HeaderConstants,
} from '../nodes/Microsoft/CosmosDB/GenericFunctions';
export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType {
name = 'microsoftCosmosDbSharedKeyApi';
displayName = 'Cosmos DB API';
documentationUrl = 'microsoftCosmosDb';
properties: INodeProperties[] = [
{
displayName: 'Account',
name: 'account',
description: 'Account name',
type: 'string',
default: '',
},
{
displayName: 'Key',
name: 'key',
description: 'Account key',
type: 'string',
typeOptions: {
password: true,
},
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<IHttpRequestOptions> {
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().toLowerCase();
requestOptions.headers = {
...requestOptions.headers,
'x-ms-date': date,
'x-ms-version': '2018-12-31',
'Cache-Control': 'no-cache',
};
if (credentials.sessionToken) {
requestOptions.headers['x-ms-session-token'] = credentials.sessionToken;
}
let url;
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')) {
const docsIndex = pathSegments.lastIndexOf('docs');
resourceType = 'docs';
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';
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';
resourceId = pathSegments.slice(0, dbsIndex + 2).join('/');
} else {
throw new ApplicationError('Unable to determine resourceType and resourceId from the URL.');
}
if (requestOptions.method) {
let 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));
}
return requestOptions;
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M105.825 58.635c5.491 22.449-8.458 45.049-31.156 50.48-22.695 5.429-45.547-8.365-51.036-30.815-5.492-22.449 8.458-45.049 31.153-50.48l.078-.018c22.588-5.46 45.374 8.223 50.896 30.565l.065.268Z" fill="#59B3D8"/><path fill-rule="evenodd" clip-rule="evenodd" d="M58.747 85.194c-.013-6.137-5.055-11.1-11.259-11.085h-1.701c1.442-5.932-2.248-11.895-8.246-13.322a11.182 11.182 0 0 0-2.703-.306H23.162c-2.525 12.851 1.246 26.127 10.174 35.796h14.149c6.205.015 11.247-4.948 11.262-11.083ZM72.69 39.233c0 .649.085 1.296.255 1.925h-4.861c-6.445 0-11.667 5.168-11.667 11.543 0 6.372 5.222 11.54 11.667 11.54h38.645c-1.258-13.787-9.486-26.01-21.862-32.477h-4.605c-4.177-.002-7.562 3.339-7.572 7.469Zm34.043 33.587H83.679c-5.259-.013-9.531 4.187-9.552 9.385a9.241 9.241 0 0 0 1.148 4.471c-5.003 1.546-7.792 6.814-6.228 11.765 1.242 3.934 4.938 6.607 9.106 6.589h6.427c12.314-6.454 20.607-18.51 22.153-32.21Z" fill="#B6DEEC"/><path fill-rule="evenodd" clip-rule="evenodd" d="M17.382 40.624a1.286 1.286 0 0 1-1.3-1.275v-.003c-.021-8.064-6.637-14.587-14.79-14.579A1.286 1.286 0 0 1 0 23.489c0-.703.579-1.275 1.292-1.275 8.143.007 14.756-6.503 14.79-14.554a1.29 1.29 0 0 1 1.356-1.227c.672.028 1.21.56 1.241 1.227.021 8.061 6.639 14.584 14.792 14.577.713 0 1.292.572 1.292 1.277 0 .706-.579 1.279-1.292 1.279-8.148-.011-14.766 6.507-14.792 14.566-.01.7-.589 1.265-1.297 1.265Z" fill="#B7D332"/><path fill-rule="evenodd" clip-rule="evenodd" d="M108.6 122.793a.764.764 0 0 1-.768-.759c-.018-4.821-3.98-8.719-8.854-8.709a.762.762 0 0 1-.77-.756c0-.419.342-.759.765-.759h.005c4.872.002 8.826-3.893 8.844-8.711a.77.77 0 0 1 .778-.767.77.77 0 0 1 .775.767c.018 4.818 3.972 8.713 8.843 8.711a.761.761 0 0 1 .77.756.759.759 0 0 1-.765.759h-.005c-4.871-.002-8.828 3.893-8.843 8.714a.764.764 0 0 1-.773.754h-.002Z" fill="#0072C5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M126.317 30.84c-4.035-6.539-14.175-8.049-29.306-4.384a121.688 121.688 0 0 0-13.893 4.384 42.829 42.829 0 0 1 8.187 5.173c2.574-.836 5.101-1.59 7.512-2.173a53.33 53.33 0 0 1 12.335-1.727c4.957 0 7.691 1.211 8.606 2.686 1.496 2.423.119 8.816-8.681 18.871-1.566 1.789-3.326 3.601-5.179 5.423a175.936 175.936 0 0 1-31.843 24.149 176.032 176.032 0 0 1-36.329 17.105c-15.317 4.936-25.773 4.836-28.119 1.048-2.342-3.788 2.344-13.048 13.776-24.29a41.005 41.005 0 0 1-.938-9.735c-18.2 16.271-24.09 30.365-19.387 37.981 2.463 3.985 7.844 6.229 15.705 6.229a80.772 80.772 0 0 0 27.183-5.932 194.648 194.648 0 0 0 32.11-15.926 193.405 193.405 0 0 0 28.884-21.148 118.565 118.565 0 0 0 9.947-9.941c10.207-11.655 13.466-21.268 9.43-27.793Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,70 @@
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { containerFields, containerOperations } from './descriptions/ContainerDescription';
import { itemFields, itemOperations } from './descriptions/ItemDescription';
import { getProperties, searchContainers, searchItems } from './GenericFunctions';
export class CosmosDb implements INodeType {
description: INodeTypeDescription = {
displayName: 'Cosmos DB',
name: 'cosmosDb',
icon: {
light: 'file:CosmosDB.svg',
dark: 'file:CosmosDB.svg',
},
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Interact with Cosmos DB API',
defaults: {
name: 'Cosmos Db',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [
{
name: 'microsoftCosmosDbSharedKeyApi',
required: true,
},
],
requestDefaults: {
baseURL: '={{$credentials.baseUrl}}',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Container',
value: 'container',
},
{
name: 'Item',
value: 'item',
},
],
default: 'container',
},
...itemOperations,
...itemFields,
...containerOperations,
...containerFields,
],
};
methods = {
listSearch: {
searchContainers,
searchItems,
getProperties,
},
};
}

View file

@ -0,0 +1,932 @@
/* 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,
IDataObject,
IExecutePaginationFunctions,
IExecuteSingleFunctions,
IHttpRequestOptions,
ILoadOptionsFunctions,
IN8nHttpFullResponse,
INodeExecutionData,
INodeListSearchItems,
INodeListSearchResult,
} from 'n8n-workflow';
import { ApplicationError, NodeApiError } from 'n8n-workflow';
export const HeaderConstants = {
AUTHORIZATION: 'Authorization',
CONTENT_TYPE: 'Content-Type',
X_MS_DATE: 'x-ms-date',
X_MS_VERSION: 'x-ms-version',
X_MS_SESSION_TOKEN: 'x-ms-session-token',
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-',
};
export function getAuthorizationTokenUsingMasterKey(
verb: string,
resourceType: string,
resourceId: string,
masterKey: string,
): string {
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');
return `type=master&ver=1.0&sig=${signature}`;
}
export async function microsoftCosmosDbRequest(
this: ILoadOptionsFunctions,
opts: IHttpRequestOptions,
): Promise<IDataObject> {
const credentials = await this.getCredentials('microsoftCosmosDbSharedKeyApi');
const databaseAccount = credentials?.account;
if (!databaseAccount) {
throw new ApplicationError('Database account not found in credentials!', { level: 'error' });
}
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,
Accept: 'application/json',
'Content-Type': 'application/json',
},
json: true,
};
const errorMapping: Record<number, Record<string, string>> = {
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!',
'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,
'microsoftCosmosDbSharedKeyApi',
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 fetchPartitionKeyField(
this: ILoadOptionsFunctions,
): Promise<INodeListSearchResult> {
const collection = this.getNodeParameter('collId', '') as { mode: string; value: string };
if (!collection?.value) {
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 = {
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 validateQueryParameters(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
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,
): Promise<IHttpRequestOptions> {
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<string, unknown>;
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;
}
export async function validateOperations(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
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"',
},
);
}
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) }
: operation.value !== undefined
? { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) }
: {}),
};
});
requestOptions.body = transformedOperations;
return requestOptions;
}
export async function validateContainerFields(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject;
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 Manual Throughput RU/s',
},
);
}
if (autoscaleThroughput) {
requestOptions.headers = {
...requestOptions.headers,
'x-ms-cosmos-offer-autopilot-setting': { maxThroughput: autoscaleThroughput },
};
}
if (manualThroughput) {
requestOptions.headers = {
...requestOptions.headers,
'x-ms-offer-throughput': manualThroughput,
};
}
return requestOptions;
}
export async function handlePagination(
this: IExecutePaginationFunctions,
resultOptions: DeclarativeRestApiSettings.ResultOptions,
): Promise<INodeExecutionData[]> {
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 }));
}
}
}
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<INodeExecutionData[]> {
if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) {
const responseBody = response.body as IDataObject;
let errorMessage = 'Unknown error occurred';
let errorDescription = 'An unexpected error was encountered.';
if (typeof responseBody === 'object' && responseBody !== null) {
if (typeof responseBody.code === 'string') {
errorMessage = responseBody.code;
}
if (typeof responseBody.message === 'string') {
errorDescription = responseBody.message;
}
}
throw new NodeApiError(
this.getNode(),
{},
{
message: errorMessage,
description: errorDescription,
},
);
}
return data;
}
export async function searchContainers(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
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<INodeListSearchResult> {
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: IDataObject, 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 as IDataObject, newPath));
} else {
paths.push(newPath);
}
});
return paths;
}
export async function searchItemById(
this: ILoadOptionsFunctions,
itemId: string,
): Promise<IDataObject | null> {
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<INodeListSearchResult> {
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 presendLimitField(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
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,
): Promise<IHttpRequestOptions> {
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<string, unknown>;
try {
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<string, unknown>, { id: newId }, parsedProperties);
return requestOptions;
}
export async function formatJSONFields(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const rawPartitionKey = this.getNodeParameter('partitionKey', '{}') as string;
const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject;
const indexingPolicy = additionalFields.indexingPolicy as string;
let parsedPartitionKey: Record<string, unknown>;
let parsedIndexPolicy: Record<string, unknown> | undefined;
try {
parsedPartitionKey = JSON.parse(rawPartitionKey);
if (indexingPolicy) {
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<string, unknown>).partitionKey = parsedPartitionKey;
if (parsedIndexPolicy) {
(requestOptions.body as Record<string, unknown>).indexingPolicy = parsedIndexPolicy;
}
return requestOptions;
}
export async function processResponseItems(
this: IExecuteSingleFunctions,
items: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (!response || typeof response !== 'object' || !Array.isArray(items)) {
throw new ApplicationError('Invalid response format from Cosmos DB.');
}
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.map((doc) => ({ json: doc }));
}
return [];
});
return extractedDocuments.length ? extractedDocuments : [{ json: {} }];
}
export async function processResponseContainers(
this: IExecuteSingleFunctions,
items: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
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 [];
}

View file

@ -0,0 +1,288 @@
import type { INodeProperties } from 'n8n-workflow';
import {
formatJSONFields,
handleErrorPostReceive,
processResponseContainers,
validateContainerFields,
} from '../GenericFunctions';
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: {
send: {
preSend: [formatJSONFields, validateContainerFields],
},
request: {
ignoreHttpStatusErrors: true,
method: 'POST',
url: '/colls',
},
output: {
postReceive: [handleErrorPostReceive],
},
},
action: 'Create container',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a container',
routing: {
request: {
ignoreHttpStatusErrors: true,
method: 'DELETE',
url: '=/colls/{{ $parameter["collId"] }}',
},
output: {
postReceive: [
handleErrorPostReceive,
{
type: 'set',
properties: {
value: '={{ { "success": true } }}',
},
},
],
},
},
action: 'Delete container',
},
{
name: 'Get',
value: 'get',
description: 'Retrieve a container',
routing: {
request: {
ignoreHttpStatusErrors: true,
method: 'GET',
url: '=/colls/{{ $parameter["collId"] }}',
},
output: {
postReceive: [handleErrorPostReceive],
},
},
action: 'Get container',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Retrieve a list of containers',
routing: {
request: {
ignoreHttpStatusErrors: true,
method: 'GET',
url: '/colls',
},
output: {
postReceive: [handleErrorPostReceive, processResponseContainers],
},
},
action: 'Get many containers',
},
],
default: 'getAll',
},
];
export const createFields: INodeProperties[] = [
{
displayName: 'ID',
name: 'newid',
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'],
},
},
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
default: {},
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',
},
{
displayName: 'Max RU/s (for Autoscale)',
name: 'maxThroughput',
type: 'number',
typeOptions: {
minValue: 1000,
},
default: 1000,
description: 'The user specified autoscale max RU/s',
},
{
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',
},
],
placeholder: 'Add Option',
type: 'collection',
},
];
export const getFields: INodeProperties[] = [
{
displayName: 'Container',
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: 'searchContainers',
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[] = [];
export const deleteFields: INodeProperties[] = [
{
displayName: 'Container',
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: 'searchContainers',
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,
];

View file

@ -0,0 +1,937 @@
import type { INodeProperties } from 'n8n-workflow';
import {
formatCustomProperties,
handleErrorPostReceive,
handlePagination,
presendLimitField,
processResponseItems,
validateOperations,
validatePartitionKey,
validateQueryParameters,
} from '../GenericFunctions';
export const itemOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['item'],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a new item',
routing: {
send: {
preSend: [formatCustomProperties, validatePartitionKey],
},
request: {
method: 'POST',
url: '=/colls/{{ $parameter["collId"] }}/docs',
headers: {
'x-ms-documentdb-is-upsert': 'True',
},
},
output: {
postReceive: [handleErrorPostReceive],
},
},
action: 'Create item',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete an existing item',
routing: {
send: {
preSend: [validatePartitionKey],
},
request: {
method: 'DELETE',
url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
},
output: {
postReceive: [
handleErrorPostReceive,
{
type: 'set',
properties: {
value: '={{ { "success": true } }}',
},
},
],
},
},
action: 'Delete item',
},
{
name: 'Get',
value: 'get',
description: 'Retrieve an item',
routing: {
send: {
preSend: [validatePartitionKey],
},
request: {
method: 'GET',
url: '=/colls/{{ $parameter["collId"]}}/docs/{{$parameter["id"]}}',
headers: {
'x-ms-documentdb-is-upsert': 'True',
},
},
output: {
postReceive: [handleErrorPostReceive],
},
},
action: 'Get item',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Retrieve a list of items',
routing: {
send: {
paginate: true,
preSend: [presendLimitField],
},
operations: {
pagination: handlePagination,
},
request: {
method: 'GET',
url: '=/colls/{{ $parameter["collId"] }}/docs',
},
output: {
postReceive: [processResponseItems, handleErrorPostReceive],
},
},
action: 'Get many items',
},
{
name: 'Query',
value: 'query',
description: 'Query items',
routing: {
send: {
preSend: [validateQueryParameters],
},
request: {
method: 'POST',
url: '=/colls/{{ $parameter["collId"] }}/docs',
headers: {
'Content-Type': 'application/query+json',
'x-ms-documentdb-isquery': 'True',
},
},
output: {
postReceive: [processResponseItems, handleErrorPostReceive],
},
},
action: 'Query items',
},
{
name: 'Update',
value: 'update',
description: 'Update an existing item',
routing: {
send: {
preSend: [validateOperations, validatePartitionKey],
},
request: {
method: 'PATCH',
url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
headers: {
'Content-Type': 'application/json-patch+json',
},
},
output: {
postReceive: [handleErrorPostReceive],
},
},
action: 'Update item',
},
],
default: 'getAll',
},
];
export const createFields: INodeProperties[] = [
{
displayName: 'Container',
name: 'collId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the container you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['create'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchContainers',
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. UsersContainer',
},
],
},
{
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',
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'],
},
},
},
];
export const deleteFields: INodeProperties[] = [
{
displayName: 'Container',
name: 'collId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the container you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['delete'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchContainers',
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. UsersContainer',
},
],
},
{
displayName: 'Item',
name: 'id',
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 ID',
name: 'itemId',
type: 'string',
hint: 'Enter the item id',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The item id must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. AndersenFamily',
},
],
},
{
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[] = [
{
displayName: 'Container',
name: 'collId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the container you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['get'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchContainers',
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. UsersContainer',
},
],
},
{
displayName: 'Item',
name: '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 ID',
name: 'itemId',
type: 'string',
hint: 'Enter the item id',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The item id must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. AndersenFamily',
},
],
},
{
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[] = [
{
displayName: 'Container',
name: 'collId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the container you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['getAll'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchContainers',
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. UsersContainer',
},
],
},
{
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'],
},
},
type: 'boolean',
},
{
displayName: 'Limit',
name: 'limit',
default: 50,
description: 'Max number of results to return',
displayOptions: {
show: {
resource: ['item'],
operation: ['getAll'],
returnAll: [false],
},
},
type: 'number',
typeOptions: {
minValue: 1,
},
validateType: 'number',
},
];
export const queryFields: INodeProperties[] = [
{
displayName: 'Container',
name: 'collId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the container you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['query'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchContainers',
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. UsersContainer',
},
],
},
{
displayName: 'Query',
name: 'query',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: ['item'],
operation: ['query'],
},
},
placeholder: 'SELECT * FROM c WHERE c.name = @Name',
routing: {
send: {
type: 'body',
property: 'query',
value: '={{$value}}',
},
},
},
{
displayName: 'Parameters',
name: 'parameters',
type: 'fixedCollection',
required: true,
default: [],
placeholder: 'Add Parameter',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: ['item'],
operation: ['query'],
},
},
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:
'={{$parameter["parameters"] && $parameter["parameters"].parameters ? $parameter["parameters"].parameters : []}}',
},
},
},
];
export const updateFields: INodeProperties[] = [
{
displayName: 'Container',
name: 'collId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the container you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['update'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchContainers',
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. UsersContainer',
},
],
},
{
displayName: 'Item',
name: 'id',
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 ID',
name: 'itemId',
type: 'string',
hint: 'Enter the item id',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The item id must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. AndersenFamily',
},
],
},
{
displayName: 'Operations',
name: 'operations',
type: 'fixedCollection',
placeholder: 'Add Operation',
description: 'Patch operations to apply to the document',
required: true,
default: [],
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: ['item'],
operation: ['update'],
},
},
options: [
{
name: 'operations',
displayName: 'Operation',
values: [
{
displayName: 'Operation',
name: 'op',
type: 'options',
options: [
{ name: 'Add', value: 'add' },
{ name: 'Increment', value: 'incr' },
{ name: 'Move', value: 'move' },
{ name: 'Remove', value: 'remove' },
{ name: 'Replace', value: 'replace' },
{ name: 'Set', value: 'set' },
],
default: 'set',
},
{
displayName: 'From Path',
name: 'from',
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: 'getProperties',
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: 'getProperties',
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: '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: 'getProperties',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'manual',
type: 'string',
hint: 'Enter the field name manually',
placeholder: 'e.g. /Parents/0/FamilyName',
},
],
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
displayOptions: {
show: {
op: ['add', 'set', 'replace', 'incr'],
},
},
},
],
},
],
},
{
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[] = [
...createFields,
...deleteFields,
...getFields,
...getAllFields,
...queryFields,
...updateFields,
];

View file

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

View file

@ -0,0 +1,193 @@
/* 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', () => {
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',
database: 'first_database_1',
baseUrl: 'https://us-east-1.documents.azure.com/dbs/first_database_1',
});
const requestOptions = {
method: 'GET' as const,
url: '/example-endpoint',
headers: {
'Content-Type': 'application/json',
},
};
const result = await microsoftCosmosDbRequest.call(mockContext, requestOptions);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'microsoftCosmosDbSharedKeyApi',
expect.objectContaining({
method: 'GET',
baseURL: 'https://us-east-1.documents.azure.com/dbs/first_database_1',
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(microsoftCosmosDbRequest.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(microsoftCosmosDbRequest.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(microsoftCosmosDbRequest.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(microsoftCosmosDbRequest.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(microsoftCosmosDbRequest.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(microsoftCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow(
'Cosmos DB error response [undefined]: Unexpected failure',
);
expect(mockRequestWithAuthentication).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,130 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchContainers } from '../GenericFunctions';
describe('GenericFunctions - searchContainers', () => {
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 containers and return results', async () => {
(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' }],
});
const response = await searchContainers.call(mockContext);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'microsoftCosmosDbSharedKeyApi',
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 containers by the provided filter string', async () => {
(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' }],
});
const response = await searchContainers.call(mockContext, 'Test');
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'microsoftCosmosDbSharedKeyApi',
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 containers 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 searchContainers.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 containers 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 searchContainers.call(mockContext);
expect(response).toEqual({ results: [] });
});
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 searchContainers.call(mockContext);
expect(response).toEqual({ results: [] });
});
});

View file

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

View file

@ -0,0 +1,130 @@
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();
mockContext.getNode = jest.fn().mockReturnValue({});
});
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' },
{ 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 container ID is missing', async () => {
(mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ mode: 'list', value: '' });
await expect(searchItems.call(mockContext)).rejects.toThrowError(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
expect.objectContaining({
message: 'Container is required',
}),
);
});
});

View file

@ -633,6 +633,7 @@
"dist/nodes/Merge/Merge.node.js",
"dist/nodes/MessageBird/MessageBird.node.js",
"dist/nodes/Metabase/Metabase.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",