mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
refactor(editor): Extract API Requests into @n8n/rest-api-client package (no-changelog) (#15930)
This commit is contained in:
10
packages/frontend/@n8n/rest-api-client/.eslintrc.cjs
Normal file
10
packages/frontend/@n8n/rest-api-client/.eslintrc.cjs
Normal file
@@ -0,0 +1,10 @@
|
||||
const sharedOptions = require('@n8n/eslint-config/shared');
|
||||
|
||||
/**
|
||||
* @type {import('@types/eslint').ESLint.ConfigData}
|
||||
*/
|
||||
module.exports = {
|
||||
extends: ['@n8n/eslint-config/frontend'],
|
||||
|
||||
...sharedOptions(__dirname, 'frontend'),
|
||||
};
|
||||
24
packages/frontend/@n8n/rest-api-client/.gitignore
vendored
Normal file
24
packages/frontend/@n8n/rest-api-client/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
22
packages/frontend/@n8n/rest-api-client/README.md
Normal file
22
packages/frontend/@n8n/rest-api-client/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @n8n/rest-api-client
|
||||
|
||||
This package contains the REST API calls for n8n.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## Features
|
||||
|
||||
- Provides a REST API for n8n
|
||||
- Supports authentication and authorization
|
||||
|
||||
## Contributing
|
||||
|
||||
For more details, please read our [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
For more details, please read our [LICENSE.md](LICENSE.md).
|
||||
4
packages/frontend/@n8n/rest-api-client/biome.jsonc
Normal file
4
packages/frontend/@n8n/rest-api-client/biome.jsonc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"extends": ["../../../../biome.jsonc"]
|
||||
}
|
||||
58
packages/frontend/@n8n/rest-api-client/package.json
Normal file
58
packages/frontend/@n8n/rest-api-client/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@n8n/rest-api-client",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"import": "./dist/*.js",
|
||||
"require": "./dist/*.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "pnpm run typecheck && tsup",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:dev": "vitest --silent=false",
|
||||
"lint": "eslint src --ext .js,.ts,.vue --quiet",
|
||||
"lintfix": "eslint src --ext .js,.ts,.vue --fix",
|
||||
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
|
||||
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/constants": "workspace:*",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"js-base64": "catalog:",
|
||||
"n8n-workflow": "workspace:*",
|
||||
"axios": "catalog:",
|
||||
"flatted": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/eslint-config": "workspace:*",
|
||||
"@n8n/i18n": "workspace:*",
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@testing-library/jest-dom": "catalog:frontend",
|
||||
"@testing-library/user-event": "catalog:frontend",
|
||||
"tsup": "catalog:",
|
||||
"typescript": "catalog:frontend",
|
||||
"vite": "catalog:frontend",
|
||||
"vitest": "catalog:frontend"
|
||||
},
|
||||
"license": "See LICENSE.md file in the root of the repository"
|
||||
}
|
||||
40
packages/frontend/@n8n/rest-api-client/src/api/api-keys.ts
Normal file
40
packages/frontend/@n8n/rest-api-client/src/api/api-keys.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
CreateApiKeyRequestDto,
|
||||
UpdateApiKeyRequestDto,
|
||||
ApiKey,
|
||||
ApiKeyWithRawValue,
|
||||
} from '@n8n/api-types';
|
||||
import type { ApiKeyScope } from '@n8n/permissions';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/api-keys');
|
||||
}
|
||||
|
||||
export async function getApiKeyScopes(context: IRestApiContext): Promise<ApiKeyScope[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/api-keys/scopes');
|
||||
}
|
||||
|
||||
export async function createApiKey(
|
||||
context: IRestApiContext,
|
||||
payload: CreateApiKeyRequestDto,
|
||||
): Promise<ApiKeyWithRawValue> {
|
||||
return await makeRestApiRequest(context, 'POST', '/api-keys', payload);
|
||||
}
|
||||
|
||||
export async function deleteApiKey(
|
||||
context: IRestApiContext,
|
||||
id: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return await makeRestApiRequest(context, 'DELETE', `/api-keys/${id}`);
|
||||
}
|
||||
|
||||
export async function updateApiKey(
|
||||
context: IRestApiContext,
|
||||
id: string,
|
||||
payload: UpdateApiKeyRequestDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
return await makeRestApiRequest(context, 'PATCH', `/api-keys/${id}`, payload);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { get, post, makeRestApiRequest } from '../utils';
|
||||
|
||||
export async function getInstalledCommunityNodes(
|
||||
context: IRestApiContext,
|
||||
): Promise<PublicInstalledPackage[]> {
|
||||
const response = await get(context.baseUrl, '/community-packages');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
export async function installNewPackage(
|
||||
context: IRestApiContext,
|
||||
name: string,
|
||||
verify?: boolean,
|
||||
version?: string,
|
||||
): Promise<PublicInstalledPackage> {
|
||||
return await post(context.baseUrl, '/community-packages', { name, verify, version });
|
||||
}
|
||||
|
||||
export async function uninstallPackage(context: IRestApiContext, name: string): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'DELETE', '/community-packages', { name });
|
||||
}
|
||||
|
||||
export async function updatePackage(
|
||||
context: IRestApiContext,
|
||||
name: string,
|
||||
): Promise<PublicInstalledPackage> {
|
||||
return await makeRestApiRequest(context, 'PATCH', '/community-packages', { name });
|
||||
}
|
||||
8
packages/frontend/@n8n/rest-api-client/src/api/ctas.ts
Normal file
8
packages/frontend/@n8n/rest-api-client/src/api/ctas.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { get } from '../utils';
|
||||
|
||||
export async function getBecomeCreatorCta(context: IRestApiContext): Promise<boolean> {
|
||||
const response = await get(context.baseUrl, '/cta/become-creator');
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IDataObject, MessageEventBusDestinationOptions } from 'n8n-workflow';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
export type ApiMessageEventBusDestinationOptions = MessageEventBusDestinationOptions & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function hasDestinationId(
|
||||
destination: MessageEventBusDestinationOptions,
|
||||
): destination is ApiMessageEventBusDestinationOptions {
|
||||
return destination.id !== undefined;
|
||||
}
|
||||
|
||||
export async function saveDestinationToDb(
|
||||
context: IRestApiContext,
|
||||
destination: ApiMessageEventBusDestinationOptions,
|
||||
subscribedEvents: string[] = [],
|
||||
) {
|
||||
const data: IDataObject = {
|
||||
...destination,
|
||||
subscribedEvents,
|
||||
};
|
||||
return await makeRestApiRequest(context, 'POST', '/eventbus/destination', data);
|
||||
}
|
||||
|
||||
export async function deleteDestinationFromDb(context: IRestApiContext, destinationId: string) {
|
||||
return await makeRestApiRequest(context, 'DELETE', `/eventbus/destination?id=${destinationId}`);
|
||||
}
|
||||
|
||||
export async function sendTestMessageToDestination(
|
||||
context: IRestApiContext,
|
||||
destination: ApiMessageEventBusDestinationOptions,
|
||||
): Promise<boolean> {
|
||||
const data: IDataObject = {
|
||||
...destination,
|
||||
};
|
||||
return await makeRestApiRequest(context, 'GET', '/eventbus/testmessage', data);
|
||||
}
|
||||
|
||||
export async function getEventNamesFromBackend(context: IRestApiContext): Promise<string[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/eventbus/eventnames');
|
||||
}
|
||||
|
||||
export async function getDestinationsFromBackend(
|
||||
context: IRestApiContext,
|
||||
): Promise<MessageEventBusDestinationOptions[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/eventbus/destination');
|
||||
}
|
||||
6
packages/frontend/@n8n/rest-api-client/src/api/events.ts
Normal file
6
packages/frontend/@n8n/rest-api-client/src/api/events.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
export async function sessionStarted(context: IRestApiContext): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'GET', '/events/session-started');
|
||||
}
|
||||
12
packages/frontend/@n8n/rest-api-client/src/api/index.ts
Normal file
12
packages/frontend/@n8n/rest-api-client/src/api/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './api-keys';
|
||||
export * from './communityNodes';
|
||||
export * from './ctas';
|
||||
export * from './eventbus.ee';
|
||||
export * from './events';
|
||||
export * from './mfa';
|
||||
export * from './nodeTypes';
|
||||
export * from './npsSurvey';
|
||||
export * from './orchestration';
|
||||
export * from './roles';
|
||||
export * from './ui';
|
||||
export * from './webhooks';
|
||||
35
packages/frontend/@n8n/rest-api-client/src/api/mfa.ts
Normal file
35
packages/frontend/@n8n/rest-api-client/src/api/mfa.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
export async function canEnableMFA(context: IRestApiContext) {
|
||||
return await makeRestApiRequest(context, 'POST', '/mfa/can-enable');
|
||||
}
|
||||
|
||||
export async function getMfaQR(
|
||||
context: IRestApiContext,
|
||||
): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> {
|
||||
return await makeRestApiRequest(context, 'GET', '/mfa/qr');
|
||||
}
|
||||
|
||||
export async function enableMfa(
|
||||
context: IRestApiContext,
|
||||
data: { mfaCode: string },
|
||||
): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'POST', '/mfa/enable', data);
|
||||
}
|
||||
|
||||
export async function verifyMfaCode(
|
||||
context: IRestApiContext,
|
||||
data: { mfaCode: string },
|
||||
): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'POST', '/mfa/verify', data);
|
||||
}
|
||||
|
||||
export type DisableMfaParams = {
|
||||
mfaCode?: string;
|
||||
mfaRecoveryCode?: string;
|
||||
};
|
||||
|
||||
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'POST', '/mfa/disable', data);
|
||||
}
|
||||
124
packages/frontend/@n8n/rest-api-client/src/api/nodeTypes.ts
Normal file
124
packages/frontend/@n8n/rest-api-client/src/api/nodeTypes.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
ActionResultRequestDto,
|
||||
CommunityNodeType,
|
||||
OptionsRequestDto,
|
||||
ResourceLocatorRequestDto,
|
||||
ResourceMapperFieldsRequestDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { INodeTranslationHeaders } from '@n8n/i18n';
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
INodeListSearchResult,
|
||||
INodePropertyOptions,
|
||||
INodeTypeDescription,
|
||||
INodeTypeNameVersion,
|
||||
NodeParameterValueType,
|
||||
ResourceMapperFields,
|
||||
} from 'n8n-workflow';
|
||||
import { sleep } from 'n8n-workflow';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
async function fetchNodeTypesJsonWithRetry(url: string, retries = 5, delay = 500) {
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
const response = await axios.get(url, { withCredentials: true });
|
||||
|
||||
if (typeof response.data === 'object' && response.data !== null) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
await sleep(delay * attempt);
|
||||
}
|
||||
|
||||
throw new Error('Could not fetch node types');
|
||||
}
|
||||
|
||||
export async function getNodeTypes(baseUrl: string) {
|
||||
return await fetchNodeTypesJsonWithRetry(baseUrl + 'types/nodes.json');
|
||||
}
|
||||
|
||||
export async function fetchCommunityNodeTypes(
|
||||
context: IRestApiContext,
|
||||
): Promise<CommunityNodeType[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/community-node-types');
|
||||
}
|
||||
|
||||
export async function fetchCommunityNodeAttributes(
|
||||
context: IRestApiContext,
|
||||
type: string,
|
||||
): Promise<CommunityNodeType | null> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'GET',
|
||||
`/community-node-types/${encodeURIComponent(type)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getNodeTranslationHeaders(
|
||||
context: IRestApiContext,
|
||||
): Promise<INodeTranslationHeaders | undefined> {
|
||||
return await makeRestApiRequest(context, 'GET', '/node-translation-headers');
|
||||
}
|
||||
|
||||
export async function getNodesInformation(
|
||||
context: IRestApiContext,
|
||||
nodeInfos: INodeTypeNameVersion[],
|
||||
): Promise<INodeTypeDescription[]> {
|
||||
return await makeRestApiRequest(context, 'POST', '/node-types', { nodeInfos });
|
||||
}
|
||||
|
||||
export async function getNodeParameterOptions(
|
||||
context: IRestApiContext,
|
||||
sendData: OptionsRequestDto,
|
||||
): Promise<INodePropertyOptions[]> {
|
||||
return await makeRestApiRequest(context, 'POST', '/dynamic-node-parameters/options', sendData);
|
||||
}
|
||||
|
||||
export async function getResourceLocatorResults(
|
||||
context: IRestApiContext,
|
||||
sendData: ResourceLocatorRequestDto,
|
||||
): Promise<INodeListSearchResult> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
'/dynamic-node-parameters/resource-locator-results',
|
||||
sendData,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getResourceMapperFields(
|
||||
context: IRestApiContext,
|
||||
sendData: ResourceMapperFieldsRequestDto,
|
||||
): Promise<ResourceMapperFields> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
'/dynamic-node-parameters/resource-mapper-fields',
|
||||
sendData,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLocalResourceMapperFields(
|
||||
context: IRestApiContext,
|
||||
sendData: ResourceMapperFieldsRequestDto,
|
||||
): Promise<ResourceMapperFields> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
'/dynamic-node-parameters/local-resource-mapper-fields',
|
||||
sendData,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getNodeParameterActionResult(
|
||||
context: IRestApiContext,
|
||||
sendData: ActionResultRequestDto,
|
||||
): Promise<NodeParameterValueType> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
'/dynamic-node-parameters/action-result',
|
||||
sendData,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NpsSurveyState } from 'n8n-workflow';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
export async function updateNpsSurveyState(context: IRestApiContext, state: NpsSurveyState) {
|
||||
await makeRestApiRequest(context, 'PATCH', '/user-settings/nps-survey', state);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
const GET_STATUS_ENDPOINT = '/orchestration/worker/status';
|
||||
|
||||
export const sendGetWorkerStatus = async (context: IRestApiContext): Promise<void> => {
|
||||
await makeRestApiRequest(context, 'POST', GET_STATUS_ENDPOINT);
|
||||
};
|
||||
8
packages/frontend/@n8n/rest-api-client/src/api/roles.ts
Normal file
8
packages/frontend/@n8n/rest-api-client/src/api/roles.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { AllRolesMap } from '@n8n/permissions';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
export const getRoles = async (context: IRestApiContext): Promise<AllRolesMap> => {
|
||||
return await makeRestApiRequest(context, 'GET', '/roles');
|
||||
};
|
||||
13
packages/frontend/@n8n/rest-api-client/src/api/ui.ts
Normal file
13
packages/frontend/@n8n/rest-api-client/src/api/ui.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { BannerName } from '@n8n/api-types';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
export async function dismissBannerPermanently(
|
||||
context: IRestApiContext,
|
||||
data: { bannerName: BannerName; dismissedBanners: string[] },
|
||||
): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'POST', '/owner/dismiss-banner', {
|
||||
banner: data.bannerName,
|
||||
});
|
||||
}
|
||||
18
packages/frontend/@n8n/rest-api-client/src/api/webhooks.ts
Normal file
18
packages/frontend/@n8n/rest-api-client/src/api/webhooks.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { IHttpRequestMethods } from 'n8n-workflow';
|
||||
|
||||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
type WebhookData = {
|
||||
workflowId: string;
|
||||
webhookPath: string;
|
||||
method: IHttpRequestMethods;
|
||||
node: string;
|
||||
};
|
||||
|
||||
export const findWebhook = async (
|
||||
context: IRestApiContext,
|
||||
data: { path: string; method: string },
|
||||
): Promise<WebhookData | null> => {
|
||||
return await makeRestApiRequest(context, 'POST', '/webhooks/find', data);
|
||||
};
|
||||
3
packages/frontend/@n8n/rest-api-client/src/index.ts
Normal file
3
packages/frontend/@n8n/rest-api-client/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './api';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
1
packages/frontend/@n8n/rest-api-client/src/shims.d.ts
vendored
Normal file
1
packages/frontend/@n8n/rest-api-client/src/shims.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
4
packages/frontend/@n8n/rest-api-client/src/types.ts
Normal file
4
packages/frontend/@n8n/rest-api-client/src/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface IRestApiContext {
|
||||
baseUrl: string;
|
||||
pushRef: string;
|
||||
}
|
||||
160
packages/frontend/@n8n/rest-api-client/src/utils.test.ts
Normal file
160
packages/frontend/@n8n/rest-api-client/src/utils.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { ResponseError, STREAM_SEPERATOR, streamRequest } from './utils';
|
||||
|
||||
describe('streamRequest', () => {
|
||||
it('should stream data from the API endpoint', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const mockResponse = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPERATOR}`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 2 })}${STREAM_SEPERATOR}`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 3 })}${STREAM_SEPERATOR}`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: mockResponse,
|
||||
});
|
||||
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const onChunkMock = vi.fn();
|
||||
const onDoneMock = vi.fn();
|
||||
const onErrorMock = vi.fn();
|
||||
|
||||
await streamRequest(
|
||||
{
|
||||
baseUrl: 'https://api.example.com',
|
||||
pushRef: '',
|
||||
},
|
||||
'/data',
|
||||
{ key: 'value' },
|
||||
onChunkMock,
|
||||
onDoneMock,
|
||||
onErrorMock,
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'value' }),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'browser-id': expect.stringContaining('-'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(onChunkMock).toHaveBeenCalledTimes(3);
|
||||
expect(onChunkMock).toHaveBeenNthCalledWith(1, { chunk: 1 });
|
||||
expect(onChunkMock).toHaveBeenNthCalledWith(2, { chunk: 2 });
|
||||
expect(onChunkMock).toHaveBeenNthCalledWith(3, { chunk: 3 });
|
||||
|
||||
expect(onDoneMock).toHaveBeenCalledTimes(1);
|
||||
expect(onErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stream error response from the API endpoint', async () => {
|
||||
const testError = { code: 500, message: 'Error happened' };
|
||||
const encoder = new TextEncoder();
|
||||
const mockResponse = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(testError)));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
body: mockResponse,
|
||||
});
|
||||
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const onChunkMock = vi.fn();
|
||||
const onDoneMock = vi.fn();
|
||||
const onErrorMock = vi.fn();
|
||||
|
||||
await streamRequest(
|
||||
{
|
||||
baseUrl: 'https://api.example.com',
|
||||
pushRef: '',
|
||||
},
|
||||
'/data',
|
||||
{ key: 'value' },
|
||||
onChunkMock,
|
||||
onDoneMock,
|
||||
onErrorMock,
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'value' }),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'browser-id': expect.stringContaining('-'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(onChunkMock).not.toHaveBeenCalled();
|
||||
expect(onErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(onErrorMock).toHaveBeenCalledWith(new ResponseError(testError.message));
|
||||
});
|
||||
|
||||
it('should handle broken stream data', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const mockResponse = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPERATOR}{"chunk": `),
|
||||
);
|
||||
controller.enqueue(encoder.encode(`2}${STREAM_SEPERATOR}{"ch`));
|
||||
controller.enqueue(encoder.encode('unk":'));
|
||||
controller.enqueue(encoder.encode(`3}${STREAM_SEPERATOR}`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: mockResponse,
|
||||
});
|
||||
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const onChunkMock = vi.fn();
|
||||
const onDoneMock = vi.fn();
|
||||
const onErrorMock = vi.fn();
|
||||
|
||||
await streamRequest(
|
||||
{
|
||||
baseUrl: 'https://api.example.com',
|
||||
pushRef: '',
|
||||
},
|
||||
'/data',
|
||||
{ key: 'value' },
|
||||
onChunkMock,
|
||||
onDoneMock,
|
||||
onErrorMock,
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'value' }),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'browser-id': expect.stringContaining('-'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(onChunkMock).toHaveBeenCalledTimes(3);
|
||||
expect(onChunkMock).toHaveBeenNthCalledWith(1, { chunk: 1 });
|
||||
expect(onChunkMock).toHaveBeenNthCalledWith(2, { chunk: 2 });
|
||||
expect(onChunkMock).toHaveBeenNthCalledWith(3, { chunk: 3 });
|
||||
|
||||
expect(onDoneMock).toHaveBeenCalledTimes(1);
|
||||
expect(onErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
289
packages/frontend/@n8n/rest-api-client/src/utils.ts
Normal file
289
packages/frontend/@n8n/rest-api-client/src/utils.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { BROWSER_ID_STORAGE_KEY } from '@n8n/constants';
|
||||
import { assert } from '@n8n/utils/assert';
|
||||
import type { AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { ApplicationError, jsonParse } from 'n8n-workflow';
|
||||
import type { GenericValue, IDataObject } from 'n8n-workflow';
|
||||
|
||||
import type { IRestApiContext } from './types';
|
||||
|
||||
const getBrowserId = () => {
|
||||
let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY);
|
||||
if (!browserId) {
|
||||
browserId = crypto.randomUUID();
|
||||
localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId);
|
||||
}
|
||||
return browserId;
|
||||
};
|
||||
|
||||
export const NO_NETWORK_ERROR_CODE = 999;
|
||||
export const STREAM_SEPERATOR = '⧉⇋⇋➽⌑⧉§§\n';
|
||||
|
||||
export class ResponseError extends ApplicationError {
|
||||
// The HTTP status code of response
|
||||
httpStatusCode?: number;
|
||||
|
||||
// The error code in the response
|
||||
errorCode?: number;
|
||||
|
||||
// The stack trace of the server
|
||||
serverStackTrace?: string;
|
||||
|
||||
/**
|
||||
* Creates an instance of ResponseError.
|
||||
* @param {string} message The error message
|
||||
* @param {number} [errorCode] The error code which can be used by frontend to identify the actual error
|
||||
* @param {number} [httpStatusCode] The HTTP status code the response should have
|
||||
* @param {string} [stack] The stack trace
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
options: { errorCode?: number; httpStatusCode?: number; stack?: string } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ResponseError';
|
||||
|
||||
const { errorCode, httpStatusCode, stack } = options;
|
||||
if (errorCode) {
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
if (httpStatusCode) {
|
||||
this.httpStatusCode = httpStatusCode;
|
||||
}
|
||||
if (stack) {
|
||||
this.serverStackTrace = stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const legacyParamSerializer = (params: Record<string, any>) =>
|
||||
Object.keys(params)
|
||||
.filter((key) => params[key] !== undefined)
|
||||
.map((key) => {
|
||||
if (Array.isArray(params[key])) {
|
||||
return params[key].map((v: string) => `${key}[]=${encodeURIComponent(v)}`).join('&');
|
||||
}
|
||||
if (typeof params[key] === 'object') {
|
||||
params[key] = JSON.stringify(params[key]);
|
||||
}
|
||||
return `${key}=${encodeURIComponent(params[key])}`;
|
||||
})
|
||||
.join('&');
|
||||
|
||||
export async function request(config: {
|
||||
method: Method;
|
||||
baseURL: string;
|
||||
endpoint: string;
|
||||
headers?: RawAxiosRequestHeaders;
|
||||
data?: GenericValue | GenericValue[];
|
||||
withCredentials?: boolean;
|
||||
}) {
|
||||
const { method, baseURL, endpoint, headers, data } = config;
|
||||
const options: AxiosRequestConfig = {
|
||||
method,
|
||||
url: endpoint,
|
||||
baseURL,
|
||||
headers: headers ?? {},
|
||||
};
|
||||
if (baseURL.startsWith('/')) {
|
||||
options.headers!['browser-id'] = getBrowserId();
|
||||
}
|
||||
if (
|
||||
import.meta.env.NODE_ENV !== 'production' &&
|
||||
!baseURL.includes('api.n8n.io') &&
|
||||
!baseURL.includes('n8n.cloud')
|
||||
) {
|
||||
options.withCredentials = options.withCredentials ?? true;
|
||||
}
|
||||
if (['POST', 'PATCH', 'PUT'].includes(method)) {
|
||||
options.data = data;
|
||||
} else if (data) {
|
||||
options.params = data;
|
||||
options.paramsSerializer = legacyParamSerializer;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.request(options);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error.message === 'Network Error') {
|
||||
throw new ResponseError("Can't connect to n8n.", {
|
||||
errorCode: NO_NETWORK_ERROR_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
const errorResponseData = error.response?.data;
|
||||
if (errorResponseData?.message !== undefined) {
|
||||
if (errorResponseData.name === 'NodeApiError') {
|
||||
errorResponseData.httpStatusCode = error.response.status;
|
||||
throw errorResponseData;
|
||||
}
|
||||
|
||||
throw new ResponseError(errorResponseData.message, {
|
||||
errorCode: errorResponseData.code,
|
||||
httpStatusCode: error.response.status,
|
||||
stack: errorResponseData.stack,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to the API and returns the response without extracting the data key.
|
||||
* @param context Rest API context
|
||||
* @param method HTTP method
|
||||
* @param endpoint relative path to the API endpoint
|
||||
* @param data request data
|
||||
* @returns data and total count
|
||||
*/
|
||||
export async function getFullApiResponse<T>(
|
||||
context: IRestApiContext,
|
||||
method: Method,
|
||||
endpoint: string,
|
||||
data?: GenericValue | GenericValue[],
|
||||
) {
|
||||
const response = await request({
|
||||
method,
|
||||
baseURL: context.baseUrl,
|
||||
endpoint,
|
||||
headers: { 'push-ref': context.pushRef },
|
||||
data,
|
||||
});
|
||||
|
||||
return response as { count: number; data: T };
|
||||
}
|
||||
|
||||
export async function makeRestApiRequest<T>(
|
||||
context: IRestApiContext,
|
||||
method: Method,
|
||||
endpoint: string,
|
||||
data?: GenericValue | GenericValue[],
|
||||
) {
|
||||
const response = await request({
|
||||
method,
|
||||
baseURL: context.baseUrl,
|
||||
endpoint,
|
||||
headers: { 'push-ref': context.pushRef },
|
||||
data,
|
||||
});
|
||||
|
||||
// All cli rest api endpoints return data wrapped in `data` key
|
||||
return response.data as T;
|
||||
}
|
||||
|
||||
export async function get(
|
||||
baseURL: string,
|
||||
endpoint: string,
|
||||
params?: IDataObject,
|
||||
headers?: RawAxiosRequestHeaders,
|
||||
) {
|
||||
return await request({ method: 'GET', baseURL, endpoint, headers, data: params });
|
||||
}
|
||||
|
||||
export async function post(
|
||||
baseURL: string,
|
||||
endpoint: string,
|
||||
params?: IDataObject,
|
||||
headers?: RawAxiosRequestHeaders,
|
||||
) {
|
||||
return await request({ method: 'POST', baseURL, endpoint, headers, data: params });
|
||||
}
|
||||
|
||||
export async function patch(
|
||||
baseURL: string,
|
||||
endpoint: string,
|
||||
params?: IDataObject,
|
||||
headers?: RawAxiosRequestHeaders,
|
||||
) {
|
||||
return await request({ method: 'PATCH', baseURL, endpoint, headers, data: params });
|
||||
}
|
||||
|
||||
export async function streamRequest<T extends object>(
|
||||
context: IRestApiContext,
|
||||
apiEndpoint: string,
|
||||
payload: object,
|
||||
onChunk?: (chunk: T) => void,
|
||||
onDone?: () => void,
|
||||
onError?: (e: Error) => void,
|
||||
separator = STREAM_SEPERATOR,
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
'browser-id': getBrowserId(),
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const assistantRequest: RequestInit = {
|
||||
headers,
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`${context.baseUrl}${apiEndpoint}`, assistantRequest);
|
||||
|
||||
if (response.body) {
|
||||
// Handle the streaming response
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
let buffer = '';
|
||||
|
||||
async function readStream() {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const chunk = decoder.decode(value);
|
||||
buffer += chunk;
|
||||
|
||||
const splitChunks = buffer.split(separator);
|
||||
|
||||
buffer = '';
|
||||
for (const splitChunk of splitChunks) {
|
||||
if (splitChunk) {
|
||||
let data: T;
|
||||
try {
|
||||
data = jsonParse<T>(splitChunk, { errorMessage: 'Invalid json' });
|
||||
} catch (e) {
|
||||
// incomplete json. append to buffer to complete
|
||||
buffer += splitChunk;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (response.ok) {
|
||||
// Call chunk callback if request was successful
|
||||
onChunk?.(data);
|
||||
} else {
|
||||
// Otherwise, call error callback
|
||||
const message = 'message' in data ? data.message : response.statusText;
|
||||
onError?.(
|
||||
new ResponseError(String(message), {
|
||||
httpStatusCode: response.status,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
onError?.(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await readStream();
|
||||
}
|
||||
|
||||
// Start reading the stream
|
||||
await readStream();
|
||||
} else if (onError) {
|
||||
onError(new Error(response.statusText));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
assert(e instanceof Error);
|
||||
onError?.(e as Error);
|
||||
}
|
||||
}
|
||||
14
packages/frontend/@n8n/rest-api-client/tsconfig.json
Normal file
14
packages/frontend/@n8n/rest-api-client/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@n8n/typescript-config/tsconfig.frontend.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist",
|
||||
"useUnknownInCatchVariables": false,
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@n8n/utils/*": ["../../../@n8n/utils/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "vite.config.ts", "tsup.config.ts"]
|
||||
}
|
||||
11
packages/frontend/@n8n/rest-api-client/tsup.config.ts
Normal file
11
packages/frontend/@n8n/rest-api-client/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__/**/*'],
|
||||
format: ['cjs', 'esm'],
|
||||
clean: true,
|
||||
dts: true,
|
||||
cjsInterop: true,
|
||||
splitting: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
4
packages/frontend/@n8n/rest-api-client/vite.config.ts
Normal file
4
packages/frontend/@n8n/rest-api-client/vite.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig, mergeConfig } from 'vite';
|
||||
import { createVitestConfig } from '@n8n/vitest-config/frontend';
|
||||
|
||||
export default mergeConfig(defineConfig({}), createVitestConfig({ setupFiles: [] }));
|
||||
Reference in New Issue
Block a user