From c3d0c8781d7ffe515aa57eb35c003e4afaa6a2c8 Mon Sep 17 00:00:00 2001 From: Makito Date: Thu, 10 Apr 2025 17:42:38 +0800 Subject: [PATCH 1/5] feat(entities-config-editor): migrate from PoC --- .../core/entities-config-editor/package.json | 3 + .../entities-config-editor/sandbox/App.vue | 84 ++- .../sandbox/fixture/ca_certificates.json | 38 ++ .../sandbox/fixture/certificates.json | 86 +++ .../sandbox/fixture/consumers.json | 61 ++ .../sandbox/fixture/routes.json | 288 +++++++++ .../sandbox/fixture/services.json | 171 +++++ .../sandbox/fixture/snis.json | 54 ++ .../sandbox/fixture/targets.json | 56 ++ .../sandbox/fixture/upstreams.json | 424 +++++++++++++ .../entities-config-editor/sandbox/index.ts | 5 + .../src/components/EntitiesConfigEditor.vue | 589 +++++++++++++++++- .../src/composables/index.ts | 3 + .../src/composables/useMonacoEditor.ts | 88 +++ .../src/types/editor.ts | 10 + .../src/types/fields.ts | 143 +++++ .../src/types/foreign.ts | 8 + .../entities-config-editor/src/types/index.ts | 10 +- .../src/types/jsonschema.ts | 12 + .../src/utils/builders.ts | 268 ++++++++ .../entities-config-editor/src/utils/index.ts | 4 + .../src/utils/monaco.ts | 19 + .../src/utils/strings.ts | 29 + .../entities-config-editor/src/utils/uuid.ts | 13 + pnpm-lock.yaml | 36 +- 25 files changed, 2482 insertions(+), 20 deletions(-) create mode 100644 packages/core/entities-config-editor/sandbox/fixture/ca_certificates.json create mode 100644 packages/core/entities-config-editor/sandbox/fixture/certificates.json create mode 100644 packages/core/entities-config-editor/sandbox/fixture/consumers.json create mode 100644 packages/core/entities-config-editor/sandbox/fixture/routes.json create mode 100644 packages/core/entities-config-editor/sandbox/fixture/services.json create mode 100644 packages/core/entities-config-editor/sandbox/fixture/snis.json create mode 100644 packages/core/entities-config-editor/sandbox/fixture/targets.json create mode 100644 packages/core/entities-config-editor/sandbox/fixture/upstreams.json create mode 100644 packages/core/entities-config-editor/src/composables/index.ts create mode 100644 packages/core/entities-config-editor/src/composables/useMonacoEditor.ts create mode 100644 packages/core/entities-config-editor/src/types/editor.ts create mode 100644 packages/core/entities-config-editor/src/types/fields.ts create mode 100644 packages/core/entities-config-editor/src/types/foreign.ts create mode 100644 packages/core/entities-config-editor/src/types/jsonschema.ts create mode 100644 packages/core/entities-config-editor/src/utils/builders.ts create mode 100644 packages/core/entities-config-editor/src/utils/index.ts create mode 100644 packages/core/entities-config-editor/src/utils/monaco.ts create mode 100644 packages/core/entities-config-editor/src/utils/strings.ts create mode 100644 packages/core/entities-config-editor/src/utils/uuid.ts diff --git a/packages/core/entities-config-editor/package.json b/packages/core/entities-config-editor/package.json index eb67778fc7..66b5bbf089 100644 --- a/packages/core/entities-config-editor/package.json +++ b/packages/core/entities-config-editor/package.json @@ -41,6 +41,9 @@ "devDependencies": { "@kong/design-tokens": "1.17.3", "@kong/kongponents": "9.25.0", + "monaco-editor": "^0.52.2", + "uuid": "^10.0.0", + "vscode-json-languageservice": "^5.4.4", "vue": "^3.5.13" }, "repository": { diff --git a/packages/core/entities-config-editor/sandbox/App.vue b/packages/core/entities-config-editor/sandbox/App.vue index c2dc23a618..72790a3dc4 100644 --- a/packages/core/entities-config-editor/sandbox/App.vue +++ b/packages/core/entities-config-editor/sandbox/App.vue @@ -1,12 +1,86 @@ + + diff --git a/packages/core/entities-config-editor/sandbox/fixture/ca_certificates.json b/packages/core/entities-config-editor/sandbox/fixture/ca_certificates.json new file mode 100644 index 0000000000..8d8e381c13 --- /dev/null +++ b/packages/core/entities-config-editor/sandbox/fixture/ca_certificates.json @@ -0,0 +1,38 @@ +{ + "fields": [ + { + "id": { + "description": "A string representing a UUID (universally unique identifier).", + "uuid": true, + "type": "string", + "auto": true + } + }, + { + "created_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "updated_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { "cert": { "description": "A string representing a certificate.", "type": "string", "required": true } }, + { "cert_digest": { "description": "The digest of the CA certificate.", "type": "string", "unique": true } }, + { + "tags": { + "description": "A set of strings representing tags.", + "elements": { "description": "A string representing a tag.", "type": "string", "required": true }, + "type": "set" + } + } + ], + "entity_checks": [{ "custom_entity_check": { "field_sources": ["cert"] } }] +} diff --git a/packages/core/entities-config-editor/sandbox/fixture/certificates.json b/packages/core/entities-config-editor/sandbox/fixture/certificates.json new file mode 100644 index 0000000000..d23e0c204a --- /dev/null +++ b/packages/core/entities-config-editor/sandbox/fixture/certificates.json @@ -0,0 +1,86 @@ +{ + "fields": [ + { + "id": { + "description": "A string representing a UUID (universally unique identifier).", + "uuid": true, + "type": "string", + "auto": true + } + }, + { + "created_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "updated_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "cert": { + "description": "A string representing a certificate.", + "referenceable": true, + "type": "string", + "required": true + } + }, + { + "key": { + "description": "A string representing a key.", + "referenceable": true, + "type": "string", + "encrypted": true, + "required": true + } + }, + { + "cert_alt": { + "description": "A string representing a certificate.", + "referenceable": true, + "type": "string", + "required": false + } + }, + { + "key_alt": { + "description": "A string representing a key.", + "referenceable": true, + "type": "string", + "encrypted": true, + "required": false + } + }, + { + "tags": { + "description": "A set of strings representing tags.", + "elements": { "description": "A string representing a tag.", "type": "string", "required": true }, + "type": "set" + } + }, + { + "snis": { + "transient": true, + "elements": { + "type": "string", + "description": "A string representing a wildcard host name, such as *.example.com." + }, + "type": "array", + "required": false + } + } + ], + "entity_checks": [ + { "mutually_required": ["cert_alt", "key_alt"] }, + { "custom_entity_check": { "field_sources": ["cert", "key"] } }, + { "custom_entity_check": { "field_sources": ["cert_alt", "key_alt"] } }, + { "custom_entity_check": { "field_sources": ["cert", "cert_alt"] } } + ] +} diff --git a/packages/core/entities-config-editor/sandbox/fixture/consumers.json b/packages/core/entities-config-editor/sandbox/fixture/consumers.json new file mode 100644 index 0000000000..a81cc088f9 --- /dev/null +++ b/packages/core/entities-config-editor/sandbox/fixture/consumers.json @@ -0,0 +1,61 @@ +{ + "fields": [ + { + "id": { + "description": "A string representing a UUID (universally unique identifier).", + "uuid": true, + "type": "string", + "auto": true + } + }, + { + "created_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "updated_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "username": { + "description": "The unique username of the Consumer. You must send at least one of username or custom_id with the request.", + "indexed": true, + "type": "string", + "unique": true + } + }, + { + "custom_id": { + "description": "Stores the existing unique ID of the consumer. You must send at least one of username or custom_id with the request.", + "indexed": true, + "type": "string", + "unique": true + } + }, + { "type": { "indexed": true, "default": 0, "type": "integer", "required": true } }, + { + "tags": { + "description": "A set of strings representing tags.", + "elements": { "description": "A string representing a tag.", "type": "string", "required": true }, + "type": "set" + } + }, + { + "username_lower": { + "db_export": false, + "description": "The lowercase representation of a username", + "type": "string", + "prefix_ws": true + } + } + ], + "entity_checks": [{ "at_least_one_of": ["custom_id", "username"] }] +} diff --git a/packages/core/entities-config-editor/sandbox/fixture/routes.json b/packages/core/entities-config-editor/sandbox/fixture/routes.json new file mode 100644 index 0000000000..234d7b2d09 --- /dev/null +++ b/packages/core/entities-config-editor/sandbox/fixture/routes.json @@ -0,0 +1,288 @@ +{ + "fields": [ + { + "id": { + "description": "A string representing a UUID (universally unique identifier).", + "uuid": true, + "type": "string", + "auto": true + } + }, + { + "created_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "updated_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "name": { + "description": "A unique string representing a UTF-8 encoded name.", + "indexed": true, + "type": "string", + "unique": true + } + }, + { + "protocols": { + "description": "An array of the protocols this Route should allow.", + "elements": { + "description": "A string representing a protocol, such as HTTP or HTTPS.", + "type": "string", + "one_of": ["grpc", "grpcs", "http", "https", "tcp", "tls", "tls_passthrough", "udp", "ws", "wss"] + }, + "type": "set", + "mutually_exclusive_subsets": [ + ["http", "https"], + ["tcp", "tls", "udp"], + ["tls_passthrough"], + ["grpc", "grpcs"], + ["ws", "wss"] + ], + "len_min": 1, + "indexed": true, + "default": ["http", "https"], + "required": true + } + }, + { + "https_redirect_status_code": { + "description": "The status code Kong responds with when all properties of a Route match except the protocol", + "default": 426, + "type": "integer", + "one_of": [426, 301, 302, 307, 308], + "required": true + } + }, + { + "strip_path": { + "description": "When matching a Route via one of the paths, strip the matching prefix from the upstream request URL.", + "default": true, + "type": "boolean", + "required": true + } + }, + { + "preserve_host": { + "description": "When matching a Route via one of the hosts domain names, use the request Host header in the upstream request headers.", + "default": false, + "type": "boolean", + "required": true + } + }, + { + "request_buffering": { + "description": "Whether to enable request body buffering or not. With HTTP 1.1.", + "default": true, + "type": "boolean", + "required": true + } + }, + { + "response_buffering": { + "description": "Whether to enable response body buffering or not.", + "default": true, + "type": "boolean", + "required": true + } + }, + { + "tags": { + "description": "A set of strings representing tags.", + "elements": { "description": "A string representing a tag.", "type": "string", "required": true }, + "type": "set" + } + }, + { + "service": { + "description": "The Service this Route is associated to. This is where the Route proxies traffic to.", + "type": "foreign", + "reference": "services" + } + }, + { + "snis": { + "description": "A list of SNIs that match this Route.", + "elements": { + "type": "string", + "description": "A string representing a wildcard host name, such as *.example.com." + }, + "type": "set" + } + }, + { + "sources": { + "description": "A set of sources, each of which is a record with at least one of 'ip' or 'port'.", + "elements": { + "fields": [ + { + "ip": { + "type": "string", + "description": "A string representing an IP address or CIDR block, such as 192.168.1.1 or 192.168.0.0/16." + } + }, + { + "port": { + "description": "An integer representing a port number between 0 and 65535, inclusive.", + "type": "integer", + "between": [0, 65535] + } + } + ], + "type": "record", + "entity_checks": [{ "at_least_one_of": ["ip", "port"] }] + }, + "type": "set" + } + }, + { + "destinations": { + "description": "A set of destinations, each of which is a record with at least one of 'ip' or 'port'.", + "elements": { + "fields": [ + { + "ip": { + "type": "string", + "description": "A string representing an IP address or CIDR block, such as 192.168.1.1 or 192.168.0.0/16." + } + }, + { + "port": { + "description": "An integer representing a port number between 0 and 65535, inclusive.", + "type": "integer", + "between": [0, 65535] + } + } + ], + "type": "record", + "entity_checks": [{ "at_least_one_of": ["ip", "port"] }] + }, + "type": "set" + } + }, + { + "methods": { + "description": "A set of strings representing HTTP methods. Each method must be a valid HTTP method.", + "indexed": true, + "type": "set", + "elements": { + "description": "A string representing an HTTP method, such as GET, POST, PUT, or DELETE. The string must contain only uppercase letters.", + "match": "^%u+$", + "type": "string" + } + } + }, + { + "hosts": { + "description": "An array of strings representing hosts. A valid host is a string containing one or more labels separated by periods, with at most one wildcard label ('*')", + "indexed": true, + "type": "array", + "elements": { + "match_any": { + "err": "invalid wildcard: must be placed at leftmost or rightmost label", + "patterns": ["^%*%.", "%.%*$", "^[^*]*$"] + }, + "type": "string", + "match_all": [{ "err": "invalid wildcard: must have at most one wildcard", "pattern": "^[^*]*%*?[^*]*$" }] + } + } + }, + { + "paths": { + "description": "An array of strings representing router paths.", + "indexed": true, + "type": "array", + "elements": { + "match_none": [{ "err": "must not have empty segments", "pattern": "//" }], + "match_any": { "err": "should start with: / (fixed path) or ~/ (regex path)", "patterns": ["^/", "^~/"] }, + "type": "string", + "description": "A string representing a router path. It must start with a forward slash ('/') for a fixed path, or the sequence '~/' for a regex path. It must not have empty segments." + } + } + }, + { + "headers": { + "description": "A map of header names to arrays of header values.", + "values": { "type": "array", "elements": { "type": "string" } }, + "type": "map", + "keys": { + "match_none": [ + { + "err": "cannot contain 'host' header, which must be specified in the 'hosts' attribute", + "pattern": "^[Hh][Oo][Ss][Tt]$" + } + ], + "type": "string", + "description": "A string representing an HTTP header name." + } + } + }, + { + "regex_priority": { + "description": "A number used to choose which route resolves a given request when several routes match it using regexes simultaneously.", + "default": 0, + "type": "integer" + } + }, + { + "path_handling": { + "description": "Controls how the Service path, Route path and requested path are combined when sending a request to the upstream.", + "default": "v0", + "type": "string", + "one_of": ["v0", "v1"] + } + }, + { "expression": { "description": "The route expression.", "type": "string" } }, + { + "priority": { + "description": "A number used to specify the matching order for expression routes. The higher the `priority`, the sooner an route will be evaluated. This field is ignored unless `expression` field is set.", + "default": 0, + "type": "integer", + "between": [0, 70368744177663], + "required": true + } + } + ], + "entity_checks": [ + { + "conditional": { + "then_field": "snis", + "if_field": "protocols", + "then_match": { "len_eq": 0 }, + "then_err": "'snis' can only be set when 'protocols' is 'grpcs', 'https', 'tls', 'tls_passthrough', or 'wss'", + "if_match": { + "elements": { "type": "string", "not_one_of": ["grpcs", "https", "tls", "tls_passthrough", "wss"] } + } + } + }, + { "custom_entity_check": { "field_sources": ["path_handling"] } }, + { + "custom_entity_check": { + "field_sources": [ + "id", + "protocols", + "snis", + "sources", + "destinations", + "methods", + "hosts", + "paths", + "headers", + "expression", + "regex_priority", + "priority" + ], + "run_with_missing_fields": true + } + } + ] +} diff --git a/packages/core/entities-config-editor/sandbox/fixture/services.json b/packages/core/entities-config-editor/sandbox/fixture/services.json new file mode 100644 index 0000000000..28df27de8f --- /dev/null +++ b/packages/core/entities-config-editor/sandbox/fixture/services.json @@ -0,0 +1,171 @@ +{ + "fields": [ + { + "id": { + "description": "A string representing a UUID (universally unique identifier).", + "type": "string", + "auto": true, + "len_min": 1, + "uuid": true + } + }, + { + "created_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "updated_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "name": { + "description": "A unique string representing a UTF-8 encoded name.", + "indexed": true, + "type": "string", + "unique": true + } + }, + { + "retries": { + "description": "The number of retries to execute upon failure to proxy.", + "default": 5, + "type": "integer", + "between": [0, 32767] + } + }, + { + "protocol": { + "description": "A string representing a protocol, such as HTTP or HTTPS.", + "default": "http", + "type": "string", + "indexed": true, + "required": true, + "one_of": ["grpc", "grpcs", "http", "https", "tcp", "tls", "tls_passthrough", "udp", "ws", "wss"] + } + }, + { + "host": { + "description": "A string representing a host name, such as example.com.", + "indexed": true, + "type": "string", + "required": true + } + }, + { + "port": { + "description": "An integer representing a port number between 0 and 65535, inclusive.", + "default": 80, + "type": "integer", + "indexed": true, + "between": [0, 65535], + "required": true + } + }, + { + "path": { + "description": "A string representing a URL path, such as /path/to/resource. Must start with a forward slash (/) and must not contain empty segments (i.e., two consecutive forward slashes).", + "type": "string", + "match_none": [{ "err": "must not have empty segments", "pattern": "//" }], + "indexed": true, + "starts_with": "/" + } + }, + { "connect_timeout": { "default": 60000, "type": "integer", "between": [1, 2147483646] } }, + { "write_timeout": { "default": 60000, "type": "integer", "between": [1, 2147483646] } }, + { "read_timeout": { "default": 60000, "type": "integer", "between": [1, 2147483646] } }, + { + "tags": { + "description": "A set of strings representing tags.", + "elements": { "description": "A string representing a tag.", "type": "string", "required": true }, + "type": "set" + } + }, + { + "client_certificate": { + "description": "Certificate to be used as client certificate while TLS handshaking to the upstream server.", + "type": "foreign", + "reference": "certificates" + } + }, + { + "tls_verify": { + "description": "Whether to enable verification of upstream server TLS certificate. If not set, the global level config `proxy_ssl_verify` will be used.", + "type": "boolean" + } + }, + { + "tls_verify_depth": { + "description": "Maximum depth of chain while verifying Upstream server's TLS certificate.", + "default": null, + "type": "integer", + "between": [0, 64] + } + }, + { + "ca_certificates": { + "description": "Array of CA Certificate object UUIDs that are used to build the trust store while verifying upstream server's TLS certificate.", + "elements": { "type": "string", "uuid": true }, + "type": "array" + } + }, + { + "enabled": { + "description": "Whether the Service is active. ", + "default": true, + "type": "boolean", + "indexed": true, + "required": true + } + } + ], + "entity_checks": [ + { + "conditional": { + "if_match": { "one_of": ["tcp", "tls", "udp", "grpc", "grpcs"] }, + "if_field": "protocol", + "then_match": { "eq": null }, + "then_field": "path" + } + }, + { + "conditional": { + "if_match": { "not_one_of": ["https", "wss", "tls"] }, + "if_field": "protocol", + "then_match": { "eq": null }, + "then_field": "client_certificate" + } + }, + { + "conditional": { + "if_match": { "not_one_of": ["https", "wss", "tls"] }, + "if_field": "protocol", + "then_match": { "eq": null }, + "then_field": "tls_verify" + } + }, + { + "conditional": { + "if_match": { "not_one_of": ["https", "tls"] }, + "if_field": "protocol", + "then_match": { "eq": null }, + "then_field": "tls_verify_depth" + } + }, + { + "conditional": { + "if_match": { "not_one_of": ["https", "tls"] }, + "if_field": "protocol", + "then_match": { "eq": null }, + "then_field": "ca_certificates" + } + } + ] +} diff --git a/packages/core/entities-config-editor/sandbox/fixture/snis.json b/packages/core/entities-config-editor/sandbox/fixture/snis.json new file mode 100644 index 0000000000..01dc9e0321 --- /dev/null +++ b/packages/core/entities-config-editor/sandbox/fixture/snis.json @@ -0,0 +1,54 @@ +{ + "fields": [ + { + "id": { + "description": "A string representing a UUID (universally unique identifier).", + "uuid": true, + "type": "string", + "auto": true + } + }, + { + "name": { + "description": "A string representing a wildcard host name, such as *.example.com.", + "type": "string", + "unique": true, + "indexed": true, + "unique_across_ws": true, + "required": true + } + }, + { + "created_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "updated_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "tags": { + "description": "A set of strings representing tags.", + "elements": { "description": "A string representing a tag.", "type": "string", "required": true }, + "type": "set" + } + }, + { + "certificate": { + "description": "The id (a UUID) of the certificate with which to associate the SNI hostname.", + "type": "foreign", + "required": true, + "reference": "certificates" + } + } + ], + "entity_checks": [] +} diff --git a/packages/core/entities-config-editor/sandbox/fixture/targets.json b/packages/core/entities-config-editor/sandbox/fixture/targets.json new file mode 100644 index 0000000000..72f49f4046 --- /dev/null +++ b/packages/core/entities-config-editor/sandbox/fixture/targets.json @@ -0,0 +1,56 @@ +{ + "fields": [ + { + "id": { + "description": "A string representing a UUID (universally unique identifier).", + "uuid": true, + "type": "string", + "auto": true + } + }, + { + "created_at": { + "description": "A number representing an automatic Unix timestamp in milliseconds.", + "timestamp": true, + "type": "number", + "auto": true + } + }, + { + "updated_at": { + "description": "A number representing an automatic Unix timestamp in milliseconds.", + "timestamp": true, + "type": "number", + "auto": true + } + }, + { + "upstream": { + "description": "The unique identifier or the name of the upstream for which to update the target.", + "reference": "upstreams", + "required": true, + "type": "foreign", + "on_delete": "cascade" + } + }, + { + "target": { "description": "The target address (ip or hostname) and port.", "type": "string", "required": true } + }, + { + "weight": { + "description": "The weight this target gets within the upstream loadbalancer (0-65535).", + "default": 100, + "type": "integer", + "between": [0, 65535] + } + }, + { + "tags": { + "description": "A set of strings representing tags.", + "elements": { "description": "A string representing a tag.", "type": "string", "required": true }, + "type": "set" + } + } + ], + "entity_checks": [] +} diff --git a/packages/core/entities-config-editor/sandbox/fixture/upstreams.json b/packages/core/entities-config-editor/sandbox/fixture/upstreams.json new file mode 100644 index 0000000000..e26a745336 --- /dev/null +++ b/packages/core/entities-config-editor/sandbox/fixture/upstreams.json @@ -0,0 +1,424 @@ +{ + "fields": [ + { + "id": { + "description": "A string representing a UUID (universally unique identifier).", + "uuid": true, + "type": "string", + "auto": true + } + }, + { + "created_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "name": { + "description": "This is a hostname, which must be equal to the host of a Service.", + "type": "string", + "unique": true, + "indexed": true, + "required": true + } + }, + { + "updated_at": { + "description": "An integer representing an automatic Unix timestamp in seconds.", + "timestamp": true, + "type": "integer", + "auto": true + } + }, + { + "algorithm": { + "description": "Which load balancing algorithm to use.", + "default": "round-robin", + "type": "string", + "one_of": ["consistent-hashing", "least-connections", "round-robin", "latency"] + } + }, + { + "hash_on": { + "default": "none", + "type": "string", + "one_of": ["none", "consumer", "ip", "header", "cookie", "path", "query_arg", "uri_capture"] + } + }, + { + "hash_fallback": { + "default": "none", + "type": "string", + "one_of": ["none", "consumer", "ip", "header", "cookie", "path", "query_arg", "uri_capture"] + } + }, + { "hash_on_header": { "type": "string", "description": "A string representing an HTTP header name." } }, + { "hash_fallback_header": { "type": "string", "description": "A string representing an HTTP header name." } }, + { "hash_on_cookie": { "description": "The cookie name to take the value from as hash input.", "type": "string" } }, + { + "hash_on_cookie_path": { + "description": "A string representing a URL path, such as /path/to/resource. Must start with a forward slash (/) and must not contain empty segments (i.e., two consecutive forward slashes).", + "default": "/", + "type": "string", + "match_none": [{ "err": "must not have empty segments", "pattern": "//" }], + "starts_with": "/" + } + }, + { "hash_on_query_arg": { "type": "string", "len_min": 1 } }, + { "hash_fallback_query_arg": { "type": "string", "len_min": 1 } }, + { "hash_on_uri_capture": { "type": "string", "len_min": 1 } }, + { "hash_fallback_uri_capture": { "type": "string", "len_min": 1 } }, + { + "slots": { + "description": "The number of slots in the load balancer algorithm.", + "default": 10000, + "type": "integer", + "between": [10, 65536] + } + }, + { + "healthchecks": { + "description": "The array of healthchecks.", + "default": { + "active": { + "unhealthy": { + "timeouts": 0, + "http_failures": 0, + "http_statuses": [429, 404, 500, 501, 502, 503, 504, 505], + "interval": 0, + "tcp_failures": 0 + }, + "timeout": 1, + "healthy": { "http_statuses": [200, 302], "interval": 0, "successes": 0 }, + "type": "http", + "http_path": "/", + "concurrency": 10, + "https_verify_certificate": true + }, + "passive": { + "type": "http", + "healthy": { + "http_statuses": [ + 200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308 + ], + "successes": 0 + }, + "unhealthy": { "http_statuses": [429, 500, 503], "http_failures": 0, "timeouts": 0, "tcp_failures": 0 } + } + }, + "type": "record", + "fields": [ + { + "active": { + "fields": [ + { + "type": { "default": "http", "type": "string", "one_of": ["tcp", "http", "https", "grpc", "grpcs"] } + }, + { + "http_path": { + "description": "A string representing a URL path, such as /path/to/resource. Must start with a forward slash (/) and must not contain empty segments (i.e., two consecutive forward slashes).", + "default": "/", + "type": "string", + "match_none": [{ "err": "must not have empty segments", "pattern": "//" }], + "starts_with": "/" + } + }, + { + "https_sni": { + "description": "A string representing an SNI (server name indication) value for TLS.", + "type": "string", + "required": false + } + }, + { "https_verify_certificate": { "default": true, "type": "boolean", "required": true } }, + { + "headers": { + "description": "A map of header names to arrays of header values.", + "values": { "type": "array", "elements": { "type": "string" } }, + "type": "map", + "required": false, + "keys": { "type": "string", "description": "A string representing an HTTP header name." } + } + }, + { "timeout": { "default": 1, "type": "number", "between": [0, 65535] } }, + { "concurrency": { "default": 10, "type": "integer", "between": [1, 2147483648] } }, + { + "healthy": { + "fields": [ + { + "http_statuses": { + "default": [200, 302], + "type": "array", + "elements": { "type": "integer", "between": [100, 999] } + } + }, + { "interval": { "default": 0, "type": "number", "between": [0, 65535] } }, + { "successes": { "default": 0, "type": "integer", "between": [0, 255] } } + ], + "default": { "http_statuses": [200, 302], "interval": 0, "successes": 0 }, + "type": "record", + "required": true + } + }, + { + "unhealthy": { + "fields": [ + { "timeouts": { "default": 0, "type": "integer", "between": [0, 255] } }, + { "http_failures": { "default": 0, "type": "integer", "between": [0, 255] } }, + { + "http_statuses": { + "default": [429, 404, 500, 501, 502, 503, 504, 505], + "type": "array", + "elements": { "type": "integer", "between": [100, 999] } + } + }, + { "interval": { "default": 0, "type": "number", "between": [0, 65535] } }, + { "tcp_failures": { "default": 0, "type": "integer", "between": [0, 255] } } + ], + "default": { + "timeouts": 0, + "http_failures": 0, + "http_statuses": [429, 404, 500, 501, 502, 503, 504, 505], + "interval": 0, + "tcp_failures": 0 + }, + "type": "record", + "required": true + } + } + ], + "default": { + "unhealthy": { + "timeouts": 0, + "http_failures": 0, + "http_statuses": [429, 404, 500, 501, 502, 503, 504, 505], + "interval": 0, + "tcp_failures": 0 + }, + "timeout": 1, + "healthy": { "http_statuses": [200, 302], "interval": 0, "successes": 0 }, + "type": "http", + "http_path": "/", + "concurrency": 10, + "https_verify_certificate": true + }, + "type": "record", + "required": true + } + }, + { + "passive": { + "fields": [ + { + "healthy": { + "fields": [ + { + "http_statuses": { + "default": [ + 200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, + 308 + ], + "type": "array", + "elements": { "type": "integer", "between": [100, 999] } + } + }, + { "successes": { "default": 0, "type": "integer", "between": [0, 255] } } + ], + "default": { + "http_statuses": [ + 200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308 + ], + "successes": 0 + }, + "type": "record", + "required": true + } + }, + { + "type": { "default": "http", "type": "string", "one_of": ["tcp", "http", "https", "grpc", "grpcs"] } + }, + { + "unhealthy": { + "fields": [ + { + "http_statuses": { + "default": [429, 500, 503], + "type": "array", + "elements": { "type": "integer", "between": [100, 999] } + } + }, + { "http_failures": { "default": 0, "type": "integer", "between": [0, 255] } }, + { "timeouts": { "default": 0, "type": "integer", "between": [0, 255] } }, + { "tcp_failures": { "default": 0, "type": "integer", "between": [0, 255] } } + ], + "default": { + "http_statuses": [429, 500, 503], + "http_failures": 0, + "timeouts": 0, + "tcp_failures": 0 + }, + "type": "record", + "required": true + } + } + ], + "default": { + "type": "http", + "healthy": { + "http_statuses": [ + 200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, 302, 303, 304, 305, 306, 307, 308 + ], + "successes": 0 + }, + "unhealthy": { "http_statuses": [429, 500, 503], "http_failures": 0, "timeouts": 0, "tcp_failures": 0 } + }, + "type": "record", + "required": true + } + }, + { "threshold": { "default": 0, "type": "number", "between": [0, 100] } } + ], + "required": true + } + }, + { + "tags": { + "description": "A set of strings representing tags.", + "elements": { "description": "A string representing a tag.", "type": "string", "required": true }, + "type": "set" + } + }, + { + "host_header": { + "type": "string", + "description": "A string representing a host name with an optional port number, such as example.com or example.com:8080." + } + }, + { + "client_certificate": { + "description": "If set, the certificate to be used as client certificate while TLS handshaking to the upstream server.", + "type": "foreign", + "reference": "certificates" + } + }, + { + "use_srv_name": { + "description": "If set, the balancer will use SRV hostname.", + "default": false, + "type": "boolean" + } + } + ], + "entity_checks": [ + { + "conditional": { + "if_match": { "match": "^header$" }, + "if_field": "hash_on", + "then_match": { "required": true }, + "then_field": "hash_on_header" + } + }, + { + "conditional": { + "if_match": { "match": "^header$" }, + "if_field": "hash_fallback", + "then_match": { "required": true }, + "then_field": "hash_fallback_header" + } + }, + { + "conditional": { + "if_match": { "match": "^cookie$" }, + "if_field": "hash_on", + "then_match": { "required": true }, + "then_field": "hash_on_cookie" + } + }, + { + "conditional": { + "if_match": { "match": "^cookie$" }, + "if_field": "hash_fallback", + "then_match": { "required": true }, + "then_field": "hash_on_cookie" + } + }, + { + "conditional": { + "if_match": { "match": "^none$" }, + "if_field": "hash_on", + "then_match": { "one_of": ["none"] }, + "then_field": "hash_fallback" + } + }, + { + "conditional": { + "if_match": { "match": "^cookie$" }, + "if_field": "hash_on", + "then_match": { "one_of": ["none"] }, + "then_field": "hash_fallback" + } + }, + { + "conditional": { + "if_match": { "match": "^consumer$" }, + "if_field": "hash_on", + "then_match": { "one_of": ["none", "ip", "header", "cookie", "path", "query_arg", "uri_capture"] }, + "then_field": "hash_fallback" + } + }, + { + "conditional": { + "if_match": { "match": "^ip$" }, + "if_field": "hash_on", + "then_match": { "one_of": ["none", "consumer", "header", "cookie", "path", "query_arg", "uri_capture"] }, + "then_field": "hash_fallback" + } + }, + { + "conditional": { + "if_match": { "match": "^path$" }, + "if_field": "hash_on", + "then_match": { "one_of": ["none", "consumer", "header", "cookie", "query_arg", "ip", "uri_capture"] }, + "then_field": "hash_fallback" + } + }, + { "distinct": ["hash_on_header", "hash_fallback_header"] }, + { + "conditional": { + "if_match": { "match": "^query_arg$" }, + "if_field": "hash_on", + "then_match": { "required": true }, + "then_field": "hash_on_query_arg" + } + }, + { + "conditional": { + "if_match": { "match": "^query_arg$" }, + "if_field": "hash_fallback", + "then_match": { "required": true }, + "then_field": "hash_fallback_query_arg" + } + }, + { "distinct": ["hash_on_query_arg", "hash_fallback_query_arg"] }, + { + "conditional": { + "if_match": { "match": "^uri_capture$" }, + "if_field": "hash_on", + "then_match": { "required": true }, + "then_field": "hash_on_uri_capture" + } + }, + { + "conditional": { + "if_match": { "match": "^uri_capture$" }, + "if_field": "hash_fallback", + "then_match": { "required": true }, + "then_field": "hash_fallback_uri_capture" + } + }, + { "distinct": ["hash_on_uri_capture", "hash_fallback_uri_capture"] } + ] +} diff --git a/packages/core/entities-config-editor/sandbox/index.ts b/packages/core/entities-config-editor/sandbox/index.ts index 52668a0a54..5820d7f19c 100644 --- a/packages/core/entities-config-editor/sandbox/index.ts +++ b/packages/core/entities-config-editor/sandbox/index.ts @@ -1,6 +1,11 @@ +import Kongponents from '@kong/kongponents' import { createApp } from 'vue' import App from './App.vue' +import '@kong/kongponents/dist/style.css' + const app = createApp(App) +app.use(Kongponents) + app.mount('#app') diff --git a/packages/core/entities-config-editor/src/components/EntitiesConfigEditor.vue b/packages/core/entities-config-editor/src/components/EntitiesConfigEditor.vue index 58ef7b962b..00723e6f2c 100644 --- a/packages/core/entities-config-editor/src/components/EntitiesConfigEditor.vue +++ b/packages/core/entities-config-editor/src/components/EntitiesConfigEditor.vue @@ -1,16 +1,591 @@ diff --git a/packages/core/entities-config-editor/src/composables/index.ts b/packages/core/entities-config-editor/src/composables/index.ts new file mode 100644 index 0000000000..28019a0c21 --- /dev/null +++ b/packages/core/entities-config-editor/src/composables/index.ts @@ -0,0 +1,3 @@ +import useMonacoEditor from './useMonacoEditor' + +export default { useMonacoEditor } diff --git a/packages/core/entities-config-editor/src/composables/useMonacoEditor.ts b/packages/core/entities-config-editor/src/composables/useMonacoEditor.ts new file mode 100644 index 0000000000..1b04a3bd34 --- /dev/null +++ b/packages/core/entities-config-editor/src/composables/useMonacoEditor.ts @@ -0,0 +1,88 @@ +import { cloneDeep } from 'lodash-es' +import type * as Monaco from 'monaco-editor' +import { v4 as uuid } from 'uuid' +import { + getLanguageService as getJSONLanguageService, + TextDocument, + type JSONDocument, +} from 'vscode-json-languageservice' +import { computed, shallowRef, unref, type MaybeRef } from 'vue' +import { type EditorLanguage, type LuaSchema } from '../types' +import { buildRecordSchema } from '../utils' + +declare global { + interface Window { + require: any + } +} + +const jsonLanguageService = getJSONLanguageService({}) + +export default function useMonacoEditor( + monaco: typeof Monaco, + language: MaybeRef, + documentId: MaybeRef, + luaSchema: MaybeRef, +) { + const uri = computed(() => { + const documentName = unref(documentId) || `unsaved_${uuid()}` + return monaco.Uri.parse(`kong:///editor/entities/${documentName}.${unref(language)}`) + }) + + const schema = computed(() => { + const _luaSchema = unref(luaSchema) + if (!_luaSchema) { + return undefined + } + + const rootSchema = cloneDeep(buildRecordSchema(_luaSchema, unref(language))) + rootSchema.properties = { + ...rootSchema.properties, + } + return rootSchema + }) + + const model = computed(() => { + const _language = unref(language) + const existingModel = monaco.editor.getModel(uri.value) + if (existingModel) { + existingModel.dispose() + } + return monaco.editor.createModel(_language === 'json' ? '{}' : '', _language, uri.value) + }) + + const textDocument = computed(() =>{ + const _language = unref(language) + return TextDocument.create(uri.value.toString(), _language, 0, _language === 'json' ? '{}' : '') + }) + + const languageSpecificDocument = shallowRef(jsonLanguageService.parseJSONDocument(textDocument.value)) + + const onDidChangeModelContent = (e: Monaco.editor.IModelContentChangedEvent) => { + TextDocument.update( + textDocument.value, + e.changes.map((change) => { + return { + range: { + start: textDocument.value.positionAt(change.rangeOffset), + end: textDocument.value.positionAt(change.rangeOffset + change.rangeLength), + }, + rangeLength: change.rangeLength, + text: change.text, + } + }), + textDocument.value.version + 1, + ) + + languageSpecificDocument.value = jsonLanguageService.parseJSONDocument(textDocument.value) + } + + return { + model, + schema, + uri, + textDocument, + languageSpecificDocument, + onDidChangeModelContent, + } +} diff --git a/packages/core/entities-config-editor/src/types/editor.ts b/packages/core/entities-config-editor/src/types/editor.ts new file mode 100644 index 0000000000..3f7d6bb7a6 --- /dev/null +++ b/packages/core/entities-config-editor/src/types/editor.ts @@ -0,0 +1,10 @@ +export const EDITOR_LANGUAGES = { + JSON: 'json', +} as const + +export type EditorLanguage = typeof EDITOR_LANGUAGES[keyof typeof EDITOR_LANGUAGES] + +export interface EditingEntity { + kind: string + identifier?: string +} diff --git a/packages/core/entities-config-editor/src/types/fields.ts b/packages/core/entities-config-editor/src/types/fields.ts new file mode 100644 index 0000000000..48833ea5f4 --- /dev/null +++ b/packages/core/entities-config-editor/src/types/fields.ts @@ -0,0 +1,143 @@ +export const FIELD_TYPES = { + STRING: 'string', // Input, Textarea, Select + NUMBER: 'number', // Input + INTEGER: 'integer', // Input + BOOLEAN: 'boolean', // Checkbox + FOREIGN: 'foreign', + ARRAY: 'array', // Array + SET: 'set', // Array + MAP: 'map', // Map + RECORD: 'record', // Record + FUNCTION: 'function', // not used + JSON: 'json', // not used +} as const + +export type FieldType = typeof FIELD_TYPES[keyof typeof FIELD_TYPES] + +export interface FieldProps { + name?: string + schema: S + value?: V + + /** DEBUGGING: An stack that contains the information of the parent fields */ + parentStack?: string[] +} + +export interface FieldEmits { + (e: 'update-value', value: V): void +} + +export type AtLeastOneOfEntityCheck = { at_least_one_of: string[] } + +export type EntityCheck = AtLeastOneOfEntityCheck + +export const isAtLeastOneOfEntityCheck = (check: EntityCheck): check is AtLeastOneOfEntityCheck => + Object.prototype.hasOwnProperty.call(check, 'at_least_one_of') + +export interface FieldSchema { + type: FieldType + required?: boolean + default?: any + description?: string + referenceable?: boolean + + one_of?: any[] + + help?: string + + entity_checks?: EntityCheck[] +} + +export type NamedFieldSchema = { [name: string]: FieldSchema } + +export interface StringFieldSchema extends FieldSchema { + type: typeof FIELD_TYPES.STRING + + one_of?: string[] + + len_eq?: number + len_min?: number + len_max?: number + + match?: string + + match_none?: { + pattern: string + err: string + }[] + + match_all?: { + pattern: string + err: string + }[] + + match_any?: { + patterns: string[] + err: string + } + + pattern?: string +} + +export const isStringField = (schema: FieldSchema): schema is StringFieldSchema => + schema.type === FIELD_TYPES.STRING + +export interface NumberLikeFieldSchema extends FieldSchema { + type: typeof FIELD_TYPES.NUMBER | typeof FIELD_TYPES.INTEGER + + one_of?: number[] + + between?: [min: number, max: number] + gt?: number +} + +export const isNumberLikeField = (schema: FieldSchema): schema is NumberLikeFieldSchema => + schema.type === FIELD_TYPES.NUMBER || schema.type === FIELD_TYPES.INTEGER + +export interface BooleanFieldSchema extends FieldSchema { + type: typeof FIELD_TYPES.BOOLEAN + + one_of?: boolean[] +} + +export const isBooleanField = (schema: FieldSchema): schema is BooleanFieldSchema => + schema.type === FIELD_TYPES.BOOLEAN + +export interface ArrayLikeFieldSchema extends FieldSchema { + type: typeof FIELD_TYPES.ARRAY | typeof FIELD_TYPES.SET + + elements: FieldSchema + len_min?: number + len_max?: number +} + +export const isArrayLikeField = (schema: FieldSchema): schema is ArrayLikeFieldSchema => + schema.type === FIELD_TYPES.ARRAY || schema.type === FIELD_TYPES.SET + +export interface MapFieldSchema extends FieldSchema { + type: typeof FIELD_TYPES.MAP + + keys: FieldSchema + values: FieldSchema + len_min?: number + len_max?: number +} + +export const isMapField = (schema: FieldSchema): schema is MapFieldSchema => + schema.type === FIELD_TYPES.MAP + +export interface RecordFieldSchema extends FieldSchema { + type: typeof FIELD_TYPES.RECORD + + fields: NamedFieldSchema[] +} + +export const isRecordField = (schema: FieldSchema): schema is RecordFieldSchema => + schema.type === FIELD_TYPES.RECORD + +export interface FormSchema extends Record { + fields: NamedFieldSchema[] +} + +/** Lua schema is a subset of RecordFieldSchema */ +export type LuaSchema = RecordFieldSchema diff --git a/packages/core/entities-config-editor/src/types/foreign.ts b/packages/core/entities-config-editor/src/types/foreign.ts new file mode 100644 index 0000000000..2babfa091b --- /dev/null +++ b/packages/core/entities-config-editor/src/types/foreign.ts @@ -0,0 +1,8 @@ +export interface ForeignCompletionItem { + id: string + label: string +} + +export type ForeignCompletionFetcher = (params: any) => Promise + +export type ForeignCompletionFetchers = Record diff --git a/packages/core/entities-config-editor/src/types/index.ts b/packages/core/entities-config-editor/src/types/index.ts index 944fdabc20..94f314f680 100644 --- a/packages/core/entities-config-editor/src/types/index.ts +++ b/packages/core/entities-config-editor/src/types/index.ts @@ -1,7 +1,3 @@ -// Export all types and interfaces from this index.ts -// The actual types and interfaces should be contained in separate files within this folder. - -// Example: -// export * from './component-types' - -export {} +export * from './editor' +export * from './fields' +export * from './jsonschema' diff --git a/packages/core/entities-config-editor/src/types/jsonschema.ts b/packages/core/entities-config-editor/src/types/jsonschema.ts new file mode 100644 index 0000000000..d1075c56c9 --- /dev/null +++ b/packages/core/entities-config-editor/src/types/jsonschema.ts @@ -0,0 +1,12 @@ +import type { JSONSchema } from 'vscode-json-languageservice' + +import type { FieldSchema } from './fields' + +export interface ExtendedJSONSchema extends JSONSchema { + detail?: string; + /** + * Reference back to Kong's field schema. + */ + _fieldSchema?: FieldSchema; +} + diff --git a/packages/core/entities-config-editor/src/utils/builders.ts b/packages/core/entities-config-editor/src/utils/builders.ts new file mode 100644 index 0000000000..2dc4fcfe92 --- /dev/null +++ b/packages/core/entities-config-editor/src/utils/builders.ts @@ -0,0 +1,268 @@ +import type { JSONSchema } from 'vscode-json-languageservice' +import { + isArrayLikeField, + isBooleanField, + isMapField, + isNumberLikeField, + isRecordField, + isStringField, + type ArrayLikeFieldSchema, + type BooleanFieldSchema, + type EditorLanguage, + type ExtendedJSONSchema, + type FieldSchema, + type MapFieldSchema, + type NumberLikeFieldSchema, + type RecordFieldSchema, + type StringFieldSchema, +} from '../types' +import { toRegExpPattern } from './strings' + +export const buildStringSchema = (fieldSchema: StringFieldSchema): ExtendedJSONSchema => { + const schema: ExtendedJSONSchema = { + type: 'string', + } + + if (fieldSchema.len_eq !== undefined) { + schema.minLength = fieldSchema.len_eq + schema.maxLength = fieldSchema.len_eq + } else { + schema.minLength = fieldSchema.len_min + schema.maxLength = fieldSchema.len_max + } + + if (fieldSchema.one_of !== undefined) { + schema.enum = fieldSchema.one_of + } + + const allOf: ExtendedJSONSchema['allOf'] = [] + + if (fieldSchema.match !== undefined) { + allOf.push({ + pattern: toRegExpPattern(fieldSchema.match), + $comment: 'source: match', + }) + } + + if (fieldSchema.match_none) { + allOf.push( + ...fieldSchema.match_none.map((rule) => { + const pattern = toRegExpPattern(rule.pattern) + return { + errorMessage: rule.err, + not: { + pattern, + }, + $comment: 'source: match_none', + } + }), + ) + } + + if (fieldSchema.match_any) { + allOf.push({ + errorMessage: fieldSchema.match_any.err, + anyOf: fieldSchema.match_any.patterns.map((pattern) => ({ + pattern: toRegExpPattern(pattern), + })), + $comment: 'source: match_any', + }) + } + + if (fieldSchema.match_all) { + allOf.push( + ...fieldSchema.match_all.map((rule) => { + const pattern = toRegExpPattern(rule.pattern) + return { + errorMessage: rule.err, + pattern, + $comment: 'source: match_all', + } + }), + ) + } + + if (allOf.length > 0) { + schema.allOf = allOf + } + + return schema +} + +export const buildNumberLikeSchema = (fieldSchema: NumberLikeFieldSchema): ExtendedJSONSchema => { + const schema: ExtendedJSONSchema = { + type: 'number', + } + + schema.minimum = fieldSchema.between?.[0] + schema.maximum = fieldSchema.between?.[1] + + if (typeof fieldSchema.gt === 'number' && !Number.isNaN(fieldSchema.gt)) { + if (typeof schema.minimum !== 'number' || fieldSchema.gt > schema.minimum) { + schema.minimum = fieldSchema.gt + } + } + + if (fieldSchema.one_of !== undefined) { + schema.enum = fieldSchema.one_of + } + + return schema +} + +export const buildBooleanSchema = (fieldSchema: BooleanFieldSchema): ExtendedJSONSchema => { + return { + type: 'boolean', + } +} + +export const buildArrayLikeSchema = ( + name: string, + fieldSchema: ArrayLikeFieldSchema, + languageHint: EditorLanguage, +): ExtendedJSONSchema => { + const schema: ExtendedJSONSchema = { + type: 'array', // JSON schema does not have a specific type for "set" + items: buildAnySchema(name, fieldSchema.elements, languageHint), + } + + if (typeof fieldSchema.len_min === 'number' && !Number.isNaN(fieldSchema.len_min)) { + schema.minItems = fieldSchema.len_min + } + if (typeof fieldSchema.len_max === 'number' && !Number.isNaN(fieldSchema.len_max)) { + schema.maxItems = fieldSchema.len_max + } + + return schema +} + +export const buildMapSchema = (fieldSchema: MapFieldSchema, languageHint: EditorLanguage): ExtendedJSONSchema => { + const schema: ExtendedJSONSchema = { + type: 'object', + additionalProperties: true, + } + + const patternProperties: JSONSchema['patternProperties'] = {} + if (isStringField(fieldSchema.keys) && fieldSchema.keys.match_none) { + for (const rule of fieldSchema.keys.match_none) { + patternProperties[toRegExpPattern(rule.pattern)] = { not: {} } + } + } + patternProperties['.*'] = buildAnySchema('value', fieldSchema.values, languageHint) + schema.patternProperties = patternProperties + + if (typeof fieldSchema.len_min === 'number' && !Number.isNaN(fieldSchema.len_min)) { + schema.minProperties = fieldSchema.len_min + } + if (typeof fieldSchema.len_max === 'number' && !Number.isNaN(fieldSchema.len_max)) { + schema.maxProperties = fieldSchema.len_max + } + + return schema +} + +export const buildUnknownSchema = (fieldSchema: FieldSchema): ExtendedJSONSchema => { + return { + detail: 'unknown', + markdownDescription: 'Unknown field type', + } +} + +export const buildRecordSchema = (fieldSchema: RecordFieldSchema, languageHint: EditorLanguage): ExtendedJSONSchema => { + const properties: Record = {} + const required: string[] = [] + + const schema: JSONSchema = { + type: 'object', + properties, + // anyOf, + } + + for (const namedChildField of fieldSchema.fields) { + const [name, childField] = Object.entries(namedChildField)[0] + properties[name] = buildAnySchema(name, childField, languageHint) + + if (languageHint === 'json') { + if (childField.required) { + required.push(name) + } + } + } + + if (required.length > 0) { + schema.required = required + } + + // TODO: Try replace with allOf + // let anyOf: JSONSchema[] | undefined + // if (Array.isArray(fieldSchema.entity_checks)) { + // for (const check of fieldSchema.entity_checks) { + // if (isAtLeastOneOfEntityCheck(check)) { + // if (!anyOf) { + // anyOf = [] + // } + // anyOf.push(...check.at_least_one_of.map((name) => ({ required: [name] }))) + // } + // } + // } + + return schema +} + +export const buildAnySchema = ( + name: string, + fieldSchema: FieldSchema, + language: EditorLanguage, +): ExtendedJSONSchema => { + const sortText = `${fieldSchema.required ? ' ' : ''}${name}` + + const commons: ExtendedJSONSchema = { + // detail: `${fieldSchema.required ? '* ' : ''}${fieldSchema.type}`, + _fieldSchema: fieldSchema, + default: fieldSchema.default, + markdownDescription: [ + ...(fieldSchema.required ? ['_Required_'] : []), + `Type: \`${fieldSchema.type}\``, + fieldSchema.description, + ...(fieldSchema.default ? [`Default: \`${JSON.stringify(fieldSchema.default)}\``] : []), + ].join('\n\n'), + } + + switch (language) { + case 'json': { + commons.suggestSortText = sortText + break + } + // case 'yaml': { + // commons.sortText = sortText + // break + // } + } + + if (isStringField(fieldSchema)) { + return { ...commons, ...buildStringSchema(fieldSchema) } + } + + if (isNumberLikeField(fieldSchema)) { + return { ...commons, ...buildNumberLikeSchema(fieldSchema) } + } + + if (isBooleanField(fieldSchema)) { + return { ...commons, ...buildBooleanSchema(fieldSchema) } + } + + if (isArrayLikeField(fieldSchema)) { + return { ...commons, ...buildArrayLikeSchema(name, fieldSchema, language) } + } + + if (isMapField(fieldSchema)) { + return { ...commons, ...buildMapSchema(fieldSchema, language) } + } + + if (isRecordField(fieldSchema)) { + return { ...commons, ...buildRecordSchema(fieldSchema, language) } + } + + console.warn(`Unknown field schema with type "${fieldSchema.type}": ${JSON.stringify(fieldSchema)}`) + return { ...commons, ...buildUnknownSchema(fieldSchema) } +} diff --git a/packages/core/entities-config-editor/src/utils/index.ts b/packages/core/entities-config-editor/src/utils/index.ts new file mode 100644 index 0000000000..545d4b4cc2 --- /dev/null +++ b/packages/core/entities-config-editor/src/utils/index.ts @@ -0,0 +1,4 @@ +export * from './builders' +export * from './monaco' +export * from './strings' +export * from './uuid' diff --git a/packages/core/entities-config-editor/src/utils/monaco.ts b/packages/core/entities-config-editor/src/utils/monaco.ts new file mode 100644 index 0000000000..05f68459b0 --- /dev/null +++ b/packages/core/entities-config-editor/src/utils/monaco.ts @@ -0,0 +1,19 @@ +import type * as Monaco from 'monaco-editor' + +export const setupMonaco = async () => { + const [EditorWorker, JSONWorker] = await Promise.all([ + import('monaco-editor/esm/vs/editor/editor.worker?worker').then(module => module.default), + import('monaco-editor/esm/vs/language/json/json.worker?worker').then(module => module.default), + ]) + + window.MonacoEnvironment = { + getWorker(_: any, label: string) { + if (label === 'json') { + return new JSONWorker() + } + return new EditorWorker() + }, + } + + return await import('monaco-editor') as typeof Monaco +} diff --git a/packages/core/entities-config-editor/src/utils/strings.ts b/packages/core/entities-config-editor/src/utils/strings.ts new file mode 100644 index 0000000000..67476b09ad --- /dev/null +++ b/packages/core/entities-config-editor/src/utils/strings.ts @@ -0,0 +1,29 @@ +/** + * Converts a Lua pattern string to a JavaScript regular expression pattern string. + * + * @param luaPattern Lua pattern string + * @returns JavaScript regular expression pattern string + * @see https://www.lua.org/pil/20.2.html + */ +export const toRegExpPattern = (luaPattern: string) => + luaPattern + .replace(/%a/g, '[A-Za-z]') // %a -> letters + .replace(/%d/g, '\\d') // %d -> digits + .replace(/%l/g, '[a-z]') // %l -> lower case letters + .replace(/%p/g, "[!\"#$%&'()*+,\\-./:;<=>?@[\\]^_`{|}~]") // %p -> punctuation characters + .replace(/%s/g, '\\s') // %s -> space characters + .replace(/%u/g, '[A-Z]') // %u -> upper case letters + .replace(/%w/g, '\\w') // %w -> alphanumeric characters + .replace(/%x/g, '[0-9A-Fa-f]') // %x -> hexadecimal digits + // magic characters: ( ) . % + - * ? [ ^ $ + .replace(/%\(/g, '\\(') // %( -> literal ( + .replace(/%\)/g, '\\)') // %) -> literal ) + .replace(/%\./g, '\\.') // %. -> literal . + .replace(/%\+/g, '\\+') // %+ -> literal + + .replace(/%-/g, '\\-') // %- -> literal - + .replace(/%\*/g, '\\*') // %* -> literal * + .replace(/%\?/g, '\\?') // %? -> literal ? + .replace(/%\[/g, '\\[') // %[ -> literal [ + .replace(/%\^/g, '\\^') // %^ -> literal ^ + .replace(/%\$/g, '\\$') // %$ -> literal $ + .replace(/%%/g, '%') // %% -> literal % diff --git a/packages/core/entities-config-editor/src/utils/uuid.ts b/packages/core/entities-config-editor/src/utils/uuid.ts new file mode 100644 index 0000000000..8156062c76 --- /dev/null +++ b/packages/core/entities-config-editor/src/utils/uuid.ts @@ -0,0 +1,13 @@ +/** + * Test if a string is a valid uuid + * + * @param {String} str - the string to test + * @returns {boolean} + */ +const uuidRegEx = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + +export function isValidUuid(str: string) { + if (!str) return false + + return str.length === 36 && new RegExp(`^${uuidRegEx}$`).test(str) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96e397ac7d..b2e752a205 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -554,6 +554,15 @@ importers: '@kong/kongponents': specifier: 9.25.0 version: 9.25.0(axios@1.7.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vue-router@4.4.5(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3)) + monaco-editor: + specifier: ^0.52.2 + version: 0.52.2 + uuid: + specifier: ^10.0.0 + version: 10.0.0 + vscode-json-languageservice: + specifier: ^5.4.4 + version: 5.4.4 vue: specifier: ^3.5.13 version: 3.5.13(typescript@5.6.3) @@ -1892,7 +1901,6 @@ packages: '@evilmartians/lefthook@1.8.2': resolution: {integrity: sha512-SZdQk3W9q7tcJwnSwEMUubQqVIK7SHxv52hEAnV7o3nPI+xKcmd+rN0hZIJg07wjBaJRAjzdvoQySKQQYPW5Qw==} - cpu: [x64, arm64, ia32] os: [darwin, linux, win32] hasBin: true @@ -3303,6 +3311,9 @@ packages: '@volar/typescript@2.4.5': resolution: {integrity: sha512-mcT1mHvLljAEtHviVcBuOyAwwMKz1ibXTi5uYtP/pf4XxoAzpdkQ+Br2IC0NPCvLCbjPZmbf3I0udndkfB1CDg==} + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + '@vue-flow/background@1.3.2': resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==} peerDependencies: @@ -6494,6 +6505,9 @@ packages: jsonc-parser@3.2.0: resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} @@ -9634,6 +9648,9 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + vscode-json-languageservice@5.4.4: + resolution: {integrity: sha512-dgT16da8VznFv0IrEpBSKYvi29gxnMf5EOq+UfZSPaCiLZ65kgVOo3vMJSPNbZK8557YYbQH/fpMxxa4wRPAQw==} + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -9654,6 +9671,9 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vt-pbf@3.1.3: resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} @@ -12746,6 +12766,8 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.0.8 + '@vscode/l10n@0.0.18': {} + '@vue-flow/background@1.3.2(@vue-flow/core@1.41.5(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3))': dependencies: '@vue-flow/core': 1.41.5(vue@3.5.13(typescript@5.6.3)) @@ -16336,6 +16358,8 @@ snapshots: jsonc-parser@3.2.0: {} + jsonc-parser@3.3.1: {} + jsonfile@6.1.0: dependencies: universalify: 2.0.1 @@ -19941,6 +19965,14 @@ snapshots: void-elements@3.1.0: {} + vscode-json-languageservice@5.4.4: + dependencies: + '@vscode/l10n': 0.0.18 + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: @@ -19958,6 +19990,8 @@ snapshots: vscode-uri@3.0.8: {} + vscode-uri@3.1.0: {} + vt-pbf@3.1.3: dependencies: '@mapbox/point-geometry': 0.1.0 From 2588ed8d536021a6ee1c78b646892129f8a32261 Mon Sep 17 00:00:00 2001 From: Makito Date: Tue, 15 Apr 2025 15:02:20 +0800 Subject: [PATCH 2/5] feat(entities-config-editor): trigger suggestions inside strings --- .../src/components/EntitiesConfigEditor.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/entities-config-editor/src/components/EntitiesConfigEditor.vue b/packages/core/entities-config-editor/src/components/EntitiesConfigEditor.vue index 00723e6f2c..e6b5c5035c 100644 --- a/packages/core/entities-config-editor/src/components/EntitiesConfigEditor.vue +++ b/packages/core/entities-config-editor/src/components/EntitiesConfigEditor.vue @@ -204,6 +204,11 @@ const assertPropertyValueNode = (node: ASTNode, keyPath: string[]) => { onMounted(async () => { editor = monaco.editor.create(editorRef.value as HTMLElement, { theme: 'vs', + quickSuggestions: { + other: true, + comments: false, + strings: true, // Enable suggestions inside strings + }, automaticLayout: true, tabSize: 2, }) From fc5c9d6465ebc767fcb55917bbb14fb0caeb9ae6 Mon Sep 17 00:00:00 2001 From: Makito Date: Tue, 15 Apr 2025 15:14:46 +0800 Subject: [PATCH 3/5] fix(entities-config-editor): missing type in compilerOptions --- packages/core/entities-config-editor/tsconfig.build.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/entities-config-editor/tsconfig.build.json b/packages/core/entities-config-editor/tsconfig.build.json index 577de9d6ae..c96c8e4d88 100644 --- a/packages/core/entities-config-editor/tsconfig.build.json +++ b/packages/core/entities-config-editor/tsconfig.build.json @@ -1,7 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "types": [] + "types": [ + "vite/client" + ] }, "exclude": [ "src/**/*.cy.ts", From 47cad3263fa5a5b98520154e0331b8078e4982c1 Mon Sep 17 00:00:00 2001 From: Makito Date: Tue, 15 Apr 2025 15:26:29 +0800 Subject: [PATCH 4/5] chore(entities-config-editor): mark monaco-editor as peerDep --- packages/core/entities-config-editor/package.json | 4 ++-- packages/core/entities-config-editor/vite.config.ts | 5 +++++ pnpm-lock.yaml | 7 ++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/entities-config-editor/package.json b/packages/core/entities-config-editor/package.json index 66b5bbf089..b2aa27fe0d 100644 --- a/packages/core/entities-config-editor/package.json +++ b/packages/core/entities-config-editor/package.json @@ -41,7 +41,6 @@ "devDependencies": { "@kong/design-tokens": "1.17.3", "@kong/kongponents": "9.25.0", - "monaco-editor": "^0.52.2", "uuid": "^10.0.0", "vscode-json-languageservice": "^5.4.4", "vue": "^3.5.13" @@ -61,10 +60,11 @@ "extends": "../../../package.json" }, "distSizeChecker": { - "errorLimit": "200KB" + "errorLimit": "300KB" }, "peerDependencies": { "@kong/kongponents": "9.25.0", + "monaco-editor": "^0.52.2", "vue": "^3.5.13" } } diff --git a/packages/core/entities-config-editor/vite.config.ts b/packages/core/entities-config-editor/vite.config.ts index 282a2c602e..be9fb534e1 100644 --- a/packages/core/entities-config-editor/vite.config.ts +++ b/packages/core/entities-config-editor/vite.config.ts @@ -16,6 +16,11 @@ const config = mergeConfig(sharedViteConfig, defineConfig({ entry: resolve(__dirname, './src/index.ts'), fileName: (format) => `${sanitizedPackageName}.${format}.js`, }, + rollupOptions: { + external: [ + 'monaco-editor', + ], + }, }, })) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2e752a205..20b3e79e29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -547,6 +547,10 @@ importers: version: 3.5.13(typescript@5.6.3) packages/core/entities-config-editor: + dependencies: + monaco-editor: + specifier: ^0.52.2 + version: 0.52.2 devDependencies: '@kong/design-tokens': specifier: 1.17.3 @@ -554,9 +558,6 @@ importers: '@kong/kongponents': specifier: 9.25.0 version: 9.25.0(axios@1.7.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vue-router@4.4.5(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3)) - monaco-editor: - specifier: ^0.52.2 - version: 0.52.2 uuid: specifier: ^10.0.0 version: 10.0.0 From e2c06ca6dd1bbb66ee033884dd16b917c768f175 Mon Sep 17 00:00:00 2001 From: Makito Date: Tue, 15 Apr 2025 16:48:26 +0800 Subject: [PATCH 5/5] fix(entities-config-editor): update distSizeChecker errorLimit to 2048KB --- packages/core/entities-config-editor/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/entities-config-editor/package.json b/packages/core/entities-config-editor/package.json index b2aa27fe0d..15c786ad69 100644 --- a/packages/core/entities-config-editor/package.json +++ b/packages/core/entities-config-editor/package.json @@ -60,7 +60,7 @@ "extends": "../../../package.json" }, "distSizeChecker": { - "errorLimit": "300KB" + "errorLimit": "2048KB" }, "peerDependencies": { "@kong/kongponents": "9.25.0",