From f9e1006da0da966083ca7f049617524b98b7cbe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=90=9A=E8=87=B4=E8=BF=9C?= Date: Wed, 14 Apr 2021 15:31:22 +0800 Subject: [PATCH 1/7] chore: update Service module (#1749) --- web/src/pages/Service/Create.tsx | 21 +++++++++------------ web/src/pages/Service/List.tsx | 5 ++--- web/src/pages/Service/components/Step1.tsx | 11 ++++++++--- web/src/pages/Service/locales/en-US.ts | 5 ++++- web/src/pages/Service/locales/zh-CN.ts | 6 +++++- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/web/src/pages/Service/Create.tsx b/web/src/pages/Service/Create.tsx index 201c3ddbe2..cb3d6672a0 100644 --- a/web/src/pages/Service/Create.tsx +++ b/web/src/pages/Service/Create.tsx @@ -81,13 +81,12 @@ const Page: React.FC = (props) => { (serviceId ? update(serviceId, data) : create(data)) .then(() => { notification.success({ - message: `${ - serviceId - ? formatMessage({ id: 'component.global.edit' }) - : formatMessage({ id: 'component.global.create' }) - } ${formatMessage({ id: 'menu.service' })} ${formatMessage({ - id: 'component.status.success', - })}`, + message: `${serviceId + ? formatMessage({ id: 'component.global.edit' }) + : formatMessage({ id: 'component.global.create' }) + } ${formatMessage({ id: 'menu.service' })} ${formatMessage({ + id: 'component.status.success', + })}`, }); history.push('/service/list'); }) @@ -115,11 +114,9 @@ const Page: React.FC = (props) => { return ( <> diff --git a/web/src/pages/Service/List.tsx b/web/src/pages/Service/List.tsx index 7bfab39eec..2c5c5124df 100644 --- a/web/src/pages/Service/List.tsx +++ b/web/src/pages/Service/List.tsx @@ -109,7 +109,7 @@ const Page: React.FC = () => { ]; return ( - + actionRef={ref} rowKey="id" @@ -129,12 +129,11 @@ const Page: React.FC = () => { {formatMessage({ id: 'component.global.create' })} , - , ]} diff --git a/web/src/pages/Service/components/Step1.tsx b/web/src/pages/Service/components/Step1.tsx index e904a577dd..12b38a0ab7 100644 --- a/web/src/pages/Service/components/Step1.tsx +++ b/web/src/pages/Service/components/Step1.tsx @@ -45,11 +45,16 @@ const Step1: React.FC = ({ return ( <>
- - + + - + Date: Wed, 14 Apr 2021 18:49:06 +0800 Subject: [PATCH 2/7] fix: user login request should remove its own prefix option (#1701) Co-authored-by: zzzdong --- web/src/pages/User/components/LoginMethodPassword.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/pages/User/components/LoginMethodPassword.tsx b/web/src/pages/User/components/LoginMethodPassword.tsx index 1ed770860a..10176a4c09 100644 --- a/web/src/pages/User/components/LoginMethodPassword.tsx +++ b/web/src/pages/User/components/LoginMethodPassword.tsx @@ -102,10 +102,9 @@ const LoginMethodPassword: UserModule.LoginMethod = { submit: async ({ username, password }) => { if (username !== '' && password !== '') { try { - const result = await request('/apisix/admin/user/login', { + const result = await request('/user/login', { method: 'POST', requestType: 'json', - prefix: '', data: { username, password, From 14166d9ee4ae630c78ebacb78693239478985cdc Mon Sep 17 00:00:00 2001 From: litesun Date: Wed, 14 Apr 2021 22:56:49 +0800 Subject: [PATCH 3/7] feat: add proxy-mirror plugin form (#1725) --- ...te-consumer-with-proxy-mirror-form.spec.js | 103 +++++++++++++++++ ...reate-route-with-proxy-mirror-form.spec.js | 105 ++++++++++++++++++ web/cypress/support/commands.js | 11 ++ web/src/components/Plugin/UI/plugin.tsx | 5 +- web/src/components/Plugin/UI/proxy-mirror.tsx | 61 ++++++++++ web/src/components/Plugin/locales/en-US.ts | 5 + web/src/components/Plugin/locales/zh-CN.ts | 5 + 7 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 web/cypress/integration/consumer/create-consumer-with-proxy-mirror-form.spec.js create mode 100644 web/cypress/integration/route/create-route-with-proxy-mirror-form.spec.js create mode 100644 web/src/components/Plugin/UI/proxy-mirror.tsx diff --git a/web/cypress/integration/consumer/create-consumer-with-proxy-mirror-form.spec.js b/web/cypress/integration/consumer/create-consumer-with-proxy-mirror-form.spec.js new file mode 100644 index 0000000000..2325ec43b6 --- /dev/null +++ b/web/cypress/integration/consumer/create-consumer-with-proxy-mirror-form.spec.js @@ -0,0 +1,103 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and delete consumer with proxy-mirror plugin form', () => { + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + const selector = { + host: "#host", + alert: "[role=alert]" + } + + it('should create consumer with proxy-mirror form', function () { + cy.visit('/'); + cy.contains('Consumer').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + // basic information + cy.get(this.domSelector.username).type(this.data.consumerName); + cy.get(this.domSelector.description).type(this.data.description); + cy.contains('Next').click(); + + // config auth plugin + cy.contains(this.domSelector.pluginCard, 'key-auth').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.get(this.domSelector.disabledSwitcher).click(); + // edit codemirror + cy.get(this.domSelector.codeMirror) + .first() + .then((editor) => { + editor[0].CodeMirror.setValue( + JSON.stringify({ + key: 'test', + }), + ); + cy.contains('button', 'Submit').click(); + }); + + cy.contains(this.domSelector.pluginCard, 'proxy-mirror').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + + cy.focused(this.domSelector.drawer).should('exist'); + + // config proxy-mirror form with wrong host + cy.get(selector.host).type('127.0.0.1:1999'); + cy.get(selector.alert).contains('address needs to contain schema: http or https, not URI part'); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.notification).should('contain', 'Invalid plugin data'); + cy.get(this.domSelector.notificationCloseIcon).click(); + + // config proxy-mirror form with correct host + cy.get(selector.host).clear().type('http://127.0.0.1:1999'); + cy.get(selector.alert).should('not.exist'); + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.get(this.domSelector.notification).should('contain', this.data.createConsumerSuccess); + }); + + it('should delete the consumer', function () { + cy.visit('/consumer/list'); + cy.contains(this.data.consumerName).should('be.visible').siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(this.domSelector.notification).should('contain', this.data.deleteConsumerSuccess); + }); +}); diff --git a/web/cypress/integration/route/create-route-with-proxy-mirror-form.spec.js b/web/cypress/integration/route/create-route-with-proxy-mirror-form.spec.js new file mode 100644 index 0000000000..7637568731 --- /dev/null +++ b/web/cypress/integration/route/create-route-with-proxy-mirror-form.spec.js @@ -0,0 +1,105 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and delete route with proxy-mirror form', () => { + const selector = { + host: "#host", + alert: ".ant-form-item-explain" + } + + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + it('should create route with proxy-mirror form', function () { + cy.visit('/'); + cy.contains('Route').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + cy.contains('Next').click().click(); + cy.get(this.domSelector.name).type('routeName'); + cy.get(this.domSelector.description).type('desc'); + cy.contains('Next').click(); + + cy.get(this.domSelector.nodes_0_host).type('127.0.0.1'); + cy.contains('Next').click(); + + // config proxy-mirror plugin + cy.contains('proxy-mirror').parents(this.domSelector.pluginCardBordered).within(() => { + cy.get('button').click({ + force: true + }); + }); + + cy.get(this.domSelector.drawer).should('be.visible').within(() => { + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.checkedSwitcher).should('exist'); + }); + + // config proxy-mirror form with wrong host + cy.get(selector.host).type('127.0.0.1:1999'); + cy.get(selector.alert).contains('address needs to contain schema: http or https, not URI part'); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.notification).should('contain', 'Invalid plugin data'); + cy.get(this.domSelector.notificationCloseIcon).click(); + + // config proxy-mirror form with correct host + cy.get(selector.host).clear().type('http://127.0.0.1:1999'); + cy.get(selector.alert).should('not.exist'); + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.contains(this.data.submitSuccess); + + // back to route list page + cy.contains('Goto List').click(); + cy.url().should('contains', 'routes/list'); + }); + + it('should delete the route', function () { + cy.visit('/routes/list'); + const { + domSelector, + data + } = this; + + cy.get(domSelector.name).clear().type('routeName'); + cy.contains('Search').click(); + cy.contains('routeName').siblings().contains('More').click(); + cy.contains('Delete').click(); + cy.get(domSelector.deleteAlert).should('be.visible').within(() => { + cy.contains('OK').click(); + }); + cy.get(domSelector.notification).should('contain', data.deleteRouteSuccess); + cy.get(domSelector.notificationCloseIcon).click(); + }); +}); diff --git a/web/cypress/support/commands.js b/web/cypress/support/commands.js index a55a8d0648..a4b08e4145 100644 --- a/web/cypress/support/commands.js +++ b/web/cypress/support/commands.js @@ -66,6 +66,17 @@ Cypress.Commands.add('configurePlugins', (cases) => { // NOTE: wait for the Drawer to appear on the DOM cy.focused(domSelector.drawer).should('exist'); + + cy.get(domSelector.codeMirrorMode).invoke('text').then(text => { + if (text === 'Form') { + cy.wait(5000); + cy.get(domSelector.codeMirrorMode).should('be.visible'); + cy.get(domSelector.codeMirrorMode).click(); + cy.get(domSelector.selectDropdown).should('be.visible'); + cy.get(domSelector.selectJSON).click(); + } + }); + cy.get(domSelector.drawer, { timeout }).within(() => { cy.get(domSelector.switch).click({ force: true, diff --git a/web/src/components/Plugin/UI/plugin.tsx b/web/src/components/Plugin/UI/plugin.tsx index 79a2df5754..c045750121 100644 --- a/web/src/components/Plugin/UI/plugin.tsx +++ b/web/src/components/Plugin/UI/plugin.tsx @@ -20,6 +20,7 @@ import { Empty } from 'antd'; import { useIntl } from 'umi'; import BasicAuth from './basic-auth'; +import ProxyMirror from './proxy-mirror'; import LimitConn from './limit-conn'; type Props = { @@ -28,7 +29,7 @@ type Props = { renderForm: boolean } -export const PLUGIN_UI_LIST = ['basic-auth', 'limit-conn']; +export const PLUGIN_UI_LIST = ['basic-auth', 'limit-conn', 'proxy-mirror']; export const PluginForm: React.FC = ({ name, renderForm, form }) => { @@ -39,6 +40,8 @@ export const PluginForm: React.FC = ({ name, renderForm, form }) => { switch (name) { case 'basic-auth': return + case 'proxy-mirror': + return case 'limit-conn': return default: diff --git a/web/src/components/Plugin/UI/proxy-mirror.tsx b/web/src/components/Plugin/UI/proxy-mirror.tsx new file mode 100644 index 0000000000..aa5db8f7a9 --- /dev/null +++ b/web/src/components/Plugin/UI/proxy-mirror.tsx @@ -0,0 +1,61 @@ +/* + * 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 from 'react'; +import type { FormInstance } from 'antd/es/form'; +import { Form, Input } from 'antd'; +import { useIntl } from 'umi'; + +type Props = { + form: FormInstance; +}; + +const FORM_ITEM_LAYOUT = { + labelCol: { + span: 4, + }, + wrapperCol: { + span: 10 + }, +}; + +const ProxyMirror: React.FC = ({ form }) => { + const { formatMessage } = useIntl(); + + return ( +
+ + + +
+ ); +} + +export default ProxyMirror; diff --git a/web/src/components/Plugin/locales/en-US.ts b/web/src/components/Plugin/locales/en-US.ts index 2b63c63573..f235b95cd9 100644 --- a/web/src/components/Plugin/locales/en-US.ts +++ b/web/src/components/Plugin/locales/en-US.ts @@ -22,6 +22,11 @@ export default { 'component.plugin.pluginTemplate.tip1': '1. When a route already have plugins field configured, the plugins in the plugin template will be merged into it.', 'component.plugin.pluginTemplate.tip2': '2. The same plugin in the plugin template will override one in the plugins', + // proxy-mirror + 'component.pluginForm.proxy-mirror.host.tooltip': 'Specify a mirror service address, e.g. http://127.0.0.1:9797 (address needs to contain schema: http or https, not URI part)', + 'component.pluginForm.proxy-mirror.host.extra': 'e.g. http://127.0.0.1:9797 (address needs to contain schema: http or https, not URI part)', + 'component.pluginForm.proxy-mirror.host.ruletip': 'address needs to contain schema: http or https, not URI part', + // limit-conn 'component.pluginForm.limit-conn.conn.tooltip': 'the maximum number of concurrent requests allowed. Requests exceeding this ratio (and below conn + burst) will get delayed(the latency seconds is configured by default_conn_delay) to conform to this threshold.', 'component.pluginForm.limit-conn.burst.tooltip': 'the number of excessive concurrent requests (or connections) allowed to be delayed.', diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts index c106b2e0dc..2f2d5ba14b 100644 --- a/web/src/components/Plugin/locales/zh-CN.ts +++ b/web/src/components/Plugin/locales/zh-CN.ts @@ -22,6 +22,11 @@ export default { 'component.plugin.pluginTemplate.tip1': '1. 若路由已配置插件,则插件模板数据将与已配置的插件数据合并。', 'component.plugin.pluginTemplate.tip2': '2. 插件模板相同的插件会覆盖掉原有的插件。', + // proxy-mirror + 'component.pluginForm.proxy-mirror.host.tooltip': '指定镜像服务地址,例如:http://127.0.0.1:9797(地址中需要包含 schema :http或https,不能包含 URI 部分)', + 'component.pluginForm.proxy-mirror.host.extra': '例如:http://127.0.0.1:9797(地址中需要包含 schema:http或https,不能包含 URI 部分)', + 'component.pluginForm.proxy-mirror.host.ruletip': '地址中需要包含 schema :http或https,不能包含 URI 部分', + // limit-conn 'component.pluginForm.limit-conn.conn.tooltip': '允许的最大并发请求数。超过 conn 的限制、但是低于 conn + burst 的请求,将被延迟处理。', 'component.pluginForm.limit-conn.burst.tooltip': '允许被延迟处理的并发请求数。', From b6a175f42c5a349d0e9aab6e016fb74a14c1810b Mon Sep 17 00:00:00 2001 From: litesun Date: Thu, 15 Apr 2021 05:01:22 +0800 Subject: [PATCH 4/7] feat: add api-breaker plugin form (#1730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 琚致远 --- ...sumer-with-api-breaker-plugin-form.spec.js | 101 ++++++++++ ...create-route-with-api-breaker-form.spec.js | 105 ++++++++++ web/src/components/Plugin/UI/api-breaker.tsx | 186 ++++++++++++++++++ web/src/components/Plugin/UI/plugin.tsx | 5 +- web/src/components/Plugin/locales/en-US.ts | 8 + web/src/components/Plugin/locales/zh-CN.ts | 8 + 6 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 web/cypress/integration/consumer/create-consumer-with-api-breaker-plugin-form.spec.js create mode 100644 web/cypress/integration/route/create-route-with-api-breaker-form.spec.js create mode 100644 web/src/components/Plugin/UI/api-breaker.tsx diff --git a/web/cypress/integration/consumer/create-consumer-with-api-breaker-plugin-form.spec.js b/web/cypress/integration/consumer/create-consumer-with-api-breaker-plugin-form.spec.js new file mode 100644 index 0000000000..c652ad937d --- /dev/null +++ b/web/cypress/integration/consumer/create-consumer-with-api-breaker-plugin-form.spec.js @@ -0,0 +1,101 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and delete consumer with api-breaker plugin form', () => { + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + const selector = { + break_response_code: "#break_response_code" + } + + const data = { + break_response_code: 200, + } + + it('creates consumer with api-breaker form', function () { + cy.visit('/'); + cy.contains('Consumer').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + // basic information + cy.get(this.domSelector.username).type(this.data.consumerName); + cy.get(this.domSelector.description).type(this.data.description); + cy.contains('Next').click(); + + // config auth plugin + cy.contains(this.domSelector.pluginCard, 'key-auth').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.get(this.domSelector.disabledSwitcher).click(); + // edit codemirror + cy.get(this.domSelector.codeMirror) + .first() + .then((editor) => { + editor[0].CodeMirror.setValue( + JSON.stringify({ + key: 'test', + }), + ); + cy.contains('button', 'Submit').click(); + }); + + cy.contains(this.domSelector.pluginCard, 'api-breaker').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + + cy.focused(this.domSelector.drawer).should('exist'); + + // config api-breaker form + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.notification).should('contain', 'Invalid plugin data'); + + cy.get(selector.break_response_code).type(data.break_response_code); + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.get(this.domSelector.notification).should('contain', this.data.createConsumerSuccess); + }); + + it('delete the consumer', function () { + cy.visit('/consumer/list'); + cy.contains(this.data.consumerName).should('be.visible').siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(this.domSelector.notification).should('contain', this.data.deleteConsumerSuccess); + }); +}); diff --git a/web/cypress/integration/route/create-route-with-api-breaker-form.spec.js b/web/cypress/integration/route/create-route-with-api-breaker-form.spec.js new file mode 100644 index 0000000000..8437d9d32b --- /dev/null +++ b/web/cypress/integration/route/create-route-with-api-breaker-form.spec.js @@ -0,0 +1,105 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and delete route with api-breaker form', () => { + const selector = { + break_response_code: '#break_response_code', + alert: '.ant-form-item-explain-error [role=alert]' + } + + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + it('should create route with api-breaker form', function () { + cy.visit('/'); + cy.contains('Route').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + cy.contains('Next').click().click(); + cy.get(this.domSelector.name).type('routeName'); + cy.get(this.domSelector.description).type('desc'); + cy.contains('Next').click(); + + cy.get(this.domSelector.nodes_0_host).type('127.0.0.1'); + cy.contains('Next').click(); + + // config api-breaker plugin + cy.contains('api-breaker').parents(this.domSelector.pluginCardBordered).within(() => { + cy.get('button').click({ + force: true + }); + }); + + cy.get(this.domSelector.drawer).should('be.visible').within(() => { + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.checkedSwitcher).should('exist'); + }); + + // config api-breaker form without break_response_code + cy.get(selector.break_response_code).click(); + cy.get(selector.alert).contains('Please Enter break_response_code'); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.notification).should('contain', 'Invalid plugin data'); + cy.get(this.domSelector.notificationCloseIcon).click(); + + // config api-breaker form with break_response_code + cy.get(selector.break_response_code).type('200'); + cy.get(selector.alert).should('not.exist'); + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.contains(this.data.submitSuccess); + + // back to route list page + cy.contains('Goto List').click(); + cy.url().should('contains', 'routes/list'); + }); + + it('should delete the route', function () { + cy.visit('/routes/list'); + const { + domSelector, + data + } = this; + + cy.get(domSelector.name).clear().type('routeName'); + cy.contains('Search').click(); + cy.contains('routeName').siblings().contains('More').click(); + cy.contains('Delete').click(); + cy.get(domSelector.deleteAlert).should('be.visible').within(() => { + cy.contains('OK').click(); + }); + cy.get(domSelector.notification).should('contain', data.deleteRouteSuccess); + cy.get(domSelector.notificationCloseIcon).click(); + }); +}); diff --git a/web/src/components/Plugin/UI/api-breaker.tsx b/web/src/components/Plugin/UI/api-breaker.tsx new file mode 100644 index 0000000000..2ccf2dde5b --- /dev/null +++ b/web/src/components/Plugin/UI/api-breaker.tsx @@ -0,0 +1,186 @@ +/* + * 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 from 'react'; +import type { FormInstance } from 'antd/es/form'; +import { Button, Form, InputNumber } from 'antd'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { useIntl } from 'umi'; + +type Props = { + form: FormInstance; +}; + +const FORM_ITEM_LAYOUT = { + labelCol: { + span: 7, + }, + wrapperCol: { + span: 7 + }, +}; + +const FORM_ITEM_WITHOUT_LABEL = { + wrapperCol: { + sm: { span: 14, offset: 7 }, + }, +}; + +const ApiBreaker: React.FC = ({ form }) => { + const { formatMessage } = useIntl() + + return ( +
+ + + + + + + + + + {(fields, { add, remove }) => { + return ( +
+ {fields.map((field, index) => ( + + + + + {fields.length > 1 ? ( + { + remove(field.name); + }} + /> + ) : null} + + ))} + { + + + + } +
+ ); + }} +
+ + + + + + + {(fields, { add, remove }) => { + return ( +
+ {fields.map((field, index) => ( + + + + + {fields.length > 1 ? ( + { + remove(field.name); + }} + /> + ) : null} + + ))} + { + + + + } +
+ ); + }} +
+ + + + +
+ ); +} + +export default ApiBreaker; diff --git a/web/src/components/Plugin/UI/plugin.tsx b/web/src/components/Plugin/UI/plugin.tsx index c045750121..86b38646a1 100644 --- a/web/src/components/Plugin/UI/plugin.tsx +++ b/web/src/components/Plugin/UI/plugin.tsx @@ -19,6 +19,7 @@ import type { FormInstance } from 'antd/es/form'; import { Empty } from 'antd'; import { useIntl } from 'umi'; +import ApiBreaker from './api-breaker'; import BasicAuth from './basic-auth'; import ProxyMirror from './proxy-mirror'; import LimitConn from './limit-conn'; @@ -29,7 +30,7 @@ type Props = { renderForm: boolean } -export const PLUGIN_UI_LIST = ['basic-auth', 'limit-conn', 'proxy-mirror']; +export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'limit-conn', 'proxy-mirror']; export const PluginForm: React.FC = ({ name, renderForm, form }) => { @@ -38,6 +39,8 @@ export const PluginForm: React.FC = ({ name, renderForm, form }) => { if (!renderForm) { return }; switch (name) { + case 'api-breaker': + return case 'basic-auth': return case 'proxy-mirror': diff --git a/web/src/components/Plugin/locales/en-US.ts b/web/src/components/Plugin/locales/en-US.ts index f235b95cd9..0188dcc90e 100644 --- a/web/src/components/Plugin/locales/en-US.ts +++ b/web/src/components/Plugin/locales/en-US.ts @@ -22,6 +22,14 @@ export default { 'component.plugin.pluginTemplate.tip1': '1. When a route already have plugins field configured, the plugins in the plugin template will be merged into it.', 'component.plugin.pluginTemplate.tip2': '2. The same plugin in the plugin template will override one in the plugins', + // api-breaker + 'component.pluginForm.api-breaker.break_response_code.tooltip': 'Return error code when unhealthy.', + 'component.pluginForm.api-breaker.max_breaker_sec.tooltip': 'Maximum breaker time(seconds).', + 'component.pluginForm.api-breaker.unhealthy.http_statuses.tooltip': 'Status codes when unhealthy.', + 'component.pluginForm.api-breaker.unhealthy.failures.tooltip': 'Number of consecutive error requests that triggered an unhealthy state.', + 'component.pluginForm.api-breaker.healthy.http_statuses.tooltip': 'Status codes when healthy.', + 'component.pluginForm.api-breaker.healthy.successes.tooltip': 'Number of consecutive normal requests that trigger health status.', + // proxy-mirror 'component.pluginForm.proxy-mirror.host.tooltip': 'Specify a mirror service address, e.g. http://127.0.0.1:9797 (address needs to contain schema: http or https, not URI part)', 'component.pluginForm.proxy-mirror.host.extra': 'e.g. http://127.0.0.1:9797 (address needs to contain schema: http or https, not URI part)', diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts index 2f2d5ba14b..9a00009504 100644 --- a/web/src/components/Plugin/locales/zh-CN.ts +++ b/web/src/components/Plugin/locales/zh-CN.ts @@ -22,6 +22,14 @@ export default { 'component.plugin.pluginTemplate.tip1': '1. 若路由已配置插件,则插件模板数据将与已配置的插件数据合并。', 'component.plugin.pluginTemplate.tip2': '2. 插件模板相同的插件会覆盖掉原有的插件。', + // api-breaker + 'component.pluginForm.api-breaker.break_response_code.tooltip': '不健康返回错误码。', + 'component.pluginForm.api-breaker.max_breaker_sec.tooltip': '最大熔断持续时间。', + 'component.pluginForm.api-breaker.unhealthy.http_statuses.tooltip': '不健康时候的状态码。', + 'component.pluginForm.api-breaker.unhealthy.failures.tooltip': '触发不健康状态的连续错误请求次数。', + 'component.pluginForm.api-breaker.healthy.http_statuses.tooltip': '健康时候的状态码。', + 'component.pluginForm.api-breaker.healthy.successes.tooltip': '触发健康状态的连续正常请求次数。', + // proxy-mirror 'component.pluginForm.proxy-mirror.host.tooltip': '指定镜像服务地址,例如:http://127.0.0.1:9797(地址中需要包含 schema :http或https,不能包含 URI 部分)', 'component.pluginForm.proxy-mirror.host.extra': '例如:http://127.0.0.1:9797(地址中需要包含 schema:http或https,不能包含 URI 部分)', From f95324f3965e9ddf703383ac5915070a85e2f091 Mon Sep 17 00:00:00 2001 From: guoqqqi <72343596+guoqqqi@users.noreply.github.com> Date: Thu, 15 Apr 2021 05:54:39 +0800 Subject: [PATCH 5/7] feat: added limit-req plugin form (#1732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: litesun <7sunmiao@gmail.com> Co-authored-by: 琚致远 --- ...reate-upstream-with-limit-req-form.spec.js | 103 +++++++++++++++++ .../create-route-with-limit-req-form.spec.js | 106 ++++++++++++++++++ .../route/create-route-with-upstream.spec.js | 4 +- web/cypress/support/commands.js | 1 - web/src/components/Plugin/UI/limit-req.tsx | 95 ++++++++++++++++ web/src/components/Plugin/UI/plugin.tsx | 7 +- web/src/components/Plugin/locales/en-US.ts | 5 + web/src/components/Plugin/locales/zh-CN.ts | 5 + 8 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 web/cypress/integration/consumer/create-upstream-with-limit-req-form.spec.js create mode 100644 web/cypress/integration/route/create-route-with-limit-req-form.spec.js create mode 100644 web/src/components/Plugin/UI/limit-req.tsx diff --git a/web/cypress/integration/consumer/create-upstream-with-limit-req-form.spec.js b/web/cypress/integration/consumer/create-upstream-with-limit-req-form.spec.js new file mode 100644 index 0000000000..51b806fe9b --- /dev/null +++ b/web/cypress/integration/consumer/create-upstream-with-limit-req-form.spec.js @@ -0,0 +1,103 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and Delete Consumer', () => { + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + const selector = { + rate: "#rate", + burst: "#burst", + key: '#key', + remote_addr: "[title=remote_addr]" + } + + const data = { + time: 2, + } + + it('creates consumer with limit-req form', function () { + cy.visit('/'); + cy.contains('Consumer').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + // basic information + cy.get(this.domSelector.username).type(this.data.consumerName); + cy.get(this.domSelector.description).type(this.data.description); + cy.contains('Next').click(); + + // config auth plugin + cy.contains(this.domSelector.pluginCard, 'key-auth').within(() => { + cy.contains('Enable').click({ force: true }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.get(this.domSelector.disabledSwitcher).click().should('have.class', 'ant-switch-checked'); + // edit codemirror + cy.get(this.domSelector.codeMirror) + .first() + .then((editor) => { + editor[0].CodeMirror.setValue( + JSON.stringify({ + key: 'test', + }), + ); + cy.contains('button', 'Submit').click(); + }); + + cy.contains(this.domSelector.pluginCard, 'limit-req').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + + cy.get(this.domSelector.drawer).should('be.visible'); + // config proxy-mirror form + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.notification).should('contain', 'Invalid plugin data'); + + cy.get(selector.rate).type(data.time); + cy.get(selector.burst).type(data.time); + cy.get(selector.key).click(); + cy.get(selector.remote_addr).click(); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.get(this.domSelector.notification).should('contain', this.data.createConsumerSuccess); + }); + + it('delete the consumer', function () { + cy.visit('/consumer/list'); + cy.contains(this.data.consumerName).should('be.visible').siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(this.domSelector.notification).should('contain', this.data.deleteConsumerSuccess); + }); +}); diff --git a/web/cypress/integration/route/create-route-with-limit-req-form.spec.js b/web/cypress/integration/route/create-route-with-limit-req-form.spec.js new file mode 100644 index 0000000000..8609d0462d --- /dev/null +++ b/web/cypress/integration/route/create-route-with-limit-req-form.spec.js @@ -0,0 +1,106 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and delete route with limit-req form', () => { + const selector = { + rate: "#rate", + burst: "#burst", + key: '#key', + remote_addr: "[title=remote_addr]" + } + + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + it('should create route with limit-req form', function () { + cy.visit('/'); + cy.contains('Route').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + cy.contains('Next').click().click(); + cy.get(this.domSelector.name).type('routeName'); + cy.get(this.domSelector.description).type('desc'); + cy.contains('Next').click(); + + cy.get(this.domSelector.nodes_0_host).type('127.0.0.1'); + cy.contains('Next').click(); + + // config limit-req plugin + cy.contains('limit-req').parents(this.domSelector.pluginCardBordered).within(() => { + cy.get('button').click({ + force: true + }); + }); + + cy.get(this.domSelector.drawer).should('be.visible').within(() => { + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.checkedSwitcher).should('exist'); + }); + + // config limit-req form without wrong data + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.notification).should('contain', 'Invalid plugin data'); + cy.get(this.domSelector.notificationCloseIcon).click(); + + // config limit-req form + cy.get(selector.rate).type(1); + cy.get(selector.burst).type(0); + cy.get(selector.key).click(); + cy.get(selector.remote_addr).click(); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.contains(this.data.submitSuccess); + + // back to route list page + cy.contains('Goto List').click(); + cy.url().should('contains', 'routes/list'); + }); + + it('should delete the route', function () { + cy.visit('/routes/list'); + const { + domSelector, + data + } = this; + + cy.get(domSelector.name).clear().type('routeName'); + cy.contains('Search').click(); + cy.contains('routeName').siblings().contains('More').click(); + cy.contains('Delete').click(); + cy.get(domSelector.deleteAlert).should('be.visible').within(() => { + cy.contains('OK').click(); + }); + cy.get(domSelector.notification).should('contain', data.deleteRouteSuccess); + cy.get(domSelector.notificationCloseIcon).click(); + }); +}); diff --git a/web/cypress/integration/route/create-route-with-upstream.spec.js b/web/cypress/integration/route/create-route-with-upstream.spec.js index 2c4beb7462..a6cf351665 100644 --- a/web/cypress/integration/route/create-route-with-upstream.spec.js +++ b/web/cypress/integration/route/create-route-with-upstream.spec.js @@ -38,7 +38,9 @@ context('Create Route with Upstream', () => { it('should create route with upstream just created', function () { cy.visit('/'); - cy.contains('Route').click(); + cy.get('[role=menu]').should('be.visible').within(() => { + cy.contains('Route').click(); + }); cy.contains('Create').click(); cy.contains('Next').click().click(); diff --git a/web/cypress/support/commands.js b/web/cypress/support/commands.js index a4b08e4145..590243cb5f 100644 --- a/web/cypress/support/commands.js +++ b/web/cypress/support/commands.js @@ -98,7 +98,6 @@ Cypress.Commands.add('configurePlugins', (cases) => { if (codemirror) { codemirror.setValue(JSON.stringify(data)); } - cy.get(domSelector.drawer).should('exist'); cy.get(domSelector.drawer, { timeout }).within(() => { cy.contains('Submit').click({ diff --git a/web/src/components/Plugin/UI/limit-req.tsx b/web/src/components/Plugin/UI/limit-req.tsx new file mode 100644 index 0000000000..58b3cb2a0f --- /dev/null +++ b/web/src/components/Plugin/UI/limit-req.tsx @@ -0,0 +1,95 @@ +/* + * 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 from 'react'; +import type { FormInstance } from 'antd/es/form'; +import { Form, InputNumber, Select } from 'antd'; +import { useIntl } from 'umi'; + +type Props = { + form: FormInstance; + ref?: any; +}; + +export const FORM_ITEM_LAYOUT = { + labelCol: { + span: 4, + }, + wrapperCol: { + span: 8 + }, +}; + +const LimitReq: React.FC = ({ form }) => { + const { formatMessage } = useIntl(); + return ( +
+ + + + + + + + + + + + +
+ ); +} + +export default LimitReq; diff --git a/web/src/components/Plugin/UI/plugin.tsx b/web/src/components/Plugin/UI/plugin.tsx index 86b38646a1..e4e4cda5ec 100644 --- a/web/src/components/Plugin/UI/plugin.tsx +++ b/web/src/components/Plugin/UI/plugin.tsx @@ -19,8 +19,9 @@ import type { FormInstance } from 'antd/es/form'; import { Empty } from 'antd'; import { useIntl } from 'umi'; +import BasicAuth from './basic-auth' +import LimitReq from './limit-req'; import ApiBreaker from './api-breaker'; -import BasicAuth from './basic-auth'; import ProxyMirror from './proxy-mirror'; import LimitConn from './limit-conn'; @@ -30,7 +31,7 @@ type Props = { renderForm: boolean } -export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'limit-conn', 'proxy-mirror']; +export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'limit-req', 'limit-conn', 'proxy-mirror']; export const PluginForm: React.FC = ({ name, renderForm, form }) => { @@ -43,6 +44,8 @@ export const PluginForm: React.FC = ({ name, renderForm, form }) => { return case 'basic-auth': return + case 'limit-req': + return case 'proxy-mirror': return case 'limit-conn': diff --git a/web/src/components/Plugin/locales/en-US.ts b/web/src/components/Plugin/locales/en-US.ts index 0188dcc90e..f65bfa08d2 100644 --- a/web/src/components/Plugin/locales/en-US.ts +++ b/web/src/components/Plugin/locales/en-US.ts @@ -41,6 +41,11 @@ export default { 'component.pluginForm.limit-conn.default_conn_delay.tooltip': 'the latency seconds of request when concurrent requests exceeding conn but below (conn + burst).', 'component.pluginForm.limit-conn.key.tooltip': 'to limit the concurrency level.For example, one can use the host name (or server zone) as the key so that we limit concurrency per host name. Otherwise, we can also use the client address as the key so that we can avoid a single client from flooding our service with too many parallel connections or requests.Now accept those as key: "remote_addr"(client\'s IP), "server_addr"(server\'s IP), "X-Forwarded-For/X-Real-IP" in request header, "consumer_name"(consumer\'s username).', 'component.pluginForm.limit-conn.rejected_code.tooltip': 'returned when the request exceeds conn + burst will be rejected.', + // limit-req + 'component.pluginForm.limit-req.rate.tooltip': 'The specified request rate (number per second) threshold. Requests exceeding this rate (and below burst) will get delayed to conform to the rate.', + 'component.pluginForm.limit-req.burst.tooltip': 'The number of excessive requests per second allowed to be delayed. Requests exceeding this hard limit will get rejected immediately.', + 'component.pluginForm.limit-req.key.tooltip': 'The user specified key to limit the rate, now accept those as key: "remote_addr"(client\'s IP), "server_addr"(server\'s IP), "X-Forwarded-For/X-Real-IP" in request header, "consumer_name"(consumer\'s username).', + 'component.pluginForm.limit-req.rejected_code.tooltip': 'The HTTP status code returned when the request exceeds the threshold is rejected.', 'component.plugin.form': 'Form', 'component.plugin.format-codes.disable': 'Format JSON or YAML data', diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts index 9a00009504..a0decff384 100644 --- a/web/src/components/Plugin/locales/zh-CN.ts +++ b/web/src/components/Plugin/locales/zh-CN.ts @@ -41,6 +41,11 @@ export default { 'component.pluginForm.limit-conn.default_conn_delay.tooltip': '默认的典型连接(或请求)的处理延迟时间。', 'component.pluginForm.limit-conn.key.tooltip': '用户指定的限制并发级别的关键字,可以是客户端 IP 或服务端 IP。例如,可以使用主机名(或服务器区域)作为关键字,以便限制每个主机名的并发性。 否则,我们也可以使用客户端地址作为关键字,这样我们就可以避免单个客户端用太多的并行连接或请求淹没我们的服务。当前接受的 key 有:"remote_addr"(客户端 IP 地址), "server_addr"(服务端 IP 地址), 请求头中的"X-Forwarded-For" 或 "X-Real-IP", "consumer_name"(consumer 的 username)。', 'component.pluginForm.limit-conn.rejected_code.tooltip': '当请求超过 conn + burst 这个阈值时,返回的 HTTP 状态码。', + // limit-req + 'component.pluginForm.limit-req.rate.tooltip': '指定的请求速率(以秒为单位),请求速率超过 rate 但没有超过 (rate + brust)的请求会被加上延时。', + 'component.pluginForm.limit-req.burst.tooltip': '请求速率超过(rate + brust)的请求会被直接拒绝。', + 'component.pluginForm.limit-req.key.tooltip': '用来做请求计数的依据,当前接受的 key 有:"remote_addr"(客户端IP地址), "server_addr"(服务端 IP 地址), 请求头中的"X-Forwarded-For" 或 "X-Real-IP","consumer_name"(consumer 的 username).', + 'component.pluginForm.limit-req.rejected_code.tooltip': '当请求超过阈值被拒绝时,返回的 HTTP 状态码。', 'component.plugin.form': '表单', 'component.plugin.format-codes.disable': '用于格式化 JSON 或 YAML 内容', From fb758091ddcbb445f6b529b3f26defbeddddab91 Mon Sep 17 00:00:00 2001 From: guoqqqi <72343596+guoqqqi@users.noreply.github.com> Date: Thu, 15 Apr 2021 09:00:56 +0800 Subject: [PATCH 6/7] feat: added cors plugin form (#1733) --- .../create-upstream-with-cors-form.spec.js | 101 ++++++++++ .../route/create-route-with-cors-form.spec.js | 93 +++++++++ web/src/components/Plugin/PluginDetail.tsx | 30 ++- web/src/components/Plugin/UI/cors.tsx | 179 ++++++++++++++++++ web/src/components/Plugin/UI/plugin.tsx | 5 +- web/src/components/Plugin/locales/en-US.ts | 9 + web/src/components/Plugin/locales/zh-CN.ts | 9 + 7 files changed, 419 insertions(+), 7 deletions(-) create mode 100644 web/cypress/integration/consumer/create-upstream-with-cors-form.spec.js create mode 100644 web/cypress/integration/route/create-route-with-cors-form.spec.js create mode 100644 web/src/components/Plugin/UI/cors.tsx diff --git a/web/cypress/integration/consumer/create-upstream-with-cors-form.spec.js b/web/cypress/integration/consumer/create-upstream-with-cors-form.spec.js new file mode 100644 index 0000000000..3bf211114e --- /dev/null +++ b/web/cypress/integration/consumer/create-upstream-with-cors-form.spec.js @@ -0,0 +1,101 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and Delete Consumer', () => { + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + const selector = { + max_age: "#max_age" + } + + const data = { + time: 2, + } + + it('creates consumer with cors form', function () { + cy.visit('/'); + cy.contains('Consumer').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + // basic information + cy.get(this.domSelector.username).type(this.data.consumerName); + cy.get(this.domSelector.description).type(this.data.description); + cy.contains('Next').click(); + + // config auth plugin + cy.contains(this.domSelector.pluginCard, 'key-auth').within(() => { + cy.contains('Enable').click({ force: true }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.get(this.domSelector.disabledSwitcher).click().should('have.class', 'ant-switch-checked'); + // edit codemirror + cy.get(this.domSelector.codeMirror) + .first() + .then((editor) => { + editor[0].CodeMirror.setValue( + JSON.stringify({ + key: 'test', + }), + ); + cy.contains('button', 'Submit').click(); + }); + + cy.contains(this.domSelector.pluginCard, 'cors').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + + cy.get(this.domSelector.drawer).should('be.visible'); + + cy.get(selector.max_age).clear(); + // config proxy-mirror form + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.notification).should('contain', 'Invalid plugin data'); + cy.get(this.domSelector.notificationCloseIcon).click().should('not.exist'); + + cy.get(selector.max_age).type(data.time); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.get(this.domSelector.notification).should('contain', this.data.createConsumerSuccess); + cy.get(this.domSelector.notificationCloseIcon).click().should('not.exist'); + }); + + it('delete the consumer', function () { + cy.visit('/consumer/list'); + cy.contains(this.data.consumerName).should('be.visible').siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(this.domSelector.notification).should('contain', this.data.deleteConsumerSuccess); + }); +}); diff --git a/web/cypress/integration/route/create-route-with-cors-form.spec.js b/web/cypress/integration/route/create-route-with-cors-form.spec.js new file mode 100644 index 0000000000..915effba32 --- /dev/null +++ b/web/cypress/integration/route/create-route-with-cors-form.spec.js @@ -0,0 +1,93 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and delete route with cors form', () => { + const selector = { + allow_credential: "#allow_credential", + allow_origins_by_regex: "#allow_origins_by_regex_0" + } + + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + it('should create route with cors form', function () { + cy.visit('/'); + cy.contains('Route').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + cy.contains('Next').click().click(); + cy.get(this.domSelector.name).type('routeName'); + cy.get(this.domSelector.description).type('desc'); + cy.contains('Next').click(); + + cy.get(this.domSelector.nodes_0_host).type('127.0.0.1'); + cy.contains('Next').click(); + + // config cors plugin + cy.contains('cors').parents(this.domSelector.pluginCardBordered).within(() => { + cy.get('button').click({ + force: true + }); + }); + + cy.get(this.domSelector.drawer).should('be.visible').within(() => { + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.checkedSwitcher).should('exist'); + }); + + // config cors form + cy.get(selector.allow_credential).click(); + cy.get(selector.allow_origins_by_regex).type('.*.test.com'); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.contains(this.data.submitSuccess); + + // back to route list page + cy.contains('Goto List').click(); + cy.url().should('contains', 'routes/list'); + }); + + it('should delete the route', function () { + cy.visit('/routes/list'); + const { + domSelector, + data + } = this; + + cy.get(domSelector.name).clear().type('routeName'); + cy.contains('Search').click(); + cy.contains('routeName').siblings().contains('More').click(); + cy.contains('Delete').click(); + cy.get(domSelector.deleteAlert).should('be.visible').within(() => { + cy.contains('OK').click(); + }); + cy.get(domSelector.notification).should('contain', data.deleteRouteSuccess); + cy.get(domSelector.notificationCloseIcon).click(); + }); +}); diff --git a/web/src/components/Plugin/PluginDetail.tsx b/web/src/components/Plugin/PluginDetail.tsx index a807b2d719..385f9670eb 100644 --- a/web/src/components/Plugin/PluginDetail.tsx +++ b/web/src/components/Plugin/PluginDetail.tsx @@ -116,6 +116,24 @@ const PluginDetail: React.FC = ({ modeOptions.push({ label: formatMessage({ id: 'component.plugin.form' }), value: codeMirrorModeList.UIForm }); } + const getUIFormData = () => { + if (name === 'cors') { + const formData = UIForm.getFieldsValue(); + const newMethods = formData.allow_methods.join(","); + return { ...formData, allow_methods: newMethods }; + } + return UIForm.getFieldsValue(); + }; + + 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: initialData[name] && !initialData[name].disable, @@ -123,7 +141,7 @@ const PluginDetail: React.FC = ({ }); if (PLUGIN_UI_LIST.includes(name)) { setCodeMirrorMode(codeMirrorModeList.UIForm); - UIForm.setFieldsValue(initialData[name]); + setUIFormData(initialData[name]); }; }, []); @@ -189,7 +207,7 @@ const PluginDetail: React.FC = ({ ); } else { ref.current.editor.setValue( - js_beautify(JSON.stringify(UIForm.getFieldsValue()), { + js_beautify(JSON.stringify(getUIFormData()), { indent_size: 2, }), ); @@ -197,7 +215,7 @@ const PluginDetail: React.FC = ({ break; } case codeMirrorModeList.YAML: { - const { data: jsonData, error } = json2yaml(codeMirrorMode === codeMirrorModeList.JSON ? ref.current.editor.getValue() : JSON.stringify(UIForm.getFieldsValue())); + const { data: jsonData, error } = json2yaml(codeMirrorMode === codeMirrorModeList.JSON ? ref.current.editor.getValue() : JSON.stringify(getUIFormData())); if (error) { notification.error({ @@ -211,7 +229,7 @@ const PluginDetail: React.FC = ({ case codeMirrorModeList.UIForm: { if (codeMirrorMode === codeMirrorModeList.JSON) { - UIForm.setFieldsValue(JSON.parse(ref.current.editor.getValue())); + setUIFormData(JSON.parse(ref.current.editor.getValue())); } else { const { data: yamlData, error } = yaml2json(ref.current.editor.getValue(), true); if (error) { @@ -220,7 +238,7 @@ const PluginDetail: React.FC = ({ }); return; } - UIForm.setFieldsValue(JSON.parse(yamlData)); + setUIFormData(JSON.parse(yamlData)); } break; } @@ -292,7 +310,7 @@ const PluginDetail: React.FC = ({ } else if (codeMirrorMode === codeMirrorModeList.YAML) { editorData = yaml2json(ref.current?.editor.getValue(), false).data; } else { - editorData = UIForm.getFieldsValue(); + editorData = getUIFormData(); } validateData(name, editorData).then((value) => { diff --git a/web/src/components/Plugin/UI/cors.tsx b/web/src/components/Plugin/UI/cors.tsx new file mode 100644 index 0000000000..d075fc43a1 --- /dev/null +++ b/web/src/components/Plugin/UI/cors.tsx @@ -0,0 +1,179 @@ +/* + * 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 from 'react'; +import type { FormInstance } from 'antd/es/form'; +import { Button, Col, Form, Input, InputNumber, Row, Select, Switch } from 'antd'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { useIntl } from '@/.umi/plugin-locale/localeExports'; + +type Props = { + form: FormInstance; + ref?: any; +}; + +const FORM_ITEM_LAYOUT = { + labelCol: { + span: 7, + }, + wrapperCol: { + span: 8 + }, +}; + +export const FORM_ITEM_WITHOUT_LABEL = { + wrapperCol: { + sm: { span: 8, offset: 7 }, + }, +}; + +const Cors: React.FC = ({ form }) => { + const { formatMessage } = useIntl(); + + const HTTPMethods: React.FC = () => ( + + + + + + + + + + ); + + return ( +
+ + + + + + + + + + + + + + + + + + + + {(fields, { add, remove }) => { + return ( +
+ {fields.map((field, index) => ( + + + + + {fields.length > 1 ? ( + { + remove(field.name); + }} + /> + ) : null} + + ))} + { + + + + } +
+ ); + }} +
+ + ); +} + +export default Cors; diff --git a/web/src/components/Plugin/UI/plugin.tsx b/web/src/components/Plugin/UI/plugin.tsx index e4e4cda5ec..5c82aa943b 100644 --- a/web/src/components/Plugin/UI/plugin.tsx +++ b/web/src/components/Plugin/UI/plugin.tsx @@ -24,6 +24,7 @@ import LimitReq from './limit-req'; import ApiBreaker from './api-breaker'; import ProxyMirror from './proxy-mirror'; import LimitConn from './limit-conn'; +import Cors from './cors'; type Props = { name: string, @@ -31,7 +32,7 @@ type Props = { renderForm: boolean } -export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'limit-req', 'limit-conn', 'proxy-mirror']; +export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'limit-conn', 'cors', 'proxy-mirror', 'limit-req']; export const PluginForm: React.FC = ({ name, renderForm, form }) => { @@ -44,6 +45,8 @@ export const PluginForm: React.FC = ({ name, renderForm, form }) => { return case 'basic-auth': return + case 'cors': + return case 'limit-req': return case 'proxy-mirror': diff --git a/web/src/components/Plugin/locales/en-US.ts b/web/src/components/Plugin/locales/en-US.ts index f65bfa08d2..3e896cc75e 100644 --- a/web/src/components/Plugin/locales/en-US.ts +++ b/web/src/components/Plugin/locales/en-US.ts @@ -21,6 +21,15 @@ export default { 'component.step.select.pluginTemplate.select.option': 'Custom', 'component.plugin.pluginTemplate.tip1': '1. When a route already have plugins field configured, the plugins in the plugin template will be merged into it.', 'component.plugin.pluginTemplate.tip2': '2. The same plugin in the plugin template will override one in the plugins', + // cors + 'component.pluginForm.cors.allow_origins.tooltip': 'Which Origins is allowed to enable CORS, format as:scheme://host:port, for example: https://somehost.com:8081. Multiple origin use , to split. When allow_credential is false, you can use * to indicate allow any origin. you also can allow all any origins forcefully using ** even already enable allow_credential, but it will bring some security risks.', + 'component.pluginForm.cors.allow_origins.extra': 'For example: https://somehost.com:8081', + 'component.pluginForm.cors.allow_methods.tooltip': 'Which Method is allowed to enable CORS, such as: GET, POST etc. Multiple method use , to split. When allow_credential is false, you can use * to indicate allow all any method. You also can allow any method forcefully using ** even already enable allow_credential, but it will bring some security risks.', + 'component.pluginForm.cors.allow_headers.tooltip': 'Which headers are allowed to set in request when access cross-origin resource. Multiple value use , to split. When allow_credential is false, you can use * to indicate allow all request headers. You also can allow any header forcefully using ** even already enable allow_credential, but it will bring some security risks.', + 'component.pluginForm.cors.expose_headers.tooltip': ' Which headers are allowed to set in response when access cross-origin resource. Multiple value use , to split.', + 'component.pluginForm.cors.max_age.tooltip': 'Maximum number of seconds the results can be cached.. Within this time range, the browser will reuse the last check result. -1 means no cache. Please note that the maximum value is depended on browser, please refer to MDN for details.', + 'component.pluginForm.cors.allow_credential.tooltip': 'If you set this option to true, you can not use \'*\' for other options.', + 'component.pluginForm.cors.allow_origins_by_regex.tooltip': 'Use regex expressions to match which origin is allowed to enable CORS, for example, [".*.test.com"] can use to match all subdomain of test.com.', // api-breaker 'component.pluginForm.api-breaker.break_response_code.tooltip': 'Return error code when unhealthy.', diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts index a0decff384..1dca43a3f1 100644 --- a/web/src/components/Plugin/locales/zh-CN.ts +++ b/web/src/components/Plugin/locales/zh-CN.ts @@ -21,6 +21,15 @@ export default { 'component.step.select.pluginTemplate.select.option': '手动配置', 'component.plugin.pluginTemplate.tip1': '1. 若路由已配置插件,则插件模板数据将与已配置的插件数据合并。', 'component.plugin.pluginTemplate.tip2': '2. 插件模板相同的插件会覆盖掉原有的插件。', + // cors + 'component.pluginForm.cors.allow_origins.tooltip': '允许跨域访问的 Origin,格式如:scheme://host:port,比如: https://somehost.com:8081 。多个值使用 , 分割,allow_credential 为 false 时可以使用 * 来表示所有 Origin 均允许通过。你也可以在启用了 allow_credential 后使用 ** 强制允许所有 Origin 都通过,但请注意这样存在安全隐患。', + 'component.pluginForm.cors.allow_origins.extra': '例如: https://somehost.com:8081', + 'component.pluginForm.cors.allow_methods.tooltip': '允许跨域访问的 Method,比如: GET,POST等。多个值使用 , 分割,allow_credential 为 false 时可以使用 * 来表示所有 Origin 均允许通过。你也可以在启用了 allow_credential 后使用 ** 强制允许所有 Method 都通过,但请注意这样存在安全隐患。', + 'component.pluginForm.cors.allow_headers.tooltip': '允许跨域访问时请求方携带哪些非 CORS规范 以外的 Header, 多个值使用 , 分割,allow_credential 为 false 时可以使用 * 来表示所 有 Header 均允许通过。你也可以在启用了 allow_credential 后使用 ** 强制允许所有 Method 都通过,但请注意这样存在安全隐患。', + 'component.pluginForm.cors.expose_headers.tooltip': '允许跨域访问时响应方携带哪些非 CORS规范 以外的 Header, 多个值使用 , 分割。', + 'component.pluginForm.cors.max_age.tooltip': '浏览器缓存 CORS 结果的最大时间,单位为秒,在这个时间范围内浏览器会复用上一次的检查结果,-1 表示不缓存。', + 'component.pluginForm.cors.allow_credential.tooltip': '是否允许跨域访问的请求方携带凭据(如 Cookie 等)。根据 CORS 规范,如果设置该选项为 true,那么将不能在其他选项中使用 * 。', + 'component.pluginForm.cors.allow_origins_by_regex.tooltip': '使用正则表达式数组来匹配允许跨域访问的 Origin, 如[".*.test.com"] 可以匹配任何test.com的子域名 * 。', // api-breaker 'component.pluginForm.api-breaker.break_response_code.tooltip': '不健康返回错误码。', From a97c8138d588745b40196f97c9f48d3dab819dd9 Mon Sep 17 00:00:00 2001 From: litesun Date: Thu, 15 Apr 2021 09:44:19 +0800 Subject: [PATCH 7/7] feat: add referer-restriction plugin form (#1727) --- ...eate-with-referer-restriction-form.spec.js | 96 +++++++++++++ ...oute-with-referer-restriction-form.spec.js | 105 +++++++++++++++ web/src/components/Plugin/UI/plugin.tsx | 5 +- .../Plugin/UI/referer-restriction.tsx | 126 ++++++++++++++++++ web/src/components/Plugin/locales/en-US.ts | 5 + web/src/components/Plugin/locales/zh-CN.ts | 4 + 6 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 web/cypress/integration/consumer/create-with-referer-restriction-form.spec.js create mode 100644 web/cypress/integration/route/create-route-with-referer-restriction-form.spec.js create mode 100644 web/src/components/Plugin/UI/referer-restriction.tsx diff --git a/web/cypress/integration/consumer/create-with-referer-restriction-form.spec.js b/web/cypress/integration/consumer/create-with-referer-restriction-form.spec.js new file mode 100644 index 0000000000..7a43c2e554 --- /dev/null +++ b/web/cypress/integration/consumer/create-with-referer-restriction-form.spec.js @@ -0,0 +1,96 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and delete Consumer with referer-restriction form ', () => { + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + const selector = { + whitelist: "#whitelist_0", + bypass_missing: "#bypass_missing", + } + + const data = { + whitelist: 'yy.com', + } + + it('creates consumer with referer-restriction form', function () { + cy.visit('/'); + cy.contains('Consumer').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + // basic information + cy.get(this.domSelector.username).type(this.data.consumerName); + cy.get(this.domSelector.description).type(this.data.description); + cy.contains('Next').click(); + + // config auth plugin + cy.contains(this.domSelector.pluginCard, 'key-auth').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + cy.focused(this.domSelector.drawer).should('exist'); + cy.get(this.domSelector.disabledSwitcher).click(); + // edit codemirror + cy.get(this.domSelector.codeMirror) + .first() + .then((editor) => { + editor[0].CodeMirror.setValue( + JSON.stringify({ + key: 'test', + }), + ); + cy.contains('button', 'Submit').click(); + }); + + cy.contains(this.domSelector.pluginCard, 'referer-restriction').within(() => { + cy.contains('Enable').click({ + force: true, + }); + }); + + cy.focused(this.domSelector.drawer).should('exist'); + + // config referer-restriction form + cy.get(selector.whitelist).type(data.whitelist); + cy.get(selector.bypass_missing).click(); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.get(this.domSelector.notification).should('contain', this.data.createConsumerSuccess); + }); + + it('delete the consumer', function () { + cy.visit('/consumer/list'); + cy.contains(this.data.consumerName).should('be.visible').siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(this.domSelector.notification).should('contain', this.data.deleteConsumerSuccess); + }); +}); diff --git a/web/cypress/integration/route/create-route-with-referer-restriction-form.spec.js b/web/cypress/integration/route/create-route-with-referer-restriction-form.spec.js new file mode 100644 index 0000000000..39e13a340f --- /dev/null +++ b/web/cypress/integration/route/create-route-with-referer-restriction-form.spec.js @@ -0,0 +1,105 @@ +/* + * 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. + */ +/* eslint-disable no-undef */ + +context('Create and delete route with referer-restriction form', () => { + const selector = { + whitlist: '#whitelist_0', + alert:'.ant-form-item-explain-error [role=alert]' + } + + beforeEach(() => { + cy.login(); + + cy.fixture('selector.json').as('domSelector'); + cy.fixture('data.json').as('data'); + }); + + it('should create route with referer-restriction form', function () { + cy.visit('/'); + cy.contains('Route').click(); + cy.get(this.domSelector.empty).should('be.visible'); + cy.contains('Create').click(); + cy.contains('Next').click().click(); + cy.get(this.domSelector.name).type('routeName'); + cy.get(this.domSelector.description).type('desc'); + cy.contains('Next').click(); + + cy.get(this.domSelector.nodes_0_host).type('127.0.0.1'); + cy.contains('Next').click(); + + // config referer-restriction plugin + cy.contains('referer-restriction').parents(this.domSelector.pluginCardBordered).within(() => { + cy.get('button').click({ + force: true + }); + }); + + cy.get(this.domSelector.drawer).should('be.visible').within(() => { + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.checkedSwitcher).should('exist'); + }); + + // config referer-restriction form without whitelist + cy.get(selector.whitlist).click(); + cy.get(selector.alert).contains('Please Enter whitelist'); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.notification).should('contain', 'Invalid plugin data'); + cy.get(this.domSelector.notificationCloseIcon).click(); + + // config referer-restriction form with whitelist + cy.get(selector.whitlist).type('127.0.0.1'); + cy.get(selector.alert).should('not.exist'); + cy.get(this.domSelector.disabledSwitcher).click(); + cy.get(this.domSelector.drawer).within(() => { + cy.contains('Submit').click({ + force: true, + }); + }); + cy.get(this.domSelector.drawer).should('not.exist'); + + cy.contains('button', 'Next').click(); + cy.contains('button', 'Submit').click(); + cy.contains(this.data.submitSuccess); + + // back to route list page + cy.contains('Goto List').click(); + cy.url().should('contains', 'routes/list'); + }); + + it('should delete the route', function () { + cy.visit('/routes/list'); + const { + domSelector, + data + } = this; + + cy.get(domSelector.name).clear().type('routeName'); + cy.contains('Search').click(); + cy.contains('routeName').siblings().contains('More').click(); + cy.contains('Delete').click(); + cy.get(domSelector.deleteAlert).should('be.visible').within(() => { + cy.contains('OK').click(); + }); + cy.get(domSelector.notification).should('contain', data.deleteRouteSuccess); + cy.get(domSelector.notificationCloseIcon).click(); + }); +}); diff --git a/web/src/components/Plugin/UI/plugin.tsx b/web/src/components/Plugin/UI/plugin.tsx index 5c82aa943b..4c6a95cd03 100644 --- a/web/src/components/Plugin/UI/plugin.tsx +++ b/web/src/components/Plugin/UI/plugin.tsx @@ -24,6 +24,7 @@ import LimitReq from './limit-req'; import ApiBreaker from './api-breaker'; import ProxyMirror from './proxy-mirror'; import LimitConn from './limit-conn'; +import RefererRestriction from './referer-restriction'; import Cors from './cors'; type Props = { @@ -32,7 +33,7 @@ type Props = { renderForm: boolean } -export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'limit-conn', 'cors', 'proxy-mirror', 'limit-req']; +export const PLUGIN_UI_LIST = ['api-breaker', 'basic-auth', 'cors', 'limit-req', 'limit-conn', 'proxy-mirror', 'referer-restriction']; export const PluginForm: React.FC = ({ name, renderForm, form }) => { @@ -53,6 +54,8 @@ export const PluginForm: React.FC = ({ name, renderForm, form }) => { return case 'limit-conn': return + case 'referer-restriction': + return default: return null; } diff --git a/web/src/components/Plugin/UI/referer-restriction.tsx b/web/src/components/Plugin/UI/referer-restriction.tsx new file mode 100644 index 0000000000..971302b4f6 --- /dev/null +++ b/web/src/components/Plugin/UI/referer-restriction.tsx @@ -0,0 +1,126 @@ +/* + * 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 from 'react'; +import type { FormInstance } from 'antd/es/form'; +import { Form, Input, Button, Switch, Row, Col } from 'antd'; +import { useIntl } from 'umi'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; + +type Props = { + form: FormInstance; +}; + +const FORM_ITEM_LAYOUT = { + labelCol: { + span: 5, + }, + wrapperCol: { + span: 18 + }, +}; + +const FORM_ITEM_WITHOUT_LABEL = { + wrapperCol: { + span: 10, offset: 5 + }, +}; + +const removeBtnStyle = { + marginLeft: 20, + display: 'flex', + alignItems: 'center', +}; + +const RefererRestriction: React.FC = ({ form }) => { + const { formatMessage } = useIntl() + return ( +
+ + {(fields, { add, remove }) => { + return ( +
+ + {fields.map((field, index) => ( + + + + + + + + {fields.length > 1 ? ( + { + remove(field.name); + }} + /> + ) : null} + + + ))} + + + + +
+ ); + }} +
+ + + +
+ ); +} + +export default RefererRestriction; diff --git a/web/src/components/Plugin/locales/en-US.ts b/web/src/components/Plugin/locales/en-US.ts index 3e896cc75e..76e7e373cc 100644 --- a/web/src/components/Plugin/locales/en-US.ts +++ b/web/src/components/Plugin/locales/en-US.ts @@ -31,6 +31,10 @@ export default { 'component.pluginForm.cors.allow_credential.tooltip': 'If you set this option to true, you can not use \'*\' for other options.', 'component.pluginForm.cors.allow_origins_by_regex.tooltip': 'Use regex expressions to match which origin is allowed to enable CORS, for example, [".*.test.com"] can use to match all subdomain of test.com.', + // referer-restriction + 'component.pluginForm.referer-restriction.whitelist.tooltip': 'List of hostname to whitelist. The hostname can be started with * as a wildcard.', + 'component.pluginForm.referer-restriction.bypass_missing.tooltip': 'Whether to bypass the check when the Referer header is missing or malformed.', + // api-breaker 'component.pluginForm.api-breaker.break_response_code.tooltip': 'Return error code when unhealthy.', 'component.pluginForm.api-breaker.max_breaker_sec.tooltip': 'Maximum breaker time(seconds).', @@ -50,6 +54,7 @@ export default { 'component.pluginForm.limit-conn.default_conn_delay.tooltip': 'the latency seconds of request when concurrent requests exceeding conn but below (conn + burst).', 'component.pluginForm.limit-conn.key.tooltip': 'to limit the concurrency level.For example, one can use the host name (or server zone) as the key so that we limit concurrency per host name. Otherwise, we can also use the client address as the key so that we can avoid a single client from flooding our service with too many parallel connections or requests.Now accept those as key: "remote_addr"(client\'s IP), "server_addr"(server\'s IP), "X-Forwarded-For/X-Real-IP" in request header, "consumer_name"(consumer\'s username).', 'component.pluginForm.limit-conn.rejected_code.tooltip': 'returned when the request exceeds conn + burst will be rejected.', + // limit-req 'component.pluginForm.limit-req.rate.tooltip': 'The specified request rate (number per second) threshold. Requests exceeding this rate (and below burst) will get delayed to conform to the rate.', 'component.pluginForm.limit-req.burst.tooltip': 'The number of excessive requests per second allowed to be delayed. Requests exceeding this hard limit will get rejected immediately.', diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts index 1dca43a3f1..ec586771d5 100644 --- a/web/src/components/Plugin/locales/zh-CN.ts +++ b/web/src/components/Plugin/locales/zh-CN.ts @@ -31,6 +31,10 @@ export default { 'component.pluginForm.cors.allow_credential.tooltip': '是否允许跨域访问的请求方携带凭据(如 Cookie 等)。根据 CORS 规范,如果设置该选项为 true,那么将不能在其他选项中使用 * 。', 'component.pluginForm.cors.allow_origins_by_regex.tooltip': '使用正则表达式数组来匹配允许跨域访问的 Origin, 如[".*.test.com"] 可以匹配任何test.com的子域名 * 。', + // referer-restriction + 'component.pluginForm.referer-restriction.whitelist.tooltip': '域名列表。域名开头可以用\'*\'作为通配符。', + 'component.pluginForm.referer-restriction.bypass_missing.tooltip': '当 Referer 不存在或格式有误时,是否绕过检查。', + // api-breaker 'component.pluginForm.api-breaker.break_response_code.tooltip': '不健康返回错误码。', 'component.pluginForm.api-breaker.max_breaker_sec.tooltip': '最大熔断持续时间。',