blob: c4b39962747c45cddf805d3800cd83b7335b7c8c [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import {
Alert,
Button,
Divider,
Drawer,
Form,
Input,
notification,
PageHeader,
Popconfirm,
Select,
Space,
Switch,
} from 'antd';
import { useIntl } from 'umi';
import { js_beautify } from 'js-beautify';
import { LinkOutlined } from '@ant-design/icons';
import Ajv from 'ajv';
import type { DefinedError } from 'ajv';
import addFormats from 'ajv-formats';
import { compact, omit } from 'lodash';
import type { Monaco } from '@monaco-editor/react';
import Editor from '@monaco-editor/react';
import type { languages } from 'monaco-editor';
import { fetchSchema } from './service';
import { json2yaml, yaml2json } from '../../helpers';
import { PluginForm, PLUGIN_UI_LIST } from './UI';
import * as allModels from './Models';
import * as modelCode from './modelCode';
type Props = {
name: string;
type?: 'global' | 'scoped';
schemaType: PluginComponent.Schema;
initialData: Record<string, any>;
pluginList: PluginComponent.Meta[];
readonly?: boolean;
visible: boolean;
maskClosable?: boolean;
isEnabled?: boolean;
onClose?: () => void;
onChange?: (data: PluginComponent.PluginDetailValues) => void;
};
const ajv = new Ajv();
addFormats(ajv);
const FORM_ITEM_LAYOUT = {
labelCol: {
span: 3,
},
wrapperCol: {
span: 16,
},
};
// NOTE: This function has side effect because it mutates the original schema data
const injectDisableProperty = (schema: Record<string, any>) => {
// NOTE: The frontend will inject the disable property into schema just like the manager-api does
if (!schema.properties) {
// eslint-disable-next-line
schema.properties = {};
}
// eslint-disable-next-line
(schema.properties as any).disable = {
type: 'boolean',
};
return schema;
};
const PluginDetail: React.FC<Props> = ({
name,
type = 'scoped',
schemaType = 'route',
visible,
pluginList = [],
readonly = false,
maskClosable = true,
isEnabled = false,
initialData = {},
onClose = () => {},
onChange = () => {},
}) => {
const { formatMessage } = useIntl();
enum monacoModeList {
JSON = 'JSON',
YAML = 'YAML',
UIForm = 'Form',
}
const [form] = Form.useForm();
const [UIForm] = Form.useForm();
const data = initialData[name] || {};
const pluginType = pluginList.find((item) => item.name === name)?.originType;
const schemaName = name === 'basic-auth' ? 'consumer_schema' : 'schema';
const pluginSchema = pluginList.find((item) => item.name === name)?.[schemaName];
const [content, setContent] = useState<string>(JSON.stringify(data, null, 2));
const [monacoMode, setMonacoMode] = useState<PluginComponent.MonacoLanguage>(monacoModeList.JSON);
const modeOptions: { label: string; value: string }[] = [
{ label: monacoModeList.JSON, value: monacoModeList.JSON },
{ label: monacoModeList.YAML, value: monacoModeList.YAML },
];
const targetPluginName = pluginList.find((item) => item.name === name)?.name;
const filteredName = name.replace("-","");
const targetModel = allModels[`${filteredName}Model`];
const targetModelCode = modelCode?.[`${filteredName}`];
if (PLUGIN_UI_LIST.includes(name)) {
modeOptions.push({
label: formatMessage({ id: 'component.plugin.form' }),
value: monacoModeList.UIForm,
});
}
const getUIFormData = () => {
const formData = UIForm.getFieldsValue();
if (name === 'cors') {
const newMethods = formData.allow_methods.join(',');
const isFilterAllowRegex = compact(formData.allow_origins_by_regex).length === 0;
const isFilterAllowMetadata = compact(formData.allow_origins_by_metadata).length === 0;
// Note: default allow_origins_by_regex and allow_origins_by_metadata setted for UI is [''], but this is not allowed, omit it.
if (isFilterAllowRegex || isFilterAllowMetadata) {
const filterAllowRegex = (isFilterAllowRegex && 'allow_origins_by_regex') || '';
const filterAllowMetadata = (isFilterAllowMetadata && 'allow_origins_by_metadata') || '';
return omit({ ...formData, allow_methods: newMethods }, [
filterAllowRegex,
filterAllowMetadata,
]);
}
return { ...formData, allow_methods: newMethods };
}
if (name === 'referer-restriction') {
if ('whitelist' in formData) {
formData.whitelist = formData.whitelist.filter((item: string) => !!item);
if (formData.whitelist <= 0) {
delete formData.whitelist;
}
}
if ('blacklist' in formData) {
formData.blacklist = formData.blacklist.filter((item: string) => !!item);
if (formData.blacklist <= 0) {
delete formData.blacklist;
}
}
}
return formData;
};
const setUIFormData = (formData: any) => {
if (name === 'cors' && formData) {
const methods = (formData.allow_methods || '').length
? formData.allow_methods.split(',')
: ['*'];
UIForm.setFieldsValue({ ...formData, allow_methods: methods });
return;
}
UIForm.setFieldsValue(formData);
};
useEffect(() => {
form.setFieldsValue({
disable: isEnabled ? true : initialData[name] && !initialData[name].disable,
scope: 'global',
});
if (PLUGIN_UI_LIST.includes(name)) {
setMonacoMode(monacoModeList.UIForm);
setUIFormData(initialData[name]);
}
}, []);
const formatYaml = (yaml: string): string => {
const json = yaml2json(yaml, true);
if (json.error) {
return yaml;
}
return json2yaml(json.data).data;
};
const editorWillMount = (monaco: Monaco) => {
fetchSchema(name, schemaType).then((schema) => {
const schemaConfig: languages.json.DiagnosticsOptions = {
validate: true,
schemas: [
{
// useless placeholder
uri: `https://apisix.apache.org/`,
fileMatch: ['*'],
schema,
},
],
trailingCommas: 'error',
enableSchemaRequest: false,
};
const yamlFormatProvider: languages.DocumentFormattingEditProvider = {
provideDocumentFormattingEdits(model) {
return [
{
text: formatYaml(model.getValue()),
range: model.getFullModelRange(),
},
];
},
};
monaco.languages.registerDocumentFormattingEditProvider('yaml', yamlFormatProvider);
monaco.editor.getModels().forEach((model) => model.updateOptions({ tabSize: 2 }));
monaco.languages.json.jsonDefaults.setDiagnosticsOptions(schemaConfig);
});
};
const validateData = (pluginName: string, value: PluginComponent.Data) => {
return fetchSchema(pluginName, schemaType).then((schema) => {
return new Promise((resolve) => {
if (schema.oneOf) {
(schema.oneOf || []).forEach((item: any) => {
injectDisableProperty(item);
});
} else {
injectDisableProperty(schema);
}
const validate = ajv.compile(schema);
if (validate(value)) {
resolve(value);
return;
}
// eslint-disable-next-line
for (const err of validate.errors as DefinedError[]) {
let description = '';
switch (err.keyword) {
case 'enum':
description = `${err.dataPath} ${err.message}: ${err.params.allowedValues.join(
', ',
)}`;
break;
case 'minItems':
case 'type':
description = `${err.dataPath} ${err.message}`;
break;
case 'oneOf':
case 'required':
description = err.message || '';
break;
default:
description = `${err.schemaPath} ${err.message}`;
}
notification.error({
message: 'Invalid plugin data',
description,
});
}
});
});
};
const handleModeChange = (value: PluginComponent.MonacoLanguage) => {
switch (value) {
case monacoModeList.JSON: {
if (monacoMode === monacoModeList.YAML) {
const { data: yamlData, error } = yaml2json(content, true);
if (error) {
notification.error({ message: formatMessage({ id: 'component.global.invalidYaml' }) });
return;
}
setContent(js_beautify(yamlData, { indent_size: 2 }));
} else {
setContent(js_beautify(JSON.stringify(getUIFormData()), { indent_size: 2 }));
}
break;
}
case monacoModeList.YAML: {
const jsonData =
monacoMode === monacoModeList.JSON ? content : JSON.stringify(getUIFormData());
const { data: yamlData, error } = json2yaml(jsonData);
if (error) {
notification.error({ message: formatMessage({ id: 'component.global.invalidJson' }) });
return;
}
setContent(yamlData);
break;
}
case monacoModeList.UIForm: {
if (monacoMode === monacoModeList.JSON) {
setUIFormData(JSON.parse(content));
} else {
const { data: yamlData, error } = yaml2json(content, true);
if (error) {
notification.error({ message: formatMessage({ id: 'component.global.invalidYaml' }) });
return;
}
setUIFormData(JSON.parse(yamlData));
}
break;
}
default:
break;
}
setMonacoMode(value);
};
const isNoConfigurationRequired =
pluginType === 'auth' &&
schemaType !== 'consumer' &&
monacoMode !== monacoModeList.UIForm &&
targetPluginName !== 'key-auth';
return (
<Drawer
title={formatMessage({ id: 'component.plugin.editor' })}
visible={visible}
placement="right"
closable={false}
maskClosable={maskClosable}
destroyOnClose
onClose={onClose}
width={700}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{' '}
<Button onClick={onClose} key={1}>
{formatMessage({ id: 'component.global.cancel' })}
</Button>
<Space>
<Popconfirm
title={formatMessage({ id: 'page.plugin.drawer.popconfirm.title.delete' })}
okText={formatMessage({ id: 'component.global.confirm' })}
cancelText={formatMessage({ id: 'component.global.cancel' })}
disabled={readonly}
onConfirm={() => {
onChange({
formData: form.getFieldsValue(),
monacoData: {},
shouldDelete: true,
});
}}
>
{initialData[name] ? (
<Button key={3} type="primary" danger disabled={readonly}>
{formatMessage({ id: 'component.global.delete' })}
</Button>
) : null}
</Popconfirm>
<Button
key={2}
disabled={readonly}
type="primary"
onClick={() => {
try {
let editorData;
if (monacoMode === monacoModeList.JSON) {
editorData = JSON.parse(content);
} else if (monacoMode === monacoModeList.YAML) {
editorData = yaml2json(content, false).data;
} else {
editorData = getUIFormData();
}
validateData(name, editorData).then((value) => {
onChange({ formData: form.getFieldsValue(), monacoData: value });
});
} catch (error) {
notification.error({
message: formatMessage({ id: 'component.global.invalidJson' }),
});
}
}}
>
{formatMessage({ id: 'component.global.submit' })}
</Button>
</Space>
</div>
}
>
<style>
{`
.site-page-header {
border: 1px solid rgb(235, 237, 240);
margin-top:10px;
}
.ant-input[disabled] {
color: #000;
}
`}
</style>
<Form {...FORM_ITEM_LAYOUT} style={{ marginTop: '10px' }} form={form}>
<Form.Item label={formatMessage({ id: 'component.global.name' })}>
<Input value={name} bordered={false} disabled />
</Form.Item>
<Form.Item
label={formatMessage({ id: 'component.global.enable' })}
valuePropName="checked"
name="disable"
>
<Switch
defaultChecked={isEnabled ? true : initialData[name] && !initialData[name].disable}
disabled={readonly || isEnabled}
/>
</Form.Item>
{type === 'global' && (
<Form.Item label={formatMessage({ id: 'component.global.scope' })} name="scope">
<Select disabled>
<Select.Option value="global">{formatMessage({ id: 'other.global' })}</Select.Option>
</Select>
</Form.Item>
)}
</Form>
<Divider orientation="left">{formatMessage({ id: 'component.global.data.editor' })}</Divider>
<PageHeader
title=""
subTitle={
isNoConfigurationRequired ? (
<Alert
message={formatMessage({ id: 'component.plugin.noConfigurationRequired' })}
type="warning"
/>
) : null
}
ghost={false}
extra={[
<Select
defaultValue={monacoModeList.JSON}
value={monacoMode}
options={modeOptions}
onChange={handleModeChange}
data-cy="monaco-mode"
key={1}
/>,
<Button
type="default"
icon={<LinkOutlined />}
onClick={() => {
if (name.startsWith('serverless')) {
window.open('https://apisix.apache.org/docs/apisix/plugins/serverless');
} else {
window.open(`https://apisix.apache.org/docs/apisix/plugins/${name}`);
}
}}
key={3}
>
{formatMessage({ id: 'component.global.document' })}
</Button>,
]}
/>
{Boolean(monacoMode === monacoModeList.UIForm) && (
<PluginForm
name={name}
schema={pluginSchema}
form={UIForm}
renderForm={!(pluginType === 'auth' && schemaType !== 'consumer')}
/>
)}
<div style={{ display: monacoMode === monacoModeList.UIForm ? 'none' : 'unset' }}>
<Editor
value={content}
onChange={(text) => {
if (text) {
setContent(text);
} else {
setContent('');
}
}}
language={monacoMode.toLocaleLowerCase()}
beforeMount={editorWillMount}
onMount={(editor) => {
// NOTE: for debug & test
// @ts-ignore
window.monacoEditor = editor;
if(targetModel)editor.setValue(targetModelCode);
}}
options={{
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
wordWrap: 'on',
minimap: { enabled: false },
readOnly: readonly,
}}
/>
</div>
</Drawer>
);
};
export default PluginDetail;