Fixed requests

This commit is contained in:
Adina Totorean 2025-02-10 11:24:12 +02:00
parent 9801c907fc
commit d210fcc172
9 changed files with 340 additions and 82 deletions

View file

@ -75,15 +75,22 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType {
requestOptions.headers['x-ms-session-token'] = credentials.sessionToken;
}
// const url = new URL (requestOptions.uri);
let url;
const url = new URL(requestOptions.baseURL + requestOptions.url);
const pathSegments = url.pathname.split('/').filter((segment) => segment);
if (requestOptions.url) {
url = new URL(requestOptions.baseURL + requestOptions.url);
//@ts-ignore
} else if (requestOptions.uri) {
//@ts-ignore
url = new URL(requestOptions.uri);
}
const pathSegments = url?.pathname.split('/').filter((segment) => segment);
let resourceType = '';
let resourceId = '';
if (pathSegments.includes('docs')) {
if (pathSegments?.includes('docs')) {
const docsIndex = pathSegments.lastIndexOf('docs');
resourceType = 'docs';
if (pathSegments[docsIndex + 1]) {
@ -92,7 +99,7 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType {
} else {
resourceId = pathSegments.slice(0, docsIndex).join('/');
}
} else if (pathSegments.includes('colls')) {
} else if (pathSegments?.includes('colls')) {
const collsIndex = pathSegments.lastIndexOf('colls');
resourceType = 'colls';
if (pathSegments[collsIndex + 1]) {
@ -101,7 +108,7 @@ export class MicrosoftCosmosDbSharedKeyApi implements ICredentialType {
} else {
resourceId = pathSegments.slice(0, collsIndex).join('/');
}
} else if (pathSegments.includes('dbs')) {
} else if (pathSegments?.includes('dbs')) {
const dbsIndex = pathSegments.lastIndexOf('dbs');
resourceType = 'dbs';
resourceId = pathSegments.slice(0, dbsIndex + 2).join('/');

View file

@ -1,3 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
import * as crypto from 'crypto';
import type {
DeclarativeRestApiSettings,
@ -71,6 +75,7 @@ export async function microsoftCosmosDbRequest(
const requestOptions: IHttpRequestOptions = {
...opts,
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
baseURL: `${credentials.baseUrl}`,
headers: {
...opts.headers,
@ -99,8 +104,6 @@ export async function microsoftCosmosDbRequest(
};
try {
console.log('Final Request Options before Request:', requestOptions);
return (await this.helpers.requestWithAuthentication.call(
this,
'microsoftCosmosDbSharedKeyApi',
@ -442,14 +445,15 @@ export async function validateOperations(
);
}
//To-Do-check to not send properties it doesn't need
return {
op: operation.op,
path: operation.op === 'move' ? operation.toPath?.value : operation.path?.value,
...(operation.from ? { from: operation.from.value } : {}),
...(operation.op === 'incr'
? { value: Number(operation.value) }
: { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) }),
: operation.value !== undefined
? { value: isNaN(Number(operation.value)) ? operation.value : Number(operation.value) }
: {}),
};
});
@ -458,12 +462,11 @@ export async function validateOperations(
return requestOptions;
}
export async function validateFields(
export async function validateContainerFields(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const additionalFields = this.getNodeParameter('additionalFields', {}) as IDataObject;
const indexingPolicy = additionalFields.indexingPolicy;
const manualThroughput = additionalFields.offerThroughput;
const autoscaleThroughput = additionalFields.maxThroughput;
@ -473,32 +476,28 @@ export async function validateFields(
{},
{
message: 'Bad parameter',
description:
'Please choose only one of Max RU/s (Autoscale) and Max RU/s (Manual Throughput)',
description: 'Please choose only one of Max RU/s (Autoscale) and Manual Throughput RU/s',
},
);
}
if (autoscaleThroughput && requestOptions?.qs) {
requestOptions.qs['x-ms-cosmos-offer-autopilot-settings'] = {
maxThroughput: autoscaleThroughput,
if (autoscaleThroughput) {
requestOptions.headers = {
...requestOptions.headers,
'x-ms-cosmos-offer-autopilot-setting': { maxThroughput: autoscaleThroughput },
};
}
if (!indexingPolicy || Object.keys(indexingPolicy).length === 0) {
throw new NodeApiError(
this.getNode(),
{},
{
message: 'Invalid Indexing Policy',
description: 'Please provide a valid indexingPolicy JSON.',
},
);
if (manualThroughput) {
requestOptions.headers = {
...requestOptions.headers,
'x-ms-offer-throughput': manualThroughput,
};
}
return requestOptions;
}
//WIP
export async function handlePagination(
this: IExecutePaginationFunctions,
resultOptions: DeclarativeRestApiSettings.ResultOptions,
@ -533,7 +532,6 @@ export async function handlePagination(
}
}
//TO-DO-check-if-works
if (responseData.length > 0) {
const lastItem = responseData[responseData.length - 1];
@ -554,40 +552,34 @@ export async function handlePagination(
return aggregatedResult.map((result) => ({ json: result }));
}
//WIP
export async function handleErrorPostReceive(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
console.log('Status code❌', response.statusCode);
if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) {
const responseBody = response.body as IDataObject;
console.log('Got here ❌', responseBody);
let errorMessage = 'Unknown error occurred';
let errorDescription = 'An unexpected error was encountered.';
if (typeof responseBody.message === 'string') {
try {
const jsonMatch = responseBody.message.match(/Message: (\{.*\})/);
if (jsonMatch && jsonMatch[1]) {
const parsedMessage = JSON.parse(jsonMatch[1]);
if (
parsedMessage.Errors &&
Array.isArray(parsedMessage.Errors) &&
parsedMessage.Errors.length > 0
) {
errorMessage = parsedMessage.Errors[0].split(' Learn more:')[0].trim();
}
}
} catch (error) {
errorMessage = 'Failed to extract error message';
if (typeof responseBody === 'object' && responseBody !== null) {
if (typeof responseBody.code === 'string') {
errorMessage = responseBody.code;
}
if (typeof responseBody.message === 'string') {
errorDescription = responseBody.message;
}
}
throw new ApplicationError(errorMessage);
throw new NodeApiError(
this.getNode(),
{},
{
message: errorMessage,
description: errorDescription,
},
);
}
return data;
}
@ -675,7 +667,7 @@ export async function searchItems(
};
}
function extractFieldPaths(obj: any, prefix = ''): string[] {
function extractFieldPaths(obj: IDataObject, prefix = ''): string[] {
let paths: string[] = [];
Object.entries(obj).forEach(([key, value]) => {
@ -692,7 +684,7 @@ function extractFieldPaths(obj: any, prefix = ''): string[] {
}
});
} else if (typeof value === 'object' && value !== null) {
paths = paths.concat(extractFieldPaths(value, newPath));
paths = paths.concat(extractFieldPaths(value as IDataObject, newPath));
} else {
paths.push(newPath);
}
@ -783,6 +775,34 @@ export async function getProperties(this: ILoadOptionsFunctions): Promise<INodeL
};
}
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,
@ -803,7 +823,6 @@ export async function formatCustomProperties(
let parsedProperties: Record<string, unknown>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
parsedProperties = JSON.parse(rawCustomProperties);
} catch (error) {
throw new NodeApiError(
@ -841,11 +860,9 @@ export async function formatJSONFields(
let parsedIndexPolicy: Record<string, unknown> | undefined;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
parsedPartitionKey = JSON.parse(rawPartitionKey);
if (indexingPolicy) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
parsedIndexPolicy = JSON.parse(indexingPolicy);
}
} catch (error) {
@ -880,32 +897,32 @@ export async function processResponseItems(
this: IExecuteSingleFunctions,
items: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<any> {
): Promise<INodeExecutionData[]> {
if (!response || typeof response !== 'object' || !Array.isArray(items)) {
throw new ApplicationError('Invalid response format from Cosmos DB.');
}
const extractedDocuments: IDataObject[] = items.flatMap((item) => {
const extractedDocuments: INodeExecutionData[] = items.flatMap((item) => {
if (
item.json &&
typeof item.json === 'object' &&
'Documents' in item.json &&
Array.isArray(item.json.Documents)
) {
return item.json.Documents as IDataObject[];
return item.json.Documents.map((doc) => ({ json: doc }));
}
return [];
});
return extractedDocuments;
return extractedDocuments.length ? extractedDocuments : [{ json: {} }];
}
export async function processResponseContainers(
this: IExecuteSingleFunctions,
items: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<any> {
): Promise<INodeExecutionData[]> {
if (!response || typeof response !== 'object' || !Array.isArray(items)) {
throw new ApplicationError('Invalid response format from Cosmos DB.');
}

View file

@ -1,6 +1,11 @@
import type { INodeProperties } from 'n8n-workflow';
import { formatJSONFields, processResponseContainers, validateFields } from '../GenericFunctions';
import {
formatJSONFields,
handleErrorPostReceive,
processResponseContainers,
validateContainerFields,
} from '../GenericFunctions';
export const containerOperations: INodeProperties[] = [
{
@ -20,13 +25,16 @@ export const containerOperations: INodeProperties[] = [
description: 'Create a container',
routing: {
send: {
preSend: [formatJSONFields, validateFields],
preSend: [formatJSONFields, validateContainerFields],
},
request: {
ignoreHttpStatusErrors: true,
method: 'POST',
url: '/colls',
},
output: {
postReceive: [handleErrorPostReceive],
},
},
action: 'Create container',
},
@ -42,6 +50,7 @@ export const containerOperations: INodeProperties[] = [
},
output: {
postReceive: [
handleErrorPostReceive,
{
type: 'set',
properties: {
@ -63,6 +72,9 @@ export const containerOperations: INodeProperties[] = [
method: 'GET',
url: '=/colls/{{ $parameter["collId"] }}',
},
output: {
postReceive: [handleErrorPostReceive],
},
},
action: 'Get container',
},
@ -77,7 +89,7 @@ export const containerOperations: INodeProperties[] = [
url: '/colls',
},
output: {
postReceive: [processResponseContainers],
postReceive: [handleErrorPostReceive, processResponseContainers],
},
},
action: 'Get many containers',
@ -156,19 +168,15 @@ export const createFields: INodeProperties[] = [
description: 'The user specified autoscale max RU/s',
},
{
displayName: 'Max RU/s (for Manual Throughput)',
displayName: 'Manual Throughput RU/s',
name: 'offerThroughput',
type: 'number',
default: 400,
typeOptions: {
minValue: 400,
},
description:
'The user specified manual throughput (RU/s) for the collection expressed in units of 100 request units per second',
routing: {
send: {
type: 'query',
property: 'x-ms-offer-throughput',
value: '={{$value}}',
},
},
},
],
placeholder: 'Add Option',

View file

@ -4,6 +4,7 @@ import {
formatCustomProperties,
handleErrorPostReceive,
handlePagination,
presendLimitField,
processResponseItems,
validateOperations,
validatePartitionKey,
@ -97,6 +98,7 @@ export const itemOperations: INodeProperties[] = [
routing: {
send: {
paginate: true,
preSend: [presendLimitField],
},
operations: {
pagination: handlePagination,
@ -128,7 +130,7 @@ export const itemOperations: INodeProperties[] = [
},
},
output: {
postReceive: [handleErrorPostReceive],
postReceive: [processResponseItems, handleErrorPostReceive],
},
},
action: 'Query items',
@ -539,13 +541,6 @@ export const getAllFields: INodeProperties[] = [
returnAll: [false],
},
},
routing: {
send: {
property: 'x-ms-max-item-count',
type: 'query',
value: '={{ $value }}',
},
},
type: 'number',
typeOptions: {
minValue: 1,
@ -611,7 +606,7 @@ export const queryFields: INodeProperties[] = [
operation: ['query'],
},
},
placeholder: 'SELECT * FROM c WHERE c.name = @name',
placeholder: 'SELECT * FROM c WHERE c.name = @Name',
routing: {
send: {
type: 'body',

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

@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { microsoftCosmosDbRequest } from '../GenericFunctions';
describe('GenericFunctions - microsoftCosmosDbRequest', () => {

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

@ -15,6 +15,7 @@ describe('GenericFunctions - searchItems', () => {
beforeEach(() => {
jest.clearAllMocks();
mockContext.getNode = jest.fn().mockReturnValue({});
});
it('should fetch documents and return formatted results', async () => {
@ -45,7 +46,7 @@ describe('GenericFunctions - searchItems', () => {
expect(response).toEqual({
results: [
{ name: 'Item1', value: 'Item 1' }, // Space removed from 'Item 1'
{ name: 'Item1', value: 'Item 1' },
{ name: 'Item2', value: 'Item 2' },
],
});
@ -119,6 +120,11 @@ describe('GenericFunctions - searchItems', () => {
it('should throw an error when container ID is missing', async () => {
(mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce({ mode: 'list', value: '' });
await expect(searchItems.call(mockContext)).rejects.toThrow('Container is required');
await expect(searchItems.call(mockContext)).rejects.toThrowError(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
expect.objectContaining({
message: 'Container is required',
}),
);
});
});