| /* |
| * 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; |