diff --git a/packages/core/expressions/README.md b/packages/core/expressions/README.md
index 8e9ae7bd60..d59d17cb68 100644
--- a/packages/core/expressions/README.md
+++ b/packages/core/expressions/README.md
@@ -18,6 +18,7 @@ Reusable components to support [Kong's expressions language](https://docs.konghq
- `vue` must be initialized in the host application
- [`monaco-editor`](https://www.npmjs.com/package/monaco-editor) is required as a dependency in the host application
- [`vite-plugin-monaco-editor`](https://www.npmjs.com/package/vite-plugin-monaco-editor) is a required Vite plugin to bundle the Monaco Editor and its web workers
+- [`@kong-ui-public/forms`](https://www.npmjs.com/package/@kong-ui-public/forms) is an optional dependency required for the `RouterPlaygroundModal` component
## Usage
@@ -27,6 +28,7 @@ Install required `dependencies` in your host application:
```sh
yarn add monaco-editor
+yarn add @kong-ui-public/forms # optional: required for `RouterPlaygroundModal` component
```
Install required `devDependencies` in your host application:
@@ -58,9 +60,12 @@ Import the component(s) in your host application as well as the package styles:
```ts
import { asyncInit, ExpressionsEditor } from '@kong-ui-public/expressions'
import '@kong-ui-public/expressions/dist/style.css'
+import '@kong-ui-public/forms/dist/style.css' // optional: required for `RouterPlaygroundModal` component
+
+app.component('VueFormGenerator', VueFormGenerator) // optional: required for `RouterPlaygroundModal` component
```
-This package utilizes [vite-plugin-top-level-await](https://github.com/Menci/vite-plugin-top-level-await) to transform code in order to use top-level await on older browsers. To load the WASM correctly, you must use `await` or `Promise.then` to wait the imported `asyncInit` before using any other imported values.
+This package utilizes [vite-plugin-top-level-await](https://github.com/Menci/vite-plugin-top-level-await) to transform code in order to use top-level await on older browsers. To load the WASM correctly, you must use `await` or `Promise.then` to wait the imported `asyncInit` before using any other imported values.
For example:
@@ -77,4 +82,5 @@ You can also make use of Vue's experimental [Suspense](https://vuejs.org/guide/b
## Individual component documentation
-- [``](docs/expressions-editor.md)
+- [``](docs/expressions-editor.md)
+- [``](docs/router-playground-modal.md)
diff --git a/packages/core/expressions/docs/expressions-editor.md b/packages/core/expressions/docs/expressions-editor.md
index e026f42828..c4db6b6406 100644
--- a/packages/core/expressions/docs/expressions-editor.md
+++ b/packages/core/expressions/docs/expressions-editor.md
@@ -1,4 +1,4 @@
-# ExpressionsEditor.vue
+# ExpressionsEditor
A Monaco-based editor with autocomplete and syntax highlighting support for the expressions language.
diff --git a/packages/core/expressions/docs/router-playground-modal.md b/packages/core/expressions/docs/router-playground-modal.md
new file mode 100644
index 0000000000..24aa13d711
--- /dev/null
+++ b/packages/core/expressions/docs/router-playground-modal.md
@@ -0,0 +1,86 @@
+# RouterPlaygroundModal
+
+The `RouterPlaygroundModal` component is a modal that allows the user to edit a route expression and see the result of the expression evaluation.
+
+- [Requirements](#requirements)
+- [Usage](#usage)
+ - [Install](#install)
+ - [Props](#props)
+ - [Events](#events)
+ - [Usage example](#usage-example)
+- [TypeScript definitions](#typescript-definitions)
+
+## Requirements
+
+[See requirements for the `@kong-ui-public/expressions` package.](../README.md#requirements)
+
+## Usage
+
+### Install
+
+[See instructions for installing the `@kong-ui-public/expressions` package.](../README.md#install)
+
+### Props
+
+#### `isVisible`
+
+- type: `boolean`
+- required: `true`
+
+Controls whether the modal is visible or not.
+
+#### `localstorageKey`
+
+- type: `String`
+- required: `false`
+- default: `kong-manager-router-playground-requests`
+
+The key to use for storing the playground requests in the local storage.
+
+#### `hideEditorActions`
+
+- type: `boolean`
+- required: `false`
+- default: `false`
+
+Controls whether the editor actions should be hidden or not.
+
+#### `initialExpression`
+
+- type: `string`
+- required: `false`
+- default: `''`
+
+The initial expression to be displayed in the editor.
+
+### Events
+
+#### change
+
+A `change` event is emitted when the expression has been updated.
+
+#### commit
+
+A `commit` event is emitted when the expression has been committed.
+
+#### cancel
+
+A `cancel` event is emitted when the modal's cancel button has been clicked.
+
+#### notify
+
+A `notify` event is emitted when a Toast is triggered. The event payload is an object with the following properties:
+- `message`:
+ - type: `string`
+ - The message to display in the Toast.
+- `type`:
+ - type: `'success' | 'error' | 'warning' | 'info'`
+ - The type of Toast to display.
+
+### Usage example
+
+Please refer to the [sandbox](../sandbox/App.vue).
+
+## TypeScript definitions
+
+TypeScript definitions are bundled with the package and can be directly imported into your host application.
diff --git a/packages/core/expressions/package.json b/packages/core/expressions/package.json
index b0b387610c..6a6599c7a3 100644
--- a/packages/core/expressions/package.json
+++ b/packages/core/expressions/package.json
@@ -37,9 +37,11 @@
"test:unit:open": "cross-env FORCE_COLOR=1 vitest --ui"
},
"devDependencies": {
+ "@kong-ui-public/forms": "workspace:^",
"@kong/atc-router": "1.6.0-rc.1",
"@kong/design-tokens": "1.15.3",
"@kong/kongponents": "9.1.7",
+ "@types/uuid": "^9.0.8",
"monaco-editor": "0.21.3",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-top-level-await": "^1.4.1",
@@ -66,10 +68,14 @@
"peerDependencies": {
"@kong/atc-router": "^1.6.0-rc.1",
"@kong/kongponents": "^9.1.7",
+ "@kong-ui-public/forms": "workspace:^",
"monaco-editor": "0.21.3",
"vue": "^3.4.31"
},
"dependencies": {
- "@kong-ui-public/core": "workspace:^"
+ "@kong-ui-public/core": "workspace:^",
+ "@kong-ui-public/i18n": "workspace:^",
+ "@kong/icons": "^1.14.2",
+ "uuid": "^9.0.1"
}
}
diff --git a/packages/core/expressions/sandbox/App.vue b/packages/core/expressions/sandbox/App.vue
index 6ded682b41..e2f4b3d871 100644
--- a/packages/core/expressions/sandbox/App.vue
+++ b/packages/core/expressions/sandbox/App.vue
@@ -29,6 +29,25 @@
@parse-result-update="onParseResultUpdate"
/>
+ Test with Router Playground
+
+
+
+ A playground where you can test out the Kong router Expressions.
+
+
+
ParseResult:
{{ parseResult }}
@@ -40,7 +59,7 @@
-
-
diff --git a/packages/core/expressions/sandbox/index.ts b/packages/core/expressions/sandbox/index.ts
index 52668a0a54..70c15aa2a0 100644
--- a/packages/core/expressions/sandbox/index.ts
+++ b/packages/core/expressions/sandbox/index.ts
@@ -1,6 +1,9 @@
+import '@kong/kongponents/dist/style.css'
import { createApp } from 'vue'
import App from './App.vue'
+import Kongponents from '@kong/kongponents'
const app = createApp(App)
+app.use(Kongponents)
app.mount('#app')
diff --git a/packages/core/expressions/src/components/MonacoEditor.vue b/packages/core/expressions/src/components/MonacoEditor.vue
new file mode 100644
index 0000000000..89a26f388e
--- /dev/null
+++ b/packages/core/expressions/src/components/MonacoEditor.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
diff --git a/packages/core/expressions/src/components/PageHeader.vue b/packages/core/expressions/src/components/PageHeader.vue
new file mode 100644
index 0000000000..598438945c
--- /dev/null
+++ b/packages/core/expressions/src/components/PageHeader.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
diff --git a/packages/core/expressions/src/components/RequestCard.vue b/packages/core/expressions/src/components/RequestCard.vue
new file mode 100644
index 0000000000..4ef8e37742
--- /dev/null
+++ b/packages/core/expressions/src/components/RequestCard.vue
@@ -0,0 +1,256 @@
+
+
+
+
+
+ {{ protocol }}
+
+
+ {{ method.toUpperCase() }}
+
+
+
+ {{ url }}
+
+
+
+ {{ i18n.t('request.SNI') }}
+
+
+ {{ sni }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/expressions/src/components/RequestImportModal.vue b/packages/core/expressions/src/components/RequestImportModal.vue
new file mode 100644
index 0000000000..7ece82214f
--- /dev/null
+++ b/packages/core/expressions/src/components/RequestImportModal.vue
@@ -0,0 +1,159 @@
+
+
+
+
+
+ {{ i18n.t('requestImport.warningBoldText') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/expressions/src/components/RequestModal.vue b/packages/core/expressions/src/components/RequestModal.vue
new file mode 100644
index 0000000000..7112bfaa22
--- /dev/null
+++ b/packages/core/expressions/src/components/RequestModal.vue
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/expressions/src/components/RouterPlayground.cy.ts b/packages/core/expressions/src/components/RouterPlayground.cy.ts
new file mode 100644
index 0000000000..46cf4f0699
--- /dev/null
+++ b/packages/core/expressions/src/components/RouterPlayground.cy.ts
@@ -0,0 +1,186 @@
+// fixme(zehao): this test cannot run without vite plugins in ../../vite.config.ts
+// it gives an erroe when importing RouterPlayground
+// it should be fixed after refactoring whole cypress tests configuration method, see the conversation in PR: 1497
+
+// import RouterPlayground from './RouterPlayground.vue'
+
+// describe('
', () => {
+// beforeEach(() => {
+// cy.viewport(800, 800)
+// })
+
+// it('should show router playground', () => {
+// cy.mount(RouterPlayground)
+
+// cy.getTestId('expression-header').should('be.visible')
+// cy.getTestId('expressions-editor').should('be.visible')
+// cy.getTestId('expressions-inspirations').should('be.visible')
+// cy.getTestId('requests-header').should('be.visible')
+// cy.getTestId('btn-commit').should('be.visible')
+// cy.getTestId('btn-import').should('be.visible')
+// cy.getTestId('empty-state-requests').should('be.visible')
+// })
+
+// it('initial expression', () => {
+// cy.mount(RouterPlayground, {
+// props: {
+// initialExpression: 'http.host == "localhost"',
+// },
+// })
+
+// cy.getTestId('expressions-editor').contains('http.host == "localhost"')
+// cy.getTestId('expressions-inspirations').should('not.exist')
+// })
+
+// it('inspirations', () => {
+// cy.mount(RouterPlayground)
+
+// cy.getTestId('btn-inspiration-0').click()
+// cy.getTestId('expressions-editor').contains('http.host == "localhost"')
+// cy.getTestId('btn-inspiration-0').should('not.exist')
+// })
+
+// it('expression changed', () => {
+// const onChangeSpy = cy.spy().as('onChangeSpy')
+// const onCommitSpy = cy.spy().as('onCommitSpy')
+// cy.mount(RouterPlayground, {
+// props: {
+// initialExpression: 'http.host == "localhost"',
+// onChange: onChangeSpy,
+// onCommit: onCommitSpy,
+// },
+// })
+// cy.get('.view-lines').type(' && http.method == "GET"')
+// cy.get('@onChangeSpy').should('have.been.calledWith', 'http.host == "localhost" && http.method == "GET"')
+
+// cy.getTestId('btn-commit').click()
+// cy.get('@onCommitSpy').should('have.been.calledWith', 'http.host == "localhost" && http.method == "GET"')
+// })
+
+// it('requests placeholder', () => {
+// cy.mount(RouterPlayground)
+
+// cy.getTestId('empty-state-requests').should('be.visible')
+// })
+
+// function addRequest({
+// url,
+// method,
+// headers,
+// }: {
+// url: string
+// method: string
+// headers?: Record
[]
+// }) {
+// cy.getTestId('btn-add-request').click()
+// cy.getTestId('url-input').should('be.visible')
+// cy.getTestId('url-input').type(url)
+
+// cy.get('#method').select(method)
+
+// if (headers?.length) {
+// headers.forEach((header, index) => {
+// cy.getTestId('keyname-input').type(header.key)
+// cy.getTestId('add-key').click()
+// cy.get(`:nth-child(${index + 3}) > .form-control`).type(header.value)
+// })
+// }
+
+// cy.getTestId('modal-action-button').click()
+// }
+
+// it('add/remove requests', () => {
+// cy.mount(RouterPlayground)
+
+// addRequest({
+// url: 'http://localhost:8000',
+// method: 'GET',
+// headers: [
+// { key: 'Foo', value: 'bar' },
+// ],
+// })
+
+// cy.get('.request-card').should('have.length', 1)
+// cy.get('.request-card').first().contains('http://localhost:8000')
+// cy.get('.request-card').first().find('.close-btn').click({ force: true })
+// cy.get('.request-card').should('not.exist')
+// })
+
+// it('matching requests', () => {
+// cy.mount(RouterPlayground)
+// const requestIds: string[] = []
+
+// addRequest({
+// url: 'http://localhost:8000',
+// method: 'GET',
+// headers: [
+// { key: 'Foo', value: 'bar' },
+// ],
+// })
+
+// addRequest({
+// url: 'https://www.konghq.com',
+// method: 'GET',
+// })
+
+// // eslint-disable-next-line cypress/unsafe-to-chain-command
+// cy.get('.request-card').each($card => {
+// requestIds.push($card.data('testid'))
+// }).then(() => {
+// cy.get('.view-lines').type('http.host == "localhost"')
+// cy.getTestId(requestIds[0]).should('have.class', 'active')
+// cy.getTestId(requestIds[1]).should('not.have.class', 'active')
+// })
+// })
+
+// it('cache requests', () => {
+// cy.clearAllLocalStorage()
+
+// cy.mount(RouterPlayground)
+
+// addRequest({
+// url: 'http://localhost:8000',
+// method: 'GET',
+// })
+
+// cy.mount(RouterPlayground)
+// cy.get('.request-card').should('have.length', 1)
+
+// cy.getTestId('clear-requests-link').click()
+// cy.getTestId('modal-action-button').click()
+
+// cy.mount(RouterPlayground)
+// cy.get('.request-card').should('not.exist')
+// })
+
+// const REQUESTS_TEXT = '[{"method":"POST","headers":{},"protocol":"http","host":"localhost","port":8080,"path":"/","id":"42eafd0a-287b-441d-b88f-f6fe4b44da0d"},{"method":"GET","headers":{},"protocol":"https","host":"konghq.com","port":443,"path":"/abc","id":"da8f04a9-c8c5-495d-a26c-f7bc132a3a64"}]'
+
+// it('import/export requests', () => {
+// cy.mount(RouterPlayground)
+// cy.getTestId('btn-import').click()
+// cy.getTestId('import-requests-editor').should('be.visible')
+// cy.getTestId('import-requests-editor').find('.view-lines').type(REQUESTS_TEXT, {
+// parseSpecialCharSequences: false,
+// })
+// cy.getTestId('modal-action-button').click()
+// cy.get('.request-card').should('have.length', 2)
+// cy.get('.request-card').first().contains('http://localhost:8080/')
+// cy.get('.request-card:nth-child(2)').contains('https://konghq.com/abc')
+
+// cy.window().then(() => {
+// // eslint-disable-next-line cypress/unsafe-to-chain-command
+// cy.getTestId('btn-export').focus().click()
+// cy.assertValueCopiedToClipboard(REQUESTS_TEXT)
+// })
+// })
+
+// })
+
+// // todo(zehao): add this to global commands
+// // Cypress.Commands.add('assertValueCopiedToClipboard', value => {
+// // cy.window().then(win => {
+// // win.navigator.clipboard.readText().then(text => {
+// // expect(text).to.eq(value)
+// // })
+// // })
+// // })
diff --git a/packages/core/expressions/src/components/RouterPlayground.vue b/packages/core/expressions/src/components/RouterPlayground.vue
new file mode 100644
index 0000000000..b31390340c
--- /dev/null
+++ b/packages/core/expressions/src/components/RouterPlayground.vue
@@ -0,0 +1,511 @@
+
+
+
+
+
+
+
+ {{ i18n.t('routerPlayground.learnMore') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.t('routerPlayground.addToRoute') }}
+
+
+
+
+
+
+ {{ i18n.t('routerPlayground.inspiration') }}
+
+
+
+ {{ exp }}
+
+
+
+
+
+
+
+
+ {{ i18n.t('routerPlayground.import') }}
+
+
+
+
+ {{ i18n.t('routerPlayground.export') }}
+
+
+
+ {{ i18n.t('routerPlayground.add') }}
+
+
+
+
+
+
+ {{ i18n.t('routerPlayground.noRequests') }}
+
+
+ {{ i18n.t('routerPlayground.noRequestsDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.t('routerPlayground.clearRequestsPrompt') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/expressions/src/components/RouterPlaygroundModal.vue b/packages/core/expressions/src/components/RouterPlaygroundModal.vue
new file mode 100644
index 0000000000..df23c35b05
--- /dev/null
+++ b/packages/core/expressions/src/components/RouterPlaygroundModal.vue
@@ -0,0 +1,72 @@
+
+
+
+
+ expression = exp"
+ @notify="emit('notify', $event)"
+ />
+
+
+
+
+
+
+
+
diff --git a/packages/core/expressions/src/components/SupportText.vue b/packages/core/expressions/src/components/SupportText.vue
new file mode 100644
index 0000000000..23d17ffe9e
--- /dev/null
+++ b/packages/core/expressions/src/components/SupportText.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/packages/core/expressions/src/composables/index.ts b/packages/core/expressions/src/composables/index.ts
new file mode 100644
index 0000000000..3893dfc828
--- /dev/null
+++ b/packages/core/expressions/src/composables/index.ts
@@ -0,0 +1,6 @@
+import useI18n from './useI18n'
+
+// All composables must be exported as part of the default object for Cypress test stubs
+export default {
+ useI18n,
+}
diff --git a/packages/core/expressions/src/composables/useI18n.ts b/packages/core/expressions/src/composables/useI18n.ts
new file mode 100644
index 0000000000..337ff02756
--- /dev/null
+++ b/packages/core/expressions/src/composables/useI18n.ts
@@ -0,0 +1,11 @@
+import { createI18n, i18nTComponent } from '@kong-ui-public/i18n'
+import english from '../locales/en.json'
+
+export default function useI18n() {
+ const i18n = createI18n('en-us', english)
+
+ return {
+ i18n,
+ i18nT: i18nTComponent(i18n), // Translation component
+ }
+}
diff --git a/packages/core/expressions/src/definitions.ts b/packages/core/expressions/src/definitions.ts
new file mode 100644
index 0000000000..bf8a9dc4ea
--- /dev/null
+++ b/packages/core/expressions/src/definitions.ts
@@ -0,0 +1,25 @@
+export type Request = {
+ id: string;
+ protocol: string;
+ host: string;
+ port: number;
+ path: string;
+ method?: string;
+ headers?: { [k: string]: string | string[] };
+ sni?: string;
+}
+
+export const DEFAULT_PROTOCOL_PORTS = {
+ http: 80,
+ https: 443,
+ grpc: 80,
+ grpcs: 443,
+ ws: 80,
+ wss: 443,
+}
+
+export const HTTP_PROTOCOLS = new Set(['http', 'https'])
+
+export const SECURED_PROTOCOLS = new Set(['https', 'grpcs', 'wss'])
+
+export const SUPPORTED_PROTOCOLS = new Set(Object.keys(DEFAULT_PROTOCOL_PORTS))
diff --git a/packages/core/expressions/src/external-links.ts b/packages/core/expressions/src/external-links.ts
new file mode 100644
index 0000000000..6aaa75eb65
--- /dev/null
+++ b/packages/core/expressions/src/external-links.ts
@@ -0,0 +1,3 @@
+export default {
+ expressionsLanguageDoc: 'https://docs.konghq.com/gateway/latest/reference/expressions-language/language-references/',
+}
diff --git a/packages/core/expressions/src/index.ts b/packages/core/expressions/src/index.ts
index ec9ebd21b3..b4916869b8 100644
--- a/packages/core/expressions/src/index.ts
+++ b/packages/core/expressions/src/index.ts
@@ -1,8 +1,9 @@
import ExpressionsEditor from './components/ExpressionsEditor.vue'
+import RouterPlaygroundModal from './components/RouterPlaygroundModal.vue'
export * as Atc from '@kong/atc-router'
export * from './schema'
-export { ExpressionsEditor }
+export { ExpressionsEditor, RouterPlaygroundModal }
declare const asyncInit: Promise
export { asyncInit }
diff --git a/packages/core/expressions/src/locales/en.json b/packages/core/expressions/src/locales/en.json
new file mode 100644
index 0000000000..f9c80b9d8d
--- /dev/null
+++ b/packages/core/expressions/src/locales/en.json
@@ -0,0 +1,64 @@
+{
+ "comma": ", ",
+ "requestImport": {
+ "warning": "Warning: All saved requests {boldText} with the imported ones.",
+ "warningBoldText": "will be removed and replaced",
+ "title": "Import requests from JSON",
+ "jsonError": "Expecting a JSON array"
+ },
+ "requestModal": {
+ "title": "Add a request",
+ "help": "Supported protocols: {protocols}",
+ "noneSelectedText": "Nothing Selected...",
+ "methodInputPlaceholder": "Enter a Method",
+ "headersInputPlaceholder": "Enter header name",
+ "headerButtonLabel": "Header Values",
+ "headerHint": "e.g. my-header",
+ "headerValueInputPlaceholder": "Comma separated list of header values",
+ "headerValueHint": "e.g. value1, value2, value 3",
+ "sniPlaceholder": "Enter an SNI",
+ "invalidRequest": "Invalid request: {err}",
+ "unsupportedProtocol": "Unsupported protocol. (Supported protocols: {protocols})"
+ },
+ "request": {
+ "SNI": "SNI",
+ "headers": "headers",
+ "Method": "Method",
+ "Headers": "Headers"
+ },
+ "routerPlayground": {
+ "help": "A playground where you can test out the Kong router Expressions. {link}",
+ "learnMore": "Learn more",
+ "expressions": "Expression",
+ "addToRoute": "Add to Route",
+ "inspiration": "Inspiration for Quickstart",
+ "importTooltip": "Import requests in JSON format",
+ "import": "Import",
+ "exportTooltip": "Export all requests as JSON to clipboard",
+ "export": "Export",
+ "add": "Add",
+ "addRequest": "Add a request",
+ "noRequests": "No requests",
+ "noRequestsDescription": "Add requests to test out route expressions.",
+ "clearRequests": "Requests appearing here are saved locally within the browser. {link} to clear all saved requests",
+ "click": "Click here",
+ "clearRequestsPrompt": "All saved requests will be removed from the browser. This operation is permanent and cannot be undone. Would you like to proceed?",
+ "notifyCopy": "Successfully copied to clipboard",
+ "notifyClear": "Successfully cleared all requests",
+ "notifyImport": "Successfully imported requests from JSON"
+ },
+ "routerPlaygroundModal": {
+ "actionButton": "Add to Route",
+ "title": "Router Playground"
+ },
+ "errors": {
+ "requiredProtocol": "Protocol is required",
+ "unsupportedProtocols": "Protocol is unsupported (Supported protocols: {protocols})",
+ "requiredHost": "Host is required",
+ "requiredPath": "Path is required",
+ "sniNotAvailable": "SNI is not available for \"{protocol}\" protocol",
+ "requiredMethod": "Method is required for \"{protocol}\" protocol",
+ "methodShouldCapitalized": "Method should be all capitalized",
+ "failedToImport": "Failed to import request #{i}: {err}"
+ }
+}
diff --git a/packages/core/expressions/src/schema.ts b/packages/core/expressions/src/schema.ts
index 13681b446c..45cb62e272 100644
--- a/packages/core/expressions/src/schema.ts
+++ b/packages/core/expressions/src/schema.ts
@@ -42,14 +42,17 @@ export const STREAM_SCHEMA_DEFINITION: SchemaDefinition = {
IpAddr: ['net.src.ip', 'net.dst.ip'],
}
+export const HTTP_BASED_PROTOCOLS = ['http', 'https', 'grpc', 'grpcs', 'ws', 'wss']
+export const STREAM_BASED_PROTOCOLS = ['tcp', 'udp', 'tls', 'tls_passthrough']
+
export const PROTOCOL_TO_SCHEMA = (() => {
const s: Record = {}
- for (const protocol of ['http', 'https', 'grpc', 'grpcs', 'ws', 'wss']) {
+ for (const protocol of HTTP_BASED_PROTOCOLS) {
s[protocol] = { name: protocol, definition: HTTP_SCHEMA_DEFINITION }
}
- for (const protocol of ['tcp', 'udp', 'tls', 'tls_passthrough']) {
+ for (const protocol of STREAM_BASED_PROTOCOLS) {
s[protocol] = { name: protocol, definition: STREAM_SCHEMA_DEFINITION }
}
diff --git a/packages/core/expressions/src/utils.ts b/packages/core/expressions/src/utils.ts
new file mode 100644
index 0000000000..cd5e771052
--- /dev/null
+++ b/packages/core/expressions/src/utils.ts
@@ -0,0 +1,62 @@
+import { v4 as uuidv4 } from 'uuid'
+import {
+ DEFAULT_PROTOCOL_PORTS, HTTP_PROTOCOLS, SECURED_PROTOCOLS, SUPPORTED_PROTOCOLS, type Request,
+} from './definitions'
+import composables from './composables'
+
+export const validateRequest = (request: Request) => {
+ if (!SUPPORTED_PROTOCOLS.has(request.protocol)) throw new Error(`Unsupported protocol: ${request.protocol}. (Supported protocols: ${Array.from(SUPPORTED_PROTOCOLS.values()).join(', ')})`)
+}
+
+/**
+ * Transforms and checks if the request is valid
+ * @param request
+ * @returns
+ */
+export const transformCheckRequest = (request: Partial): string | undefined => {
+ const { i18n } = composables.useI18n()
+
+ if (request.id === undefined) {
+ request.id = uuidv4()
+ }
+
+ if (!request.protocol) {
+ return i18n.t('errors.requiredProtocol')
+ }
+
+ if (!SUPPORTED_PROTOCOLS.has(request.protocol)) {
+ return i18n.t('errors.unsupportedProtocols', {
+ protocols: Array.from(SUPPORTED_PROTOCOLS).join(i18n.t('comma')),
+ })
+ }
+
+ if (!request.port) {
+ request.port = (DEFAULT_PROTOCOL_PORTS as any)[request.protocol]
+ }
+
+ if (!request.host) {
+ return i18n.t('errors.requiredHost')
+ }
+
+ if (!request.path) {
+ return i18n.t('errors.requiredPath')
+ }
+
+ if (!SECURED_PROTOCOLS.has(request.protocol) && request.sni) {
+ return i18n.t('errors.sniNotAvailable', { protocol: request.protocol })
+ }
+
+ if (HTTP_PROTOCOLS.has(request.protocol)) {
+ if (Array.isArray(request.method)) {
+ request.method = request.method[0]
+ }
+
+ if (!request.method) {
+ return i18n.t('errors.requiredMethod', { protocol: request.protocol })
+ }
+
+ if (!/^[A-Z]+$/g.test(request.method)) {
+ return i18n.t('errors.methodShouldCapitalized')
+ }
+ }
+}
diff --git a/packages/core/expressions/vite.config.ts b/packages/core/expressions/vite.config.ts
index ec1423e694..62ed455b16 100644
--- a/packages/core/expressions/vite.config.ts
+++ b/packages/core/expressions/vite.config.ts
@@ -21,7 +21,7 @@ const config = mergeConfig(sharedViteConfig, defineConfig({
fileName: (format) => `${sanitizedPackageName}.${format}.js`,
},
rollupOptions: {
- external: ['monaco-editor'],
+ external: ['monaco-editor', '@kong-ui-public/forms', '@kong-ui-public/forms/dist/style.css'],
},
},
plugins: [
@@ -29,11 +29,11 @@ const config = mergeConfig(sharedViteConfig, defineConfig({
topLevelAwait({
promiseExportName: 'asyncInit',
}),
- // We don't need this plugin to bundle the library. Only for sandbox previews.
- // See: https://github.com/vdesjs/vite-plugin-monaco-editor/issues/21
- ...process.env.USE_SANDBOX
+ // This plugin is only used in the sandbox & testing environment
+ // It generates extra files in dist folder which are not need in library build
+ ...(process.env.USE_SANDBOX
? [((monacoEditorPlugin as any).default as typeof monacoEditorPlugin)({})]
- : [],
+ : []),
],
}))
diff --git a/packages/entities/entities-routes/docs/route-form.md b/packages/entities/entities-routes/docs/route-form.md
index f5b92e3093..99472de710 100644
--- a/packages/entities/entities-routes/docs/route-form.md
+++ b/packages/entities/entities-routes/docs/route-form.md
@@ -109,7 +109,7 @@ Show/hide Route name field. If `true`, `name` field is stripped from payload obj
- required: `false`
- default: `false`
-Show/hide Service Select field. Should be used in case of manual adding `service_id` in payload.
+Show/hide Service Select field. Should be used in case of manually adding `service_id` in payload.
#### `showTagsFiledUnderAdvanced`
@@ -154,6 +154,13 @@ Show tags field under _Advanced Fields_ collapse or in it's default place (befor
- default: `undefined`
- Text to show in the tooltip of the Expressions config tab.
+#### `showExpressionsModalEntry`
+
+- type: `Boolean`
+- required: `false`
+- default: `false`
+
+Show/hide the Expressions modal entry button.
### Slots
@@ -202,6 +209,16 @@ A `@update` event is emitted when the form is saved. The event payload is the Ro
A `@model-updated` event is emitted when any form value was changed. The event payload is the Route payload object.
+#### notify
+
+A `@notify` event is emitted when a Toast is called. The event payload is an object with the following properties:
+- `message`:
+ - type: `string`
+ - The message to display in the Toast.
+- `type`:
+ - type: `'success' | 'error' | 'warning' | 'info'`
+ - The type of Toast to display.
+
### Usage example
Please refer to the [sandbox](../sandbox/pages/RouteListPage.vue). The form is accessible by clicking the `+ New Route` button or `Edit` action of an existing Route.
diff --git a/packages/entities/entities-routes/package.json b/packages/entities/entities-routes/package.json
index d2701c9753..d4cec5843c 100644
--- a/packages/entities/entities-routes/package.json
+++ b/packages/entities/entities-routes/package.json
@@ -67,7 +67,7 @@
"extends": "../../../package.json"
},
"distSizeChecker": {
- "errorLimit": "700KB"
+ "errorLimit": "800KB"
},
"dependencies": {
"@kong-ui-public/entities-shared": "workspace:^",
diff --git a/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue b/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue
index 11766c81a3..3de2c2e16f 100644
--- a/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue
+++ b/packages/entities/entities-routes/sandbox/pages/RouteFormPage.vue
@@ -26,6 +26,7 @@
:config="konnectConfig"
:route-flavors="routeFlavors"
:route-id="routeId"
+ show-expressions-modal-entry
@error="onError"
@update="onUpdate"
>
@@ -55,6 +56,7 @@
:config="kongManagerConfig"
:route-flavors="routeFlavors"
:route-id="routeId"
+ show-expressions-modal-entry
@error="onError"
@update="onUpdate"
>
diff --git a/packages/entities/entities-routes/src/components/RouteForm.cy.ts b/packages/entities/entities-routes/src/components/RouteForm.cy.ts
index 68c643936d..4244db4938 100644
--- a/packages/entities/entities-routes/src/components/RouteForm.cy.ts
+++ b/packages/entities/entities-routes/src/components/RouteForm.cy.ts
@@ -3,6 +3,7 @@ import RouteForm from './RouteForm.vue'
import { route, routeExpressions, services } from '../../fixtures/mockData'
import { EntityBaseForm } from '@kong-ui-public/entities-shared'
import type { RouteHandler } from 'cypress/types/net-stubbing'
+import { HTTP_BASED_PROTOCOLS, STREAM_BASED_PROTOCOLS } from '@kong-ui-public/expressions'
const cancelRoute = { name: 'route-list' }
@@ -831,6 +832,83 @@ describe('', { viewportHeight: 700, viewportWidth: 700 }, () => {
cy.getTestId('select-item-ws').should('not.exist')
cy.getTestId('select-item-wss').should('not.exist')
})
+
+ describe('RoutePlayground', () => {
+ beforeEach(() => {
+ cy.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop completed with undelivered notifications.'))
+ })
+
+ it('route playground entry should hide if select stream-based protocols', () => {
+ cy.mount(RouteForm, {
+ props: {
+ config: baseConfigKM,
+ routeFlavors: TRADITIONAL_EXPRESSIONS,
+ showExpressionsModalEntry: true,
+ },
+ })
+
+ cy.get('#expressions-tab').click()
+
+ STREAM_BASED_PROTOCOLS.forEach((protocol) => {
+ cy.getTestId('route-form-protocols').click({ force: true })
+ cy.get(`[data-testid='select-item-${protocol}']`).click()
+ cy.getTestId('open-router-playground').should('have.class', 'disabled')
+ cy.getTestId('open-router-playground').click()
+ cy.get('.router-playground-wrapper').should('not.exist')
+ })
+ })
+
+ it('route playground entry should show if select http-based protocols', () => {
+ cy.mount(RouteForm, {
+ props: {
+ config: baseConfigKM,
+ routeFlavors: TRADITIONAL_EXPRESSIONS,
+ showExpressionsModalEntry: true,
+ },
+ })
+
+ cy.get('#expressions-tab').click()
+
+ HTTP_BASED_PROTOCOLS.forEach((protocol) => {
+ cy.getTestId('route-form-protocols').click({ force: true })
+ cy.get(`[data-testid='select-item-${protocol}']`).click()
+ cy.getTestId('open-router-playground').should('not.have.class', 'disabled')
+ })
+ })
+
+ it('route playground should have initial expression value', () => {
+ cy.mount(RouteForm, {
+ props: {
+ config: baseConfigKM,
+ routeFlavors: TRADITIONAL_EXPRESSIONS,
+ showExpressionsModalEntry: true,
+ },
+ })
+
+ cy.get('#expressions-tab').click()
+ cy.get('.monaco-editor').first().as('monacoEditor').click()
+ cy.get('@monacoEditor').type('http.path == "/kong"')
+ cy.getTestId('open-router-playground').click()
+ cy.get('.router-playground > [data-testid="expressions-editor"]').contains('http.path == "/kong"')
+ })
+
+ it('should expression updated when save in route playground', () => {
+ cy.mount(RouteForm, {
+ props: {
+ config: baseConfigKM,
+ routeFlavors: TRADITIONAL_EXPRESSIONS,
+ showExpressionsModalEntry: true,
+ },
+ })
+ cy.get('#expressions-tab').click()
+ cy.get('.monaco-editor').first().as('monacoEditor').click()
+ cy.get('@monacoEditor').type('http.path == "/kong"')
+ cy.getTestId('open-router-playground').click()
+ cy.get('.router-playground > [data-testid="expressions-editor"]').type(' && http.method == "GET"')
+ cy.getTestId('modal-action-button').click()
+ cy.get('@monacoEditor').contains('http.path == "/kong" && http.method == "GET"')
+ })
+ })
})
describe('Konnect', { viewportHeight: 700, viewportWidth: 700 }, () => {
@@ -1609,6 +1687,83 @@ describe('', { viewportHeight: 700, viewportWidth: 700 }, () => {
cy.get('@onUpdateSpy').should('have.been.calledOnce')
})
+
+ describe('RoutePlayground', () => {
+ beforeEach(() => {
+ cy.on('uncaught:exception', err => !err.message.includes('ResizeObserver loop completed with undelivered notifications.'))
+ })
+
+ it('route playground entry should hide if select stream-based protocols', () => {
+ cy.mount(RouteForm, {
+ props: {
+ config: baseConfigKonnect,
+ routeFlavors: TRADITIONAL_EXPRESSIONS,
+ showExpressionsModalEntry: true,
+ },
+ })
+
+ cy.get('#expressions-tab').click()
+
+ STREAM_BASED_PROTOCOLS.forEach((protocol) => {
+ cy.getTestId('route-form-protocols').click({ force: true })
+ cy.get(`[data-testid='select-item-${protocol}']`).click()
+ cy.getTestId('open-router-playground').should('have.class', 'disabled')
+ cy.getTestId('open-router-playground').click()
+ cy.get('.router-playground-wrapper').should('not.exist')
+ })
+ })
+
+ it('route playground entry should show if select http-based protocols', () => {
+ cy.mount(RouteForm, {
+ props: {
+ config: baseConfigKonnect,
+ routeFlavors: TRADITIONAL_EXPRESSIONS,
+ showExpressionsModalEntry: true,
+ },
+ })
+
+ cy.get('#expressions-tab').click()
+
+ HTTP_BASED_PROTOCOLS.forEach((protocol) => {
+ cy.getTestId('route-form-protocols').click({ force: true })
+ cy.get(`[data-testid='select-item-${protocol}']`).click()
+ cy.getTestId('open-router-playground').should('not.have.class', 'disabled')
+ })
+ })
+
+ it('route playground should have initial expression value', () => {
+ cy.mount(RouteForm, {
+ props: {
+ config: baseConfigKonnect,
+ routeFlavors: TRADITIONAL_EXPRESSIONS,
+ showExpressionsModalEntry: true,
+ },
+ })
+
+ cy.get('#expressions-tab').click()
+ cy.get('.monaco-editor').first().as('monacoEditor').click()
+ cy.get('@monacoEditor').type('http.path == "/kong"')
+ cy.getTestId('open-router-playground').click()
+ cy.get('.router-playground > [data-testid="expressions-editor"]').contains('http.path == "/kong"')
+ })
+
+ it('should expression updated when save in route playground', () => {
+ cy.mount(RouteForm, {
+ props: {
+ config: baseConfigKonnect,
+ routeFlavors: TRADITIONAL_EXPRESSIONS,
+ showExpressionsModalEntry: true,
+ },
+ })
+ cy.get('#expressions-tab').click()
+ cy.get('.monaco-editor').first().as('monacoEditor').click()
+ cy.get('@monacoEditor').type('http.path == "/kong"')
+ cy.getTestId('open-router-playground').click()
+ cy.get('.router-playground > [data-testid="expressions-editor"]').type(' && http.method == "GET"')
+ cy.getTestId('modal-action-button').click()
+ cy.get('@monacoEditor').contains('http.path == "/kong" && http.method == "GET"')
+ })
+ })
} // for RouteFlavors[]
})
})
diff --git a/packages/entities/entities-routes/src/components/RouteForm.vue b/packages/entities/entities-routes/src/components/RouteForm.vue
index ab9a09ca2d..e6358925c4 100644
--- a/packages/entities/entities-routes/src/components/RouteForm.vue
+++ b/packages/entities/entities-routes/src/components/RouteForm.vue
@@ -359,6 +359,8 @@
undefined,
},
+ /** Whether to show the expressions modal entry */
+ showExpressionsModalEntry: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
})
const emit = defineEmits<{
@@ -576,6 +584,7 @@ const emit = defineEmits<{
(e: 'error', error: AxiosError): void,
(e: 'loading', isLoading: boolean): void,
(e: 'model-updated', val: BaseRoutePayload): void,
+ (e: 'notify', options: { message: string, type: string }): void,
}>()
const currentConfigHash = ref(
diff --git a/packages/entities/entities-routes/src/components/RouteFormExpressionsEditor.vue b/packages/entities/entities-routes/src/components/RouteFormExpressionsEditor.vue
index 17568d6605..4c0b4eafe3 100644
--- a/packages/entities/entities-routes/src/components/RouteFormExpressionsEditor.vue
+++ b/packages/entities/entities-routes/src/components/RouteFormExpressionsEditor.vue
@@ -4,21 +4,84 @@
v-model="expression"
:schema="schema"
/>
+
+
+
+
+
+
+
+
diff --git a/packages/entities/entities-routes/src/components/RouteFormExpressionsEditorLoader.vue b/packages/entities/entities-routes/src/components/RouteFormExpressionsEditorLoader.vue
index 46128e64ec..76a0b0a25f 100644
--- a/packages/entities/entities-routes/src/components/RouteFormExpressionsEditorLoader.vue
+++ b/packages/entities/entities-routes/src/components/RouteFormExpressionsEditorLoader.vue
@@ -12,8 +12,14 @@
+ :show-expressions-modal-entry="showExpressionsModalEntry"
+ @notify="emit('notify', $event)"
+ >
+
+ {{ t('form.expression_playground.test_link') }}
+
h('div', t('form.expressions_editor.loading')),
@@ -49,7 +57,13 @@ const RouteFormExpressionsEditor = defineAsyncComponent({
errorComponent,
})
-const props = defineProps<{ protocol?: string }>()
+const props = defineProps<{
+ protocol?: string
+ showExpressionsModalEntry?: boolean
+}>()
+const emit = defineEmits<{
+ (e: 'notify', options: { message: string, type: string }): void
+}>()
const state = ref(ExpressionsEditorState.LOADING)
const expression = defineModel({ required: true })
@@ -69,3 +83,23 @@ onMounted(async () => {
}
})
+
+
diff --git a/packages/entities/entities-routes/src/locales/en.json b/packages/entities/entities-routes/src/locales/en.json
index 60e73075ab..4736b26696 100644
--- a/packages/entities/entities-routes/src/locales/en.json
+++ b/packages/entities/entities-routes/src/locales/en.json
@@ -248,6 +248,10 @@
"expressions_editor": {
"loading": "Loading the Expressions editor…",
"error": "Error occurred while loading the Expressions editor. Please view the console for more details."
+ },
+ "expression_playground": {
+ "test_link": "Test with Router Playground",
+ "supported_protocols_hint": "Currently only supports the following protocols: {protocols}"
}
}
}
diff --git a/packages/entities/entities-routes/vite.config.ts b/packages/entities/entities-routes/vite.config.ts
index 9219a541fe..251850395f 100644
--- a/packages/entities/entities-routes/vite.config.ts
+++ b/packages/entities/entities-routes/vite.config.ts
@@ -20,22 +20,28 @@ const config = mergeConfig(sharedViteConfig, defineConfig({
rollupOptions: {
external: [
'@kong-ui-public/expressions', // This is optional if we do not use Expressions features
+ '@kong-ui-public/expressions/dist/style.css', // This is optional if we do not use Expressions features
'monaco-editor', // This is optional if we do not use Expressions features
],
},
},
- server: {
- proxy: {
- // Add the API proxies to inject the Authorization header
- ...getApiProxies(),
- },
- },
- ...process.env.USE_SANDBOX && {
- plugins: [
- // See: https://github.com/vdesjs/vite-plugin-monaco-editor/issues/21
- ((monacoEditorPlugin as any).default as typeof monacoEditorPlugin)({}),
- ],
- },
+ ...(process.env.USE_SANDBOX
+ ? {
+ server: {
+ proxy: {
+ // Add the API proxies to inject the Authorization header
+ ...getApiProxies(),
+ },
+ },
+ }
+ : {}),
+ plugins: [
+ // This plugin is only used in the sandbox & testing environment
+ // It generates extra files in dist folder whitch are not need in library build
+ ...(process.env.USE_SANDBOX
+ ? [((monacoEditorPlugin as any).default as typeof monacoEditorPlugin)({})]
+ : []),
+ ],
}))
// If we are trying to preview a build of the local `package/entities-routes/sandbox` directory,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7c3f2ea00a..f1a55bc7a6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -545,7 +545,19 @@ importers:
'@kong-ui-public/core':
specifier: workspace:^
version: link:../core
+ '@kong-ui-public/i18n':
+ specifier: workspace:^
+ version: link:../i18n
+ '@kong/icons':
+ specifier: ^1.14.2
+ version: 1.15.1(vue@3.4.31(typescript@5.3.3))
+ uuid:
+ specifier: ^9.0.1
+ version: 9.0.1
devDependencies:
+ '@kong-ui-public/forms':
+ specifier: workspace:^
+ version: link:../forms
'@kong/atc-router':
specifier: 1.6.0-rc.1
version: 1.6.0-rc.1
@@ -555,6 +567,9 @@ importers:
'@kong/kongponents':
specifier: 9.1.7
version: 9.1.7(axios@1.6.8)(vue-router@4.4.0(vue@3.4.31(typescript@5.3.3)))(vue@3.4.31(typescript@5.3.3))
+ '@types/uuid':
+ specifier: ^9.0.8
+ version: 9.0.8
monaco-editor:
specifier: 0.21.3
version: 0.21.3
@@ -1739,7 +1754,6 @@ packages:
'@evilmartians/lefthook@1.7.1':
resolution: {integrity: sha512-Wp8DaTMHZM1tUV4Mow6nG+6zq+giruD5054zHmFIDLXlPQxqYxnZMqJg0aYxe16vYwqFmH6NIClEMRdtGucO0Q==}
- cpu: [x64, arm64, ia32]
os: [darwin, linux, win32]
hasBin: true