mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-03 14:27:32 +08:00
Feature/generic api connector (#13545)
# feat: Add Generic REST API Connector
## What problem does this PR solve?
RAGFlow supports many specific data source connectors (MySQL, Slack,
Google Drive, etc.), but there was no way to connect an arbitrary REST
API as a data source. Users with custom or third-party APIs had to write
a new connector class for each one.
This PR adds a **generic, configuration-driven REST API connector** that
lets users connect any REST API as a data source entirely through the UI
— no code changes needed per API.
---
## Features
### Core Connector (`common/data_source/rest_api_connector.py`)
- Implements `LoadConnector` and `PollConnector` interfaces for full and
incremental sync
- **Configurable authentication:** None, API Key (custom header), Bearer
Token, Basic Auth
- **Pluggable pagination:** Page-based, Offset-based, Cursor-based, or
None
- Smart page-size inference from user's query parameters to avoid
duplicate/conflicting params
- Configurable request delay between pages to prevent API rate limiting
- Auto-detection of the items array in JSON responses (`items`,
`results`, `data`, `records`, or first list found)
- **Advanced field mapping** with dot-notation (`country.name`), array
wildcards (`newsType[*].name`), type hints, and default values
- Optional content template rendering (`"Title: {title}\nBody: {body}"`)
- HTML stripping for content fields
- Stable document IDs via `hash128` from a configurable ID field or
auto-generated from item content
- Pydantic configuration schema with automatic coercion of UI string
inputs to dicts/lists
### Backend Registration (`rag/svr/sync_data_source.py`,
`common/constants.py`, `common/data_source/config.py`)
- `REST_API` sync class wired into RAGFlow's `func_factory`
- Full sync (`load_from_state`) and incremental polling (`poll_source`)
support
- Credentials and config passed from task to connector following
existing patterns (MySQL, SeaFile, etc.)
### Test Connection Endpoint (`api/apps/connector_app.py`)
- `POST /v1/connector/<id>/test` validates config schema,
authentication, and API connectivity without triggering a sync
- Clear error messages for auth failures vs. config issues
### Frontend UI (`web/src/pages/user-setting/data-source/constant/`)
- **Postman-style configuration:** Base URL, Query Parameters (key=value
per line), Auth, Content Fields, Metadata Fields, Pagination Type
- Auth-type-aware form: fields for API key header/value, Bearer token,
or Basic username/password appear only when relevant
- **Advanced Settings** toggle for: Custom Headers, Max Pages, Request
Delay, Poll Timestamp Field, Request Body (POST)
- Connector icon (SVG) and i18n strings (English)
- **"Test Connection"** button to validate before syncing
---
## Controls & Safety
- Configurable max pages safety cap (default: 1000, adjustable in UI)
- Configurable request delay between pages (default: 0.5s, adjustable in
UI)
- Auth errors (401/403) fail immediately without retries; transient
errors retry with exponential backoff
- Diagnostic logging: auth setup confirmation, request details on
failure, content field extraction status
---
## Type of change
- [x] New Feature (non-breaking change which adds functionality)
##Visual Screenshots of Features
<img width="482" height="510" alt="Screenshot 2026-03-11 at 5 19 52 PM"
src="https://github.com/user-attachments/assets/dcb7ab4a-1622-44f3-bb02-d6f0527314c4"
/>
(Connector can be configured within the external data sources tab)
Configuration Parameters:
<img width="661" height="682" alt="Screenshot 2026-03-11 at 5 20 46 PM"
src="https://github.com/user-attachments/assets/5e154e71-4ab5-4872-bfb2-04f02b73c18a"
/>
<img width="661" height="682" alt="Screenshot 2026-03-11 at 5 20 54 PM"
src="https://github.com/user-attachments/assets/00cb14b7-0bcf-4b94-9d71-34e93369ecb2"
/>
Connection can be tested before attaching to dataset:
<img width="981" height="681" alt="Screenshot 2026-03-11 at 5 21 40 PM"
src="https://github.com/user-attachments/assets/aaa6eeeb-89a7-4349-bc34-2423bf8be9ee"
/>
Ingestion tested with API connector (works perfectly fine):
<img width="1062" height="705" alt="Screenshot 2026-03-11 at 5 22 30 PM"
src="https://github.com/user-attachments/assets/afcd0d58-cadd-4152-badc-d2f14d96fbec"
/>
Search & Retrieval works as well with metadata flow:
<img width="1062" height="705" alt="Screenshot 2026-03-11 at 5 23 05 PM"
src="https://github.com/user-attachments/assets/d41ee935-dcf7-4456-b317-22a76ca032c0"
/>
---------
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -127,7 +127,10 @@ const BoxTokenField = ({ value, onChange }: BoxTokenFieldProps) => {
|
||||
string,
|
||||
any
|
||||
>;
|
||||
const { user_id: _userId, code, ...rest } = credentials;
|
||||
const code = credentials.code;
|
||||
const rest = { ...credentials };
|
||||
delete rest.user_id;
|
||||
delete rest.code;
|
||||
|
||||
const finalValue: Record<string, any> = {
|
||||
...rest,
|
||||
@@ -173,7 +176,7 @@ const BoxTokenField = ({ value, onChange }: BoxTokenFieldProps) => {
|
||||
setWebStatus('error');
|
||||
setWebStatusMessage(errorMessage);
|
||||
clearWebState();
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
message.error('Unable to retrieve authorization result.');
|
||||
setWebStatus('error');
|
||||
setWebStatusMessage('Unable to retrieve authorization result.');
|
||||
@@ -304,7 +307,7 @@ const BoxTokenField = ({ value, onChange }: BoxTokenFieldProps) => {
|
||||
} else {
|
||||
message.error(data.message || 'Failed to start Box authorization.');
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
message.error('Failed to start Box authorization.');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
|
||||
@@ -95,11 +95,7 @@ const withRedirectUri = (credentials: string, redirectUri: string): string => {
|
||||
});
|
||||
};
|
||||
|
||||
const GmailTokenField = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: GmailTokenFieldProps) => {
|
||||
const GmailTokenField = ({ value, onChange }: GmailTokenFieldProps) => {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [pendingCredentials, setPendingCredentials] = useState<string>('');
|
||||
const [redirectUri, setRedirectUri] = useState('');
|
||||
@@ -195,7 +191,7 @@ const GmailTokenField = ({
|
||||
}
|
||||
message.error(data.message || 'Authorization failed.');
|
||||
clearWebState();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('Unable to retrieve authorization result.');
|
||||
clearWebState();
|
||||
}
|
||||
@@ -315,7 +311,7 @@ const GmailTokenField = ({
|
||||
} else {
|
||||
message.error(data.message || 'Failed to start browser authorization.');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('Failed to start browser authorization.');
|
||||
} finally {
|
||||
setWebAuthLoading(false);
|
||||
|
||||
@@ -192,7 +192,7 @@ const GoogleDriveTokenField = ({
|
||||
}
|
||||
message.error(data.message || 'Authorization failed.');
|
||||
clearWebState();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('Unable to retrieve authorization result.');
|
||||
clearWebState();
|
||||
}
|
||||
@@ -312,7 +312,7 @@ const GoogleDriveTokenField = ({
|
||||
} else {
|
||||
message.error(data.message || 'Failed to start browser authorization.');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('Failed to start browser authorization.');
|
||||
} finally {
|
||||
setWebAuthLoading(false);
|
||||
|
||||
@@ -41,6 +41,7 @@ export enum DataSourceKey {
|
||||
SEAFILE = 'seafile',
|
||||
MYSQL = 'mysql',
|
||||
POSTGRESQL = 'postgresql',
|
||||
REST_API = 'rest_api',
|
||||
RSS = 'rss',
|
||||
|
||||
// SHAREPOINT = 'sharepoint',
|
||||
@@ -202,6 +203,11 @@ export const generateDataSourceInfo = (t: TFunction) => {
|
||||
description: t(`setting.${DataSourceKey.GMAIL}Description`),
|
||||
icon: <SvgIcon name={'data-source/gmail'} width={38} />,
|
||||
},
|
||||
[DataSourceKey.REST_API]: {
|
||||
name: 'REST API',
|
||||
description: t(`setting.${DataSourceKey.REST_API}Description`),
|
||||
icon: <SvgIcon name={'data-source/rest-api'} width={38} />,
|
||||
},
|
||||
[DataSourceKey.MOODLE]: {
|
||||
name: 'Moodle',
|
||||
description: t(`setting.${DataSourceKey.MOODLE}Description`),
|
||||
@@ -373,47 +379,6 @@ export const getCommonExtraDefaultValues = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const getDataSourceFieldsWithExtras = (
|
||||
source?: DataSourceKey,
|
||||
): FormFieldConfig[] => {
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceFields =
|
||||
DataSourceFormFields[source as keyof typeof DataSourceFormFields] || [];
|
||||
const extraFields = getCommonExtraFields(source);
|
||||
|
||||
if (source !== DataSourceKey.JIRA) {
|
||||
return [...sourceFields, ...extraFields];
|
||||
}
|
||||
|
||||
const modeFieldIndex = sourceFields.findIndex(
|
||||
(field) => field.name === 'config.is_cloud',
|
||||
);
|
||||
if (modeFieldIndex < 0) {
|
||||
return [...sourceFields, ...extraFields];
|
||||
}
|
||||
|
||||
const sharedFields = sourceFields.slice(0, modeFieldIndex);
|
||||
const modeFields = sourceFields.slice(modeFieldIndex);
|
||||
|
||||
const sharedCheckboxFieldIndex = sharedFields.findIndex(
|
||||
(field) => field.type === FormFieldType.Checkbox,
|
||||
);
|
||||
|
||||
if (sharedCheckboxFieldIndex < 0) {
|
||||
return [...sharedFields, ...extraFields, ...modeFields];
|
||||
}
|
||||
|
||||
return [
|
||||
...sharedFields.slice(0, sharedCheckboxFieldIndex),
|
||||
...sharedFields.slice(sharedCheckboxFieldIndex),
|
||||
...extraFields,
|
||||
...modeFields,
|
||||
];
|
||||
};
|
||||
|
||||
export const DataSourceFormFields = {
|
||||
[DataSourceKey.RSS]: [
|
||||
{
|
||||
@@ -1123,6 +1088,286 @@ export const DataSourceFormFields = {
|
||||
tooltip: t('setting.postgresqlTimestampColumnTip'),
|
||||
},
|
||||
],
|
||||
[DataSourceKey.REST_API]: [
|
||||
// ── Essential fields ──────────────────────────────────────────────
|
||||
{
|
||||
label: 'Base URL',
|
||||
name: 'config.url',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'https://api.example.com/v1/resources',
|
||||
},
|
||||
{
|
||||
label: 'HTTP Method',
|
||||
name: 'config.method',
|
||||
type: FormFieldType.Select,
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
],
|
||||
defaultValue: 'GET',
|
||||
},
|
||||
{
|
||||
label: 'Query Parameters',
|
||||
name: 'config.query_params',
|
||||
type: FormFieldType.Textarea,
|
||||
required: false,
|
||||
placeholder: `key=value\none_per_line=true`,
|
||||
tooltip: t('setting.restApiQueryParamsTip'),
|
||||
},
|
||||
{
|
||||
label: 'Items Path',
|
||||
name: 'config.items_path',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: '$.items',
|
||||
tooltip: t('setting.restApiItemsPathTip'),
|
||||
},
|
||||
{
|
||||
label: 'ID Field',
|
||||
name: 'config.id_field',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'id',
|
||||
tooltip: t('setting.restApiIdFieldTip'),
|
||||
},
|
||||
{
|
||||
label: 'Auth Type',
|
||||
name: 'config.auth_type',
|
||||
type: FormFieldType.Select,
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'API Key (Header)', value: 'api_key_header' },
|
||||
{ label: 'Bearer Token', value: 'bearer' },
|
||||
{ label: 'Basic Auth', value: 'basic' },
|
||||
],
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{
|
||||
label: 'API Key Header Name',
|
||||
name: 'config.auth_config.header_name',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'X-API-Key',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.auth_type === 'api_key_header',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (
|
||||
values?.config?.auth_type === 'api_key_header' &&
|
||||
!(val != null && String(val).trim())
|
||||
) {
|
||||
return t('setting.restApiValidationApiKeyHeaderNameRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'API Key Value',
|
||||
name: 'config.credentials.api_key',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.auth_type === 'api_key_header',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (values?.config?.auth_type === 'api_key_header' && !val) {
|
||||
return t('setting.restApiValidationApiKeyRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Bearer Token',
|
||||
name: 'config.credentials.token',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
shouldRender: (values: any) => values?.config?.auth_type === 'bearer',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (values?.config?.auth_type === 'bearer' && !val) {
|
||||
return t('setting.restApiValidationBearerTokenRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Username',
|
||||
name: 'config.credentials.username',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
shouldRender: (values: any) => values?.config?.auth_type === 'basic',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (
|
||||
values?.config?.auth_type === 'basic' &&
|
||||
!(val != null && String(val).trim())
|
||||
) {
|
||||
return t('setting.restApiValidationBasicUsernameRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Password',
|
||||
name: 'config.credentials.password',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
shouldRender: (values: any) => values?.config?.auth_type === 'basic',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (values?.config?.auth_type === 'basic' && !val) {
|
||||
return t('setting.restApiValidationBasicPasswordRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Content Fields',
|
||||
name: 'config.content_fields',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'title,body',
|
||||
tooltip: t('setting.restApiContentFieldsTip'),
|
||||
},
|
||||
{
|
||||
label: 'Metadata Fields',
|
||||
name: 'config.metadata_fields',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'author,category',
|
||||
tooltip: t('setting.restApiMetadataFieldsTip'),
|
||||
},
|
||||
{
|
||||
label: 'Pagination Type',
|
||||
name: 'config.pagination_type',
|
||||
type: FormFieldType.Select,
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Page', value: 'page' },
|
||||
{ label: 'Offset', value: 'offset' },
|
||||
{ label: 'Cursor', value: 'cursor' },
|
||||
],
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{
|
||||
label: 'Start Page',
|
||||
name: 'config.pagination_config.start_page',
|
||||
type: FormFieldType.Number,
|
||||
required: false,
|
||||
defaultValue: 1,
|
||||
shouldRender: (values: any) => values?.config?.pagination_type === 'page',
|
||||
},
|
||||
{
|
||||
label: 'Offset Param',
|
||||
name: 'config.pagination_config.offset_param',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
defaultValue: 'offset',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.pagination_type === 'offset',
|
||||
},
|
||||
{
|
||||
label: 'Start Offset',
|
||||
name: 'config.pagination_config.start_offset',
|
||||
type: FormFieldType.Number,
|
||||
required: false,
|
||||
defaultValue: 0,
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.pagination_type === 'offset',
|
||||
},
|
||||
{
|
||||
label: 'Cursor Param',
|
||||
name: 'config.pagination_config.cursor_param',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
defaultValue: 'cursor',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.pagination_type === 'cursor',
|
||||
},
|
||||
{
|
||||
label: 'Next Cursor JSONPath',
|
||||
name: 'config.pagination_config.next_cursor_path',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: '$.next_cursor',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.pagination_type === 'cursor',
|
||||
tooltip: t('setting.restApiNextCursorPathTip'),
|
||||
},
|
||||
// ── Advanced settings toggle ──────────────────────────────────────
|
||||
{
|
||||
label: 'Advanced Settings',
|
||||
name: 'config.show_advanced',
|
||||
type: FormFieldType.Switch,
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
// ── Advanced fields (hidden until toggled) ────────────────────────
|
||||
{
|
||||
label: 'Custom Headers (JSON)',
|
||||
name: 'config.headers',
|
||||
type: FormFieldType.Textarea,
|
||||
required: false,
|
||||
placeholder: `{"X-Custom-Header": "value"}`,
|
||||
tooltip: t('setting.restApiHeadersTip'),
|
||||
shouldRender: (values: any) => !!values?.config?.show_advanced,
|
||||
},
|
||||
{
|
||||
label: 'Limit Param',
|
||||
name: 'config.pagination_config.limit_param',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'limit (leave empty if already in Query Parameters)',
|
||||
shouldRender: (values: any) =>
|
||||
!!values?.config?.show_advanced &&
|
||||
values?.config?.pagination_type === 'offset',
|
||||
},
|
||||
{
|
||||
label: 'Initial Cursor',
|
||||
name: 'config.pagination_config.initial_cursor',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
shouldRender: (values: any) =>
|
||||
!!values?.config?.show_advanced &&
|
||||
values?.config?.pagination_type === 'cursor',
|
||||
},
|
||||
{
|
||||
label: 'Max Pages',
|
||||
name: 'config.max_pages',
|
||||
type: FormFieldType.Number,
|
||||
required: false,
|
||||
defaultValue: 1000,
|
||||
shouldRender: (values: any) => !!values?.config?.show_advanced,
|
||||
},
|
||||
{
|
||||
label: 'Request Delay (seconds)',
|
||||
name: 'config.request_delay',
|
||||
type: FormFieldType.Number,
|
||||
required: false,
|
||||
defaultValue: 0.5,
|
||||
placeholder: '0.5',
|
||||
tooltip: t('setting.restApiRequestDelayTip'),
|
||||
shouldRender: (values: any) => !!values?.config?.show_advanced,
|
||||
},
|
||||
{
|
||||
label: 'Poll Timestamp Field',
|
||||
name: 'config.poll_timestamp_field',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'updated_at',
|
||||
tooltip: t('setting.restApiPollTimestampFieldTip'),
|
||||
shouldRender: (values: any) => !!values?.config?.show_advanced,
|
||||
},
|
||||
{
|
||||
label: 'Request Body (POST) JSON',
|
||||
name: 'config.request_body',
|
||||
type: FormFieldType.Textarea,
|
||||
required: false,
|
||||
placeholder: `{"status": "published"}`,
|
||||
tooltip: t('setting.restApiRequestBodyTip'),
|
||||
shouldRender: (values: any) =>
|
||||
!!values?.config?.show_advanced && values?.config?.method === 'POST',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DataSourceFormDefaultValues = {
|
||||
@@ -1477,4 +1722,74 @@ export const DataSourceFormDefaultValues = {
|
||||
},
|
||||
},
|
||||
},
|
||||
[DataSourceKey.REST_API]: {
|
||||
name: '',
|
||||
source: DataSourceKey.REST_API,
|
||||
config: {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
query_params: '',
|
||||
headers: '',
|
||||
auth_type: 'none',
|
||||
auth_config: {},
|
||||
items_path: '',
|
||||
id_field: '',
|
||||
content_fields: '',
|
||||
metadata_fields: '',
|
||||
pagination_type: 'none',
|
||||
pagination_config: {},
|
||||
poll_timestamp_field: '',
|
||||
request_body: '',
|
||||
max_pages: 1000,
|
||||
request_delay: 0.5,
|
||||
show_advanced: false,
|
||||
credentials: {
|
||||
api_key: '',
|
||||
token: '',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getDataSourceFieldsWithExtras = (
|
||||
source?: DataSourceKey,
|
||||
): FormFieldConfig[] => {
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceFields =
|
||||
DataSourceFormFields[source as keyof typeof DataSourceFormFields] || [];
|
||||
const extraFields = getCommonExtraFields(source);
|
||||
|
||||
if (source !== DataSourceKey.JIRA) {
|
||||
return [...sourceFields, ...extraFields];
|
||||
}
|
||||
|
||||
const modeFieldIndex = sourceFields.findIndex(
|
||||
(field) => field.name === 'config.is_cloud',
|
||||
);
|
||||
if (modeFieldIndex < 0) {
|
||||
return [...sourceFields, ...extraFields];
|
||||
}
|
||||
|
||||
const sharedFields = sourceFields.slice(0, modeFieldIndex);
|
||||
const modeFields = sourceFields.slice(modeFieldIndex);
|
||||
|
||||
const sharedCheckboxFieldIndex = sharedFields.findIndex(
|
||||
(field) => field.type === FormFieldType.Checkbox,
|
||||
);
|
||||
|
||||
if (sharedCheckboxFieldIndex < 0) {
|
||||
return [...sharedFields, ...extraFields, ...modeFields];
|
||||
}
|
||||
|
||||
return [
|
||||
...sharedFields.slice(0, sharedCheckboxFieldIndex),
|
||||
...sharedFields.slice(sharedCheckboxFieldIndex),
|
||||
...extraFields,
|
||||
...modeFields,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
DataSourceFormBaseFields,
|
||||
DataSourceFormDefaultValues,
|
||||
DataSourceKey,
|
||||
getCommonExtraDefaultValues,
|
||||
getDataSourceFieldsWithExtras,
|
||||
mergeDataSourceFormValues,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
useAddDataSource,
|
||||
useDataSourceResume,
|
||||
useFetchDataSourceDetail,
|
||||
useTestDataSource,
|
||||
} from '../hooks';
|
||||
import { DataSourceLogsTable } from './log-table';
|
||||
|
||||
@@ -144,6 +146,7 @@ const SourceDetailPage = () => {
|
||||
}, [detail, runSchedule]);
|
||||
|
||||
const { addLoading, handleAddOk } = useAddDataSource({ isEdit: true });
|
||||
const { loading: testLoading, handleTest } = useTestDataSource();
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
formRef?.current?.submit();
|
||||
@@ -187,7 +190,6 @@ const SourceDetailPage = () => {
|
||||
detail as FieldValues,
|
||||
),
|
||||
};
|
||||
console.log('defaultValue', defaultValueTemp);
|
||||
setDefaultValues(defaultValueTemp);
|
||||
}
|
||||
}, [detail, customFields, onSubmit]);
|
||||
@@ -213,7 +215,18 @@ const SourceDetailPage = () => {
|
||||
defaultValues={defaultValues}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-[1200px] flex justify-end">
|
||||
<div className="max-w-[1200px] flex justify-end gap-2">
|
||||
{detail?.source === DataSourceKey.REST_API && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testLoading}
|
||||
loading={testLoading}
|
||||
>
|
||||
{t('setting.restApiTestConnection')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import dataSourceService, {
|
||||
deleteDataSource,
|
||||
featchDataSourceDetail,
|
||||
getDataSourceLogs,
|
||||
testDataSource,
|
||||
} from '@/services/data-source-service';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
@@ -213,3 +214,28 @@ export const useDataSourceRebuild = () => {
|
||||
);
|
||||
return { handleRebuild };
|
||||
};
|
||||
|
||||
export const useTestDataSource = () => {
|
||||
const [currentQueryParameters] = useSearchParams();
|
||||
const id = currentQueryParameters.get('id');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await testDataSource(id);
|
||||
if (data.code === 0) {
|
||||
message.success(t('setting.restApiTestSuccess'));
|
||||
} else {
|
||||
message.error(data.message || t('setting.restApiTestFailed'));
|
||||
}
|
||||
} catch {
|
||||
message.error(t('setting.restApiTestFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
return { loading, handleTest };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user