diff --git a/.gitignore b/.gitignore index a0d82570f..8156bbad1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ node_modules server/api/dashboard.go coverage.txt versioninfo.json +.DS_Store +Icon diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 765f976d1..7e8a4b6ab 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,10 +4,10 @@ - @@ -1152,7 +1155,8 @@ - true diff --git a/api/handler.go b/api/handler.go index 4af96236a..5ddc704d9 100644 --- a/api/handler.go +++ b/api/handler.go @@ -22,6 +22,7 @@ import ( type Handler interface { http.Handler RegisterHealthHandler(path string, h http.Handler) + RegisterMcpHandler(path string, h http.Handler) } type handler struct { @@ -33,6 +34,8 @@ type handler struct { index string healthPath string healthHandler http.Handler + mcpPath string + mcpHandler http.Handler } type info struct { @@ -111,6 +114,11 @@ func (h *handler) RegisterHealthHandler(path string, handler http.Handler) { h.healthHandler = handler } +func (h *handler) RegisterMcpHandler(path string, handler http.Handler) { + h.mcpPath = path + h.mcpHandler = handler +} + func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" && r.Method != "POST" { http.Error(w, fmt.Sprintf("method %v is not allowed", r.Method), http.StatusMethodNotAllowed) @@ -154,6 +162,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.getSearchResults(w, r) case strings.HasPrefix(p, h.healthPath) && h.healthHandler != nil: h.healthHandler.ServeHTTP(w, r) + case strings.HasPrefix(p, h.mcpPath) && h.mcpHandler != nil: + h.mcpHandler.ServeHTTP(w, r) case h.fileServer != nil: if r.Method != "GET" { http.Error(w, fmt.Sprintf("method %v is not allowed", r.Method), http.StatusMethodNotAllowed) diff --git a/api/handler_config.go b/api/handler_config.go index aa761e090..180e005f9 100644 --- a/api/handler_config.go +++ b/api/handler_config.go @@ -79,7 +79,17 @@ func (h *handler) getConfigData(w http.ResponseWriter, r *http.Request, key stri ext := filepath.Ext(path) mt := mime.TypeByExtension(filepath.Ext(ext)) if mt == "" { - mt = "text/plain" + if ext == "" { + values, err := mime.ExtensionsByType(c.Info.Kernel().ContentType) + if err == nil && len(values) > 0 { + ext = values[0] + path += ext + mt = mime.TypeByExtension(filepath.Ext(ext)) + } + } + if mt == "" { + mt = "text/plain" + } } w.Header().Set("Last-Modified", c.Info.Time.UTC().Format(http.TimeFormat)) w.Header().Set("Content-Type", mt) diff --git a/api/handler_config_test.go b/api/handler_config_test.go index f5abbd834..658e80f6b 100644 --- a/api/handler_config_test.go +++ b/api/handler_config_test.go @@ -149,6 +149,7 @@ func TestHandler_Config(t *testing.T) { requestUrl: "http://foo.api/api/configs/foo/data", test: []try.ResponseCondition{ try.HasStatusCode(http.StatusOK), + try.HasHeader("Content-Disposition", "inline; filename=\"foo.yaml\""), try.HasHeader("Last-Modified", "Wed, 27 Dec 2023 13:01:30 GMT"), try.HasHeaderXor("Content-Type", "text/plain", "application/yaml"), try.HasHeader("Cache-Control", "no-cache"), @@ -173,6 +174,34 @@ func TestHandler_Config(t *testing.T) { requestUrl: "http://foo.api/api/configs/foo/data", test: []try.ResponseCondition{ try.HasStatusCode(http.StatusOK), + try.HasHeader("Content-Disposition", "inline; filename=\"foo.json\""), + try.HasHeader("Last-Modified", "Fri, 22 Dec 2023 13:01:30 GMT"), + try.HasHeader("Content-Type", "application/json"), + try.HasHeader("Etag", etag), + try.HasHeader("Cache-Control", "no-cache"), + try.HasBody(`{"foo": "bar"}`), + }, + }, + { + name: "config data: no extension but ContentType in Info is set", + app: func() *runtime.App { + + return &runtime.App{Configs: map[string]*dynamic.Config{ + "foo": { + Info: dynamic.ConfigInfo{ + Url: mustUrl("https://foo.bar/foo"), + Time: mustTime("2023-12-22T13:01:30+00:00"), + Checksum: checksum, + ContentType: "application/json", + }, + Raw: data, + }, + }} + }, + requestUrl: "http://foo.api/api/configs/foo/data", + test: []try.ResponseCondition{ + try.HasStatusCode(http.StatusOK), + try.HasHeader("Content-Disposition", "inline; filename=\"foo.json\""), try.HasHeader("Last-Modified", "Fri, 22 Dec 2023 13:01:30 GMT"), try.HasHeader("Content-Type", "application/json"), try.HasHeader("Etag", etag), diff --git a/cmd/mokapi/main_test.go b/cmd/mokapi/main_test.go index 4ce178986..14efff287 100644 --- a/cmd/mokapi/main_test.go +++ b/cmd/mokapi/main_test.go @@ -89,6 +89,11 @@ health: path: /health port: 8080 log: false +mcp: + server: + enabled: false + path: /mcp + port: 8080 rootCaCert: "" rootCaKey: "" configs: [] diff --git a/config/dynamic/config_info.go b/config/dynamic/config_info.go index 113a3ca0e..d4a78eb6a 100644 --- a/config/dynamic/config_info.go +++ b/config/dynamic/config_info.go @@ -12,12 +12,13 @@ import ( ) type ConfigInfo struct { - Provider string - Url *url.URL - Checksum []byte - Time time.Time - inner *ConfigInfo - Tags []string + Provider string + Url *url.URL + Checksum []byte + Time time.Time + inner *ConfigInfo + Tags []string + ContentType string } func (ci *ConfigInfo) Path() string { diff --git a/config/dynamic/parse.go b/config/dynamic/parse.go index 6145d5818..083512836 100644 --- a/config/dynamic/parse.go +++ b/config/dynamic/parse.go @@ -76,10 +76,12 @@ func parse(c *Config) (interface{}, error) { var v interface{} v, err = parseJson(b, result) if err == nil { + c.Info.ContentType = "application/json" return v, nil } v, err = parseYaml(b, result) if v != nil && err == nil { + c.Info.ContentType = "application/yaml" return v, nil } err = nil diff --git a/config/dynamic/parse_test.go b/config/dynamic/parse_test.go index 4d80224c0..552d46b5d 100644 --- a/config/dynamic/parse_test.go +++ b/config/dynamic/parse_test.go @@ -107,6 +107,7 @@ func TestParse(t *testing.T) { err := dynamic.Parse(c, &dynamictest.Reader{}) require.NoError(t, err) require.Equal(t, "foo", c.Data.(*data).User) + require.Equal(t, "application/json", c.Info.ContentType) }, }, { @@ -161,6 +162,7 @@ func TestParse(t *testing.T) { err := dynamic.Parse(c, &dynamictest.Reader{}) require.NoError(t, err) require.Equal(t, "foo", c.Data.(*data).User) + require.Equal(t, "application/yaml", c.Info.ContentType) }, }, { diff --git a/config/dynamic/provider/http/http.go b/config/dynamic/provider/http/http.go index 0faf03b4a..7d7364b7a 100644 --- a/config/dynamic/provider/http/http.go +++ b/config/dynamic/provider/http/http.go @@ -198,6 +198,10 @@ func (p *Provider) readUrl(u *url.URL) (c *dynamic.Config, changed bool, err err Raw: b, } + if ct := res.Header.Get("Content-Type"); ct != "" { + c.Info.ContentType = ct + } + return } diff --git a/config/dynamic/provider/http/http_test.go b/config/dynamic/provider/http/http_test.go index 2ff37331a..fabc108ad 100644 --- a/config/dynamic/provider/http/http_test.go +++ b/config/dynamic/provider/http/http_test.go @@ -3,9 +3,6 @@ package http import ( "context" "fmt" - "github.com/sirupsen/logrus" - "github.com/sirupsen/logrus/hooks/test" - "github.com/stretchr/testify/require" "io" "mokapi/config/dynamic" "mokapi/config/static" @@ -15,6 +12,10 @@ import ( "net/http/httptest" "testing" "time" + + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/require" ) func TestProvider_Start(t *testing.T) { @@ -159,6 +160,28 @@ func TestProvider_Start(t *testing.T) { require.Equal(t, fmt.Sprintf("request to %v failed: request has timed out", url), hook.LastEntry().Message) }, }, + { + name: "content type", + init: func() (static.HttpProvider, *httptest.Server) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("foo: bar")) + })) + + cfg := static.HttpProvider{ + Urls: []string{server.URL}, + } + + return cfg, server + }, + test: func(t *testing.T, url string, ch chan dynamic.ConfigEvent, hook *test.Hook, err error) { + require.NoError(t, err) + c := <-ch + require.Equal(t, "foo: bar", string(c.Config.Raw)) + require.Equal(t, "application/json", c.Config.Info.ContentType) + }, + }, } for _, tc := range testcases { diff --git a/config/static/static_config.go b/config/static/static_config.go index a7b39a434..fc22f4620 100644 --- a/config/static/static_config.go +++ b/config/static/static_config.go @@ -19,6 +19,7 @@ type Config struct { Providers Providers `json:"providers" yaml:"providers"` Api Api `json:"api" yaml:"api"` Health Health `json:"health" yaml:"health"` + Mcp Mcp `json:"mcp" yaml:"mcp"` RootCaCert tls.FileOrContent `json:"rootCaCert" yaml:"rootCaCert" name:"root-ca-cert"` RootCaKey tls.FileOrContent `json:"rootCaKey" yaml:"rootCaKey" name:"root-ca-cert"` Configs Configs `json:"configs" yaml:"configs" explode:"config"` @@ -44,6 +45,14 @@ func NewConfig() *Config { cfg.Health.Port = 8080 cfg.Health.Path = "/health" + cfg.Mcp = Mcp{ + Server: McpServer{ + Enabled: false, + Port: 8080, + Path: "/mcp", + }, + } + cfg.Providers.File.SkipPrefix = []string{"_"} cfg.Event.Store = map[string]Store{"default": {Size: 100}} cfg.DataGen.OptionalProperties = "0.85" @@ -298,3 +307,13 @@ type Health struct { Port int `yaml:"port" json:"port"` Log bool `yaml:"log" json:"log"` } + +type Mcp struct { + Server McpServer `yaml:"server" json:"server"` +} + +type McpServer struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Path string `yaml:"path" json:"path"` + Port int `yaml:"port" json:"port"` +} diff --git a/context7.json b/context7.json new file mode 100644 index 000000000..502bfbf63 --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/marle3003/mokapi", + "public_key": "pk_aZSdHtCTd5H3Tm72PzeNI" +} \ No newline at end of file diff --git a/docs/javascript-api/mokapi-http/fetchoptions.md b/docs/javascript-api/mokapi-http/fetchoptions.md index d65f23e6c..c9620ca53 100644 --- a/docs/javascript-api/mokapi-http/fetchoptions.md +++ b/docs/javascript-api/mokapi-http/fetchoptions.md @@ -7,13 +7,14 @@ description: The FetchOptions object defines parameters for the fetch() function The `FetchOptions` object defines additional parameters for the [`fetch()`](/docs/javascript-api/mokapi-http/fetch.md) function in the `mokapi/http` module. It allows you to customize request behavior such as HTTP method, headers, body, timeout, and redirect handling. -| Name | Type | Description | -|--------------|----------------|--------------------------------------------------------------------------------------------------------------| -| method | string | The HTTP method used for the request (e.g. `"GET"`, `"POST"`, `"PUT"`). Defaults to `"GET"`. | | -| body | object | The request body to send. Automatically serialized to JSON when `Content-Type` is set to `application/json`. | -| headers | object | Key-value pairs representing HTTP headers to include with the request. | -| maxRedirects | number | The number of redirects to follow. Default value is 5. A value of 0 (zero) prevents all redirection. | -| timeout | number, string | Maximum time to wait for the request to complete. Default timeout is 60 seconds ("60s") | +| Name | Type | Description | +|--------------|----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| method | string | The HTTP method used for the request (e.g. `"GET"`, `"POST"`, `"PUT"`). Defaults to `"GET"`. | | +| body | any | The request body to send. Automatically serialized to JSON when `Content-Type` is set to `application/json`. | +| headers | object | Key-value pairs representing HTTP headers to include with the request. | +| maxRedirects | number | The number of redirects to follow. Default value is 5. A value of 0 (zero) prevents all redirection. | +| timeout | number, string | Maximum time to wait for the request to complete. Default timeout is 60 seconds ("60s") | +| insecure | boolean | If set to true, TLS certificate verification is skipped. This allows connections to servers with self-signed or invalid certificates. Default is false. | ## Example of Accept header diff --git a/docs/javascript-api/mokapi-kafka/produceargs.md b/docs/javascript-api/mokapi-kafka/produceargs.md index 1a24b8649..def60c29e 100644 --- a/docs/javascript-api/mokapi-kafka/produceargs.md +++ b/docs/javascript-api/mokapi-kafka/produceargs.md @@ -10,7 +10,7 @@ ProduceArgs is an object used by [produce](/docs/javascript-api/mokapi-kafka/pro |-----------------------------------|--------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| | cluster (optional) | string | Kafka cluster name. Used when topic name is not unique. | | topic (optional) | string | Kafka topic name. If not specified, message will be written to a random topic. | -| messages | array | An list of [Message](/docs/javascript-api/mokapi-kafka/message.md) contains Kafka messages to produce into given topic | +| messages | array | A list of [Message](/docs/javascript-api/mokapi-kafka/message.md) contains Kafka messages to produce into given topic | | partition (optional, deprecated ) | number | Kafka partition index. If not specified, the message will be written to any partition | | key (optional, deprecated) | any | Kafka message key. If not specified, a random key will be generated based on the topic configuration. | | value (optional, deprecated) | any | Kafka message value. If not specified, a random value will be generated based on the topic configuration. Object will be encoded based on the topic configuration | diff --git a/docs/javascript-api/mokapi/eventhandler/kafkaeventmessage.md b/docs/javascript-api/mokapi/eventhandler/kafkaeventmessage.md index b7dbc4dda..5fc75e3aa 100644 --- a/docs/javascript-api/mokapi/eventhandler/kafkaeventmessage.md +++ b/docs/javascript-api/mokapi/eventhandler/kafkaeventmessage.md @@ -7,10 +7,10 @@ description: KafkaEventMessage is an object used by KafkaEventHandler KafkaMessage is an object used by [KafkaEventHandler](/docs/javascript-api/mokapi/eventhandler/kafkaeventhandler.md) that contains Kafka-specific message data. -| Name | Type | Description | -|---------|--------|---------------------------------------------------------------| -| offset | number | Kafka partition where the message was written to (read-only). | -| key | string | Kafka message key | -| value | string | Kafka message value | -| headers | object | Kafka message headers | +| Name | Type | Description | +|---------|--------|------------------------------------------------| +| offset | number | Kafka offset of the kafka message (read-only). | +| key | string | Kafka message key | +| value | string | Kafka message value | +| headers | object | Kafka message headers | diff --git a/engine/common/host.go b/engine/common/host.go index b03e89a8e..ef67cf24b 100644 --- a/engine/common/host.go +++ b/engine/common/host.go @@ -116,6 +116,7 @@ type HttpClient interface { type HttpClientOptions struct { MaxRedirects int Timeout time.Duration + Insecure bool } type Action struct { diff --git a/engine/host.go b/engine/host.go index e1e38b208..0690843ba 100644 --- a/engine/host.go +++ b/engine/host.go @@ -1,6 +1,7 @@ package engine import ( + "crypto/tls" "encoding/json" "fmt" "mokapi/config/dynamic" @@ -283,8 +284,15 @@ func (sh *scriptHost) KafkaClient() common.KafkaClient { } func (sh *scriptHost) HttpClient(opts common.HttpClientOptions) common.HttpClient { - return &http.Client{ - Timeout: opts.Timeout, + transport := http.DefaultTransport.(*http.Transport).Clone() + + if opts.Insecure { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + + c := &http.Client{ + Transport: transport, + Timeout: opts.Timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { if l := len(via); l > opts.MaxRedirects { log.Warnf("Stopped after %d redirects, original URL was %s", opts.MaxRedirects, via[0].URL) @@ -293,6 +301,8 @@ func (sh *scriptHost) HttpClient(opts common.HttpClientOptions) common.HttpClien return nil }, } + + return c } func (sh *scriptHost) CanClose() bool { diff --git a/examples/mokapi/services_http.js b/examples/mokapi/services_http.js index 9c5bc401e..e8065dbfb 100644 --- a/examples/mokapi/services_http.js +++ b/examples/mokapi/services_http.js @@ -377,7 +377,7 @@ export let apps = [ { method: "get", summary: "Finds Pets by status", - description: "Multiple status values can be provided with comma separated strings", + description: "Multiple status values **can** be provided with comma separated strings", operationId: "findPetsByStatus", tags: ["pet"], parameters: [ diff --git a/examples/swagger/streetlight.yaml b/examples/swagger/streetlight.yaml new file mode 100644 index 000000000..19964daf2 --- /dev/null +++ b/examples/swagger/streetlight.yaml @@ -0,0 +1,188 @@ +asyncapi: '2.6.0' +info: + title: Streetlights Kafka API + version: '1.0.0' + description: | + The Smartylighting Streetlights API allows you to remotely manage the city lights. + + ### Check out its awesome features: + + * Turn a specific streetlight on/off 🌃 + * Dim a specific streetlight 😎 + * Receive real-time information about environmental lighting conditions 📈 + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + +servers: + scram-connections: + url: test.mykafkacluster.org:18092 + protocol: kafka-secure + description: Test broker secured with scramSha256 + security: + - saslScram: [] + tags: + - name: "env:test-scram" + description: "This environment is meant for running internal tests through scramSha256" + - name: "kind:remote" + description: "This server is a remote server. Not exposed by the application" + - name: "visibility:private" + description: "This resource is private and only available to certain users" + mtls-connections: + url: test.mykafkacluster.org:28092 + protocol: kafka-secure + description: Test broker secured with X509 + security: + - certs: [] + tags: + - name: "env:test-mtls" + description: "This environment is meant for running internal tests through mtls" + - name: "kind:remote" + description: "This server is a remote server. Not exposed by the application" + - name: "visibility:private" + description: "This resource is private and only available to certain users" + +defaultContentType: application/json + +channels: + smartylighting.streetlights.1.0.event.{streetlightId}.lighting.measured: + description: The topic on which measured values may be produced and consumed. + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + publish: + summary: Inform about environmental lighting conditions of a particular streetlight. + operationId: receiveLightMeasurement + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/lightMeasured' + + smartylighting.streetlights.1.0.action.{streetlightId}.turn.on: + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + subscribe: + operationId: turnOn + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/turnOnOff' + + smartylighting.streetlights.1.0.action.{streetlightId}.turn.off: + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + subscribe: + operationId: turnOff + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/turnOnOff' + + smartylighting.streetlights.1.0.action.{streetlightId}.dim: + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + subscribe: + operationId: dimLight + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/dimLight' + +components: + messages: + lightMeasured: + name: lightMeasured + title: Light measured + summary: Inform about environmental lighting conditions of a particular streetlight. + contentType: application/json + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: "#/components/schemas/lightMeasuredPayload" + turnOnOff: + name: turnOnOff + title: Turn on/off + summary: Command a particular streetlight to turn the lights on or off. + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: "#/components/schemas/turnOnOffPayload" + dimLight: + name: dimLight + title: Dim light + summary: Command a particular streetlight to dim the lights. + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: "#/components/schemas/dimLightPayload" + + schemas: + lightMeasuredPayload: + type: object + properties: + lumens: + type: integer + minimum: 0 + description: Light intensity measured in lumens. + sentAt: + $ref: "#/components/schemas/sentAt" + turnOnOffPayload: + type: object + properties: + command: + type: string + enum: + - on + - off + description: Whether to turn on or off the light. + sentAt: + $ref: "#/components/schemas/sentAt" + dimLightPayload: + type: object + properties: + percentage: + type: integer + description: Percentage to which the light should be dimmed to. + minimum: 0 + maximum: 100 + sentAt: + $ref: "#/components/schemas/sentAt" + sentAt: + type: string + format: date-time + description: Date and time when the message was sent. + + securitySchemes: + saslScram: + type: scramSha256 + description: Provide your username and password for SASL/SCRAM authentication + certs: + type: X509 + description: Download the certificate files from service provider + + parameters: + streetlightId: + description: The ID of the streetlight. + schema: + type: string + + messageTraits: + commonHeaders: + headers: + type: object + properties: + my-app-header: + type: integer + minimum: 0 + maximum: 100 + + operationTraits: + kafka: + bindings: + kafka: + clientId: + type: string + enum: ['my-app-id'] diff --git a/go.mod b/go.mod index 65b5e86ee..4a1d62586 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.5 require ( github.com/Masterminds/sprig v2.22.0+incompatible github.com/blevesearch/bleve/v2 v2.5.7 - github.com/blevesearch/bleve_index_api v1.3.2 + github.com/blevesearch/bleve_index_api v1.3.6 github.com/bradleyfalzon/ghinstallation/v2 v2.18.0 github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c @@ -63,6 +63,7 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/google/go-github/v84 v84.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/huandu/xstrings v1.3.2 // indirect github.com/imdario/mergo v0.3.14 // indirect @@ -71,16 +72,21 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/modelcontextprotocol/go-sdk v1.4.1 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.etcd.io/bbolt v1.4.0 // indirect go.uber.org/atomic v1.9.0 // indirect golang.org/x/crypto v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.42.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index 059257435..58c3709bf 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCk github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8= github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA= -github.com/blevesearch/bleve_index_api v1.3.2 h1:y4VLXBF7nQR01CvF+QzmCJKMpVPCLp1CJ5FsRSZXzRE= -github.com/blevesearch/bleve_index_api v1.3.2/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= +github.com/blevesearch/bleve_index_api v1.3.6 h1:Cp67NjekrlHh2KTDGpKM0hqMnBTBIBJVQVtEEzDnIaI= +github.com/blevesearch/bleve_index_api v1.3.6/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI= @@ -114,6 +114,8 @@ github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfh github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -144,6 +146,8 @@ github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMK github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -161,6 +165,10 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -182,6 +190,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= @@ -197,6 +207,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbR golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/imap/idle_test.go b/imap/idle_test.go index 4980c5eae..5be684545 100644 --- a/imap/idle_test.go +++ b/imap/idle_test.go @@ -188,25 +188,27 @@ func TestSendUpdatesWhileIdle(t *testing.T) { IdleFunc: func(w imap.UpdateWriter, done chan struct{}, session map[string]interface{}) error { session["idle"] = done go func() { + defer close(sent) + err := w.WriteNumMessages(10) require.NoError(t, err) err = w.WriteMessageFlags(20, []imap.Flag{imap.FlagSeen}) require.NoError(t, err) err = w.WriteExpunge(1) - sent <- true }() return nil }, }, } - defer s.Close() go func() { err := s.ListenAndServe() require.ErrorIs(t, err, imap.ErrServerClosed) }() - c := imap.NewClient(fmt.Sprintf("localhost:%v", p)) - defer func() { _ = c.Close() }() + defer func() { + _ = c.Close() + defer s.Close() + }() _, err := c.Dial() require.NoError(t, err) @@ -220,7 +222,11 @@ func TestSendUpdatesWhileIdle(t *testing.T) { require.NoError(t, err) require.Equal(t, "+ idling", res) - <-sent + select { + case <-sent: + case <-time.After(4 * time.Second): + t.Fatal("timeout waiting for updates") + } res, err = c.ReadLine() require.NoError(t, err) diff --git a/js/http/http.go b/js/http/http.go index fa2d6bfc9..890eeb953 100644 --- a/js/http/http.go +++ b/js/http/http.go @@ -281,6 +281,12 @@ func parseArgs(args *goja.Object) (*RequestArgs, common.HttpClientOptions, error continue } rArgs.Body = v.Export() + case "insecure": + v := args.Get(k) + if v.ExportType().Kind() != reflect.Bool { + return rArgs, opts, fmt.Errorf("unexpected type for 'insecure': got %s, expected Boolean", util.JsType(v)) + } + opts.Insecure = v.ToBoolean() } } } diff --git a/js/http/http_test.go b/js/http/http_test.go index da462f191..82975a834 100644 --- a/js/http/http_test.go +++ b/js/http/http_test.go @@ -546,6 +546,54 @@ func TestHttp(t *testing.T) { r.Equal(t, "unexpected type for 'maxRedirects': got String, expected Number", v.Export()) }, }, + { + name: "fetch insecure", + client: func(options common.HttpClientOptions) common.HttpClient { + return &enginetest.HttpClient{ + DoFunc: func(request *http.Request) (*http.Response, error) { + if !options.Insecure { + return nil, fmt.Errorf("expected insecure=true") + } + return &http.Response{StatusCode: http.StatusOK}, nil + }, + } + }, + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + _, err := vm.RunString(` + const m = require('mokapi/http') + const p = m.fetch('https://foo.bar', { insecure: true }) + let result; + p.then(v => result = v).catch(err => result = err) + `) + r.NoError(t, err) + time.Sleep(200 * time.Millisecond) + + v, err := vm.RunString("result") + r.NoError(t, err) + res, ok := v.Export().(mod.Response) + if !ok { + r.FailNow(t, v.String()) + } + r.Equal(t, http.StatusOK, res.StatusCode) + }, + }, + { + name: "fetch insecure not boolean", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + _, err := vm.RunString(` + const m = require('mokapi/http') + const p = m.fetch('https://foo.bar', { insecure: 'foo' }) + let result; + p.then(v => result = v).catch(err => result = err) + `) + r.NoError(t, err) + time.Sleep(200 * time.Millisecond) + + v, err := vm.RunString("result") + r.NoError(t, err) + r.Equal(t, "unexpected type for 'insecure': got String, expected Boolean", v.Export()) + }, + }, } for _, tc := range testcases { diff --git a/js/mokapi/shared.go b/js/mokapi/shared.go index 80cae9196..8a4030797 100644 --- a/js/mokapi/shared.go +++ b/js/mokapi/shared.go @@ -5,6 +5,7 @@ import ( "mokapi/engine/common" "reflect" "slices" + "sync" "github.com/dop251/goja" ) @@ -117,6 +118,7 @@ func Export(v any) any { type SharedValue struct { vm *goja.Runtime source goja.Value + m sync.RWMutex } func NewSharedValue(v goja.Value, vm *goja.Runtime) *SharedValue { @@ -131,6 +133,9 @@ func (p *SharedValue) Use(vm *goja.Runtime) *SharedValue { } func (p *SharedValue) Get(key string) goja.Value { + p.m.RLock() + defer p.m.RUnlock() + switch v := p.source.(type) { case *goja.Object: f := v.Get(key) @@ -159,6 +164,9 @@ func (p *SharedValue) Has(key string) bool { } func (p *SharedValue) Set(key string, value goja.Value) bool { + p.m.Lock() + defer p.m.Unlock() + switch v := p.source.(type) { case *goja.Object: sv := useValue(value, p.vm) @@ -172,6 +180,9 @@ func (p *SharedValue) Set(key string, value goja.Value) bool { } func (p *SharedValue) Delete(key string) bool { + p.m.Lock() + defer p.m.Unlock() + switch v := p.source.(type) { case *goja.Object: err := v.Delete(key) @@ -185,6 +196,9 @@ func (p *SharedValue) Delete(key string) bool { } func (p *SharedValue) Keys() []string { + p.m.RLock() + defer p.m.RUnlock() + switch v := p.source.(type) { case *goja.Object: return v.Keys() diff --git a/js/mokapi/shared_test.go b/js/mokapi/shared_test.go index 70f80b246..3feed4d96 100644 --- a/js/mokapi/shared_test.go +++ b/js/mokapi/shared_test.go @@ -343,6 +343,21 @@ func TestModule_Shared(t *testing.T) { r.Equal(t, "bar", mokapi.Export(v)) }, }, + { + name: "delete field in object", + test: func(t *testing.T, newVm func() *goja.Runtime) { + vm1 := newVm() + + v, err := vm1.RunString(` + const m = require('mokapi'); + const shared = m.shared.update('foo', (v) => v ?? { foo: 'bar' }); + delete shared.foo + shared + `) + r.NoError(t, err) + r.Equal(t, map[string]any{}, mokapi.Export(v)) + }, + }, { name: "push array", test: func(t *testing.T, newVm func() *goja.Runtime) { diff --git a/lib/http.go b/lib/http.go index d8b31eb2d..04a7b6d22 100644 --- a/lib/http.go +++ b/lib/http.go @@ -15,7 +15,11 @@ func GetUrl(r *http.Request) string { } else { sb.WriteString("http://") } - sb.WriteString(r.Host) + if r.Host != "" { + sb.WriteString(r.Host) + } else { + sb.WriteString("localhost") + } sb.WriteString(r.URL.String()) return sb.String() } diff --git a/mcp/generate_http_mock_response.go b/mcp/generate_http_mock_response.go new file mode 100644 index 000000000..78a8aac1a --- /dev/null +++ b/mcp/generate_http_mock_response.go @@ -0,0 +1,179 @@ +package mcp + +import ( + "context" + "fmt" + "mokapi/media" + "mokapi/providers/openapi" + "mokapi/providers/openapi/schema" + "mokapi/schema/json/generator" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type GenerateHttpMockResponseInput struct { + ApiName string `json:"apiName"` + Path string `json:"path"` + Method string `json:"method"` + StatusCode int `json:"statusCode"` + ContentType string `json:"contentType,omitempty"` +} + +type GenerateHttpMockResponseOutput struct { + StatusCode int `json:"statusCode"` + Data any `json:"data"` + Headers map[string]any `json:"headers"` +} + +func (s *Service) registerGenerateHttpMockResponseTool(server *mcp.Server) { + inputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "apiName": map[string]any{ + "type": "string", + "description": "The exact name of the API as returned by 'get_api_list'", + }, + "path": map[string]any{ + "type": "string", + "description": "The path template of the endpoint (e.g. /pets/{id})", + }, + "method": map[string]any{ + "type": "string", + "description": "The HTTP method (GET, POST, PUT, DELETE, etc.)", + }, + "statusCode": map[string]any{ + "type": "integer", + "description": "The HTTP status code to generate the response for", + }, + "contentType": map[string]any{ + "type": "string", + "description": `The HTTP content type of the response body. Optional: + If provided, this content type is used. + If the endpoint has only one content type, it will be used automatically. + Otherwise defaults to 'application/json'`, + "default": "application/json", + }, + }, + "required": []string{"apiName", "path", "method", "statusCode"}, + } + + outputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "statusCode": map[string]any{ + "type": "integer", + "description": "HTTP status code for the response", + }, + "headers": map[string]any{ + "type": "object", + "description": "response headers defined by the API specification", + }, + "data": map[string]any{ + "type": "any", + "description": "structured response body that matches the OpenAPI schema", + }, + }, + } + + registerTool(server, &mcp.Tool{ + Name: "mokapi_generate_http_mock_response", + Description: `Generate a valid HTTP response for a specific API endpoint. + +This tool returns a complete response object that already conforms to the OpenAPI specification. +The generated data strictly matches the response schema, including all required fields and correct types. + +Use this tool when writing HTTP mock scripts. + +The "data" field is preferred and will be automatically encoded based on the API specification. +The "body" field is not returned by this tool and should only be used for raw responses. + +Use this tool to: +- Ensure the response structure matches the OpenAPI definition +- Avoid manually constructing response data + +You can: +- Modify or replace the returned "data" field +- Use custom logic to determine the response content + +The returned object should be assigned to: +- response.statusCode +- response.headers +- response.data +`, + InputSchema: inputSchema, + OutputSchema: outputSchema, + }, s.GenerateHttpMockResponse) +} + +func (s *Service) GenerateHttpMockResponse(_ context.Context, in GenerateHttpMockResponseInput) (GenerateHttpMockResponseOutput, error) { + result := GenerateHttpMockResponseOutput{StatusCode: in.StatusCode, Headers: make(map[string]any)} + + info := s.app.GetHttp(in.ApiName) + if info == nil { + return result, fmt.Errorf("http api not found") + } + p, ok := info.Paths[in.Path] + if !ok || p.Value == nil { + return result, fmt.Errorf("path not found") + } + o := p.Value.Operation(in.Method) + if o == nil { + return result, fmt.Errorf("operation not found") + } + r := o.Responses.GetResponse(in.StatusCode) + if r == nil { + return result, fmt.Errorf("response not found") + } + + n := len(r.Content) + if n == 0 { + return result, fmt.Errorf("response has no content") + } + + var mt *openapi.MediaType + if n == 1 && in.ContentType == "" { + for _, v := range r.Content { + mt = v + break + } + } else { + contentType := "application/json" + if in.ContentType != "" { + contentType = in.ContentType + } + accept := media.ParseContentType(contentType) + for k, v := range r.Content { + key := media.ParseContentType(k) + if accept.Match(key) { + mt = v + break + } + } + } + + if mt == nil { + return result, fmt.Errorf("response not found") + } + + segments := strings.Split(p.Value.Path, "/") + var names []string + for _, seg := range segments[1:] { + if !strings.HasPrefix(seg, "{") { + names = append(names, seg) + } + } + req := generator.NewRequest( + names, + schema.ConvertToJsonSchema(mt.Schema), + nil, + ) + + var err error + result.Data, err = generator.New(req) + if err != nil { + return result, err + } + + return result, nil +} diff --git a/mcp/generate_http_mock_response_test.go b/mcp/generate_http_mock_response_test.go new file mode 100644 index 000000000..12e031368 --- /dev/null +++ b/mcp/generate_http_mock_response_test.go @@ -0,0 +1,64 @@ +package mcp_test + +import ( + "context" + "mokapi/mcp" + "mokapi/providers/openapi/openapitest" + "mokapi/providers/openapi/schema/schematest" + "mokapi/runtime" + "mokapi/runtime/runtimetest" + "mokapi/schema/json/generator" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestService_GenerateHttpResponse(t *testing.T) { + testcases := []struct { + name string + app *runtime.App + test func(t *testing.T, s *mcp.Service) + }{ + { + name: "Generate response result", + app: runtimetest.NewApp( + runtimetest.WithHttp(openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + openapitest.WithPath("/foo", + openapitest.WithOperation(http.MethodGet, + openapitest.WithResponse(http.StatusOK, + openapitest.WithContent("application/json", + openapitest.WithSchema(schematest.New("string")), + ), + ), + ), + ), + )), + ), + test: func(t *testing.T, s *mcp.Service) { + result, err := s.GenerateHttpMockResponse(context.Background(), mcp.GenerateHttpMockResponseInput{ + ApiName: "foo", + Path: "/foo", + Method: http.MethodGet, + StatusCode: http.StatusOK, + }) + require.NoError(t, err) + require.Equal(t, mcp.GenerateHttpMockResponseOutput{ + StatusCode: http.StatusOK, + Data: "Ln8rnaRqlL", + Headers: map[string]any{}, + }, result) + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + generator.Seed(12345) + + s := mcp.NewService(tc.app) + tc.test(t, s) + }) + } +} diff --git a/mcp/get_api_spec.go b/mcp/get_api_spec.go new file mode 100644 index 000000000..9c5b6a271 --- /dev/null +++ b/mcp/get_api_spec.go @@ -0,0 +1,195 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + log "github.com/sirupsen/logrus" +) + +type GetApiSpecInput struct { + Name string `json:"name"` + Type string `json:"type"` +} + +type GetApiSpecOutput struct { + Apis []ApiSpec `json:"apis"` +} + +type ApiSpec struct { + Name string `json:"name"` + Type string `json:"type"` + Spec any `json:"spec"` +} + +func (s *Service) registerGetSpecTool(server *mcp.Server) { + inputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "The exact name of the API", + "optional": true, + }, + "type": map[string]any{ + "type": "string", + "description": "Filter APIs by type. Use 'http' for REST/OpenAPI APIs, 'kafka' for AsyncAPI topics, 'ldap' for directory services, or 'mail' for SMTP/IMAP.", + "enum": []string{"http", "kafka", "ldap", "mail"}, + "optional": true, + }, + }, + } + + outputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "apis": map[string]any{ + "type": "array", + "description": "The list of mocked apis", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "The name of the API", + }, + "type": map[string]any{ + "type": "string", + "description": "The type of the API", + "enum": []string{"http", "kafka", "ldap", "mail"}, + }, + "spec": map[string]any{ + "type": "any", + "description": "The specification of the API (e.g. OpenAPI or AsyncAPI", + }, + }, + }, + }, + }, + } + + registerTool(server, &mcp.Tool{ + Name: "mokapi_get_api_spec", + Description: `Retrieve API specifications from Mokapi. + +- DISCOVERY: Call without 'name' to get an overview of all available APIs (names and types). +- DETAILS: Call with a specific 'name' and 'type' to get the full specification (OpenAPI, AsyncAPI, etc.) including endpoints, schemas, and operations. + +Use discovery first if you are unsure which APIs are currently mocked.`, + InputSchema: inputSchema, + OutputSchema: outputSchema, + }, s.GetApiSpec) +} + +func (s *Service) GetApiSpec(_ context.Context, in GetApiSpecInput) (GetApiSpecOutput, error) { + var result []ApiSpec + + switch in.Type { + case "", "http", "kafka", "ldap", "mail": + break + default: + return GetApiSpecOutput{}, fmt.Errorf("unknown type: %s", in.Type) + } + + if in.Name == "" { + if in.Type == "http" || len(in.Type) == 0 { + for _, api := range s.app.ListHttp() { + if api.Info.Name == "" { + log.Warnf("mcp tool get_api_list: skip empty HTTTP API name") + continue + } + result = append(result, ApiSpec{ + Name: api.Info.Name, + Type: "http", + }) + } + } + + if in.Type == "kafka" || len(in.Type) == 0 { + for _, api := range s.app.Kafka.List() { + if api.Info.Name == "" { + log.Warnf("mcp tool get_api_list: skip empty Kafka API name") + continue + } + result = append(result, ApiSpec{ + Name: api.Info.Name, + Type: "kafka", + }) + } + } + + if in.Type == "ldap" || len(in.Type) == 0 { + for _, api := range s.app.Ldap.List() { + if api.Info.Name == "" { + log.Warnf("mcp tool get_api_list: skip empty LDAP API name") + continue + } + result = append(result, ApiSpec{ + Name: api.Info.Name, + Type: "ldap", + }) + } + } + + if in.Type == "mail" || len(in.Type) == 0 { + for _, api := range s.app.Mail.List() { + if api.Info.Name == "" { + log.Warnf("mcp tool get_api_list: skip empty Mail API name") + continue + } + result = append(result, ApiSpec{ + Name: api.Info.Name, + Type: "mail", + }) + } + } + return GetApiSpecOutput{Apis: result}, nil + } + + if in.Type == "http" || len(in.Type) == 0 { + info := s.app.GetHttp(in.Name) + if info != nil { + result = append(result, ApiSpec{ + Name: in.Name, + Type: "http", + Spec: info.Config, + }) + } + } + + if in.Type == "kafka" || len(in.Type) == 0 { + info := s.app.Kafka.Get(in.Name) + if info != nil { + result = append(result, ApiSpec{ + Name: in.Name, + Type: "kafka", + Spec: info.Config, + }) + } + } + + if in.Type == "ldap" || len(in.Type) == 0 { + info := s.app.Ldap.Get(in.Name) + if info != nil { + result = append(result, ApiSpec{ + Name: in.Name, + Type: "ldap", + Spec: info.Config, + }) + } + } + + if in.Type == "mail" || len(in.Type) == 0 { + info := s.app.Mail.Get(in.Name) + if info != nil { + result = append(result, ApiSpec{ + Name: in.Name, + Type: "mail", + Spec: info.Config, + }) + } + } + + return GetApiSpecOutput{Apis: result}, nil +} diff --git a/mcp/get_api_spec_test.go b/mcp/get_api_spec_test.go new file mode 100644 index 000000000..97203e57f --- /dev/null +++ b/mcp/get_api_spec_test.go @@ -0,0 +1,219 @@ +package mcp_test + +import ( + "context" + "mokapi/mcp" + "mokapi/providers/asyncapi3" + "mokapi/providers/asyncapi3/asyncapi3test" + "mokapi/providers/directory" + "mokapi/providers/mail" + "mokapi/providers/openapi" + "mokapi/providers/openapi/openapitest" + "mokapi/runtime" + "mokapi/runtime/runtimetest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestService_GetApiSpec(t *testing.T) { + testcases := []struct { + name string + app *runtime.App + test func(t *testing.T, s *mcp.Service) + }{ + { + name: "List APIs skip empty name", + app: runtimetest.NewHttpApp( + openapitest.NewConfig("3.1.0"), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{}) + require.NoError(t, err) + require.Len(t, r.Apis, 0) + }, + }, + { + name: "List APIs with HTTP", + app: runtimetest.NewHttpApp( + openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + ), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{}) + require.NoError(t, err) + require.Len(t, r.Apis, 1) + require.Equal(t, "foo", r.Apis[0].Name) + require.Equal(t, "http", r.Apis[0].Type) + }, + }, + { + name: "List APIs with Kafka", + app: runtimetest.NewApp( + runtimetest.WithKafka(asyncapi3test.NewConfig( + asyncapi3test.WithInfo("foo", "", ""), + )), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{}) + require.NoError(t, err) + require.Len(t, r.Apis, 1) + require.Equal(t, "foo", r.Apis[0].Name) + require.Equal(t, "kafka", r.Apis[0].Type) + }, + }, + { + name: "List APIs with Kafka and HTTP", + app: runtimetest.NewApp( + runtimetest.WithHttp(openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + )), + runtimetest.WithKafka(asyncapi3test.NewConfig( + asyncapi3test.WithInfo("bar", "", ""), + )), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{}) + require.NoError(t, err) + require.Len(t, r.Apis, 2) + require.Equal(t, "foo", r.Apis[0].Name) + require.Equal(t, "http", r.Apis[0].Type) + require.Equal(t, "bar", r.Apis[1].Name) + require.Equal(t, "kafka", r.Apis[1].Type) + }, + }, + { + name: "List APIs with Kafka and HTTP using filter", + app: runtimetest.NewApp( + runtimetest.WithHttp(openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + )), + runtimetest.WithKafka(asyncapi3test.NewConfig( + asyncapi3test.WithInfo("bar", "", ""), + )), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{Type: "kafka"}) + require.NoError(t, err) + require.Len(t, r.Apis, 1) + require.Equal(t, "bar", r.Apis[0].Name) + require.Equal(t, "kafka", r.Apis[0].Type) + }, + }, + { + name: "List APIs with LDAP", + app: runtimetest.NewApp( + runtimetest.WithLdap(&directory.Config{ + Info: directory.Info{Name: "foo"}, + }), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{}) + require.NoError(t, err) + require.Len(t, r.Apis, 1) + require.Equal(t, "foo", r.Apis[0].Name) + require.Equal(t, "ldap", r.Apis[0].Type) + }, + }, + { + name: "List APIs with Mail", + app: runtimetest.NewApp( + runtimetest.WithMail(&mail.Config{ + Info: mail.Info{Name: "foo"}, + }), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{}) + require.NoError(t, err) + require.Len(t, r.Apis, 1) + require.Equal(t, "foo", r.Apis[0].Name) + require.Equal(t, "mail", r.Apis[0].Type) + }, + }, + { + name: "Get HTTP API spec", + app: runtimetest.NewHttpApp( + openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + ), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{Name: "foo", Type: "http"}) + require.NoError(t, err) + require.IsType(t, &openapi.Config{}, r.Apis[0].Spec) + require.Equal(t, "foo", r.Apis[0].Spec.(*openapi.Config).Info.Name) + }, + }, + { + name: "Get HTTP API spec name does not exist", + app: runtimetest.NewHttpApp( + openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + ), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{Name: "bar", Type: "http"}) + require.NoError(t, err) + require.Len(t, r.Apis, 0) + }, + }, + { + name: "Get Kafka API spec", + app: runtimetest.NewApp( + runtimetest.WithKafka(asyncapi3test.NewConfig( + asyncapi3test.WithInfo("foo", "", ""), + )), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{Name: "foo", Type: "kafka"}) + require.NoError(t, err) + require.IsType(t, &asyncapi3.Config{}, r.Apis[0].Spec) + require.Equal(t, "foo", r.Apis[0].Spec.(*asyncapi3.Config).Info.Name) + }, + }, + { + name: "Get Mail API spec", + app: runtimetest.NewApp( + runtimetest.WithLdap(&directory.Config{Info: directory.Info{Name: "foo"}}), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{Name: "foo", Type: "ldap"}) + require.NoError(t, err) + require.IsType(t, &directory.Config{}, r.Apis[0].Spec) + require.Equal(t, "foo", r.Apis[0].Spec.(*directory.Config).Info.Name) + }, + }, + { + name: "Get Mail API spec", + app: runtimetest.NewApp( + runtimetest.WithMail(&mail.Config{Info: mail.Info{Name: "foo"}}), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{Name: "foo", Type: "mail"}) + require.NoError(t, err) + require.IsType(t, &mail.Config{}, r.Apis[0].Spec) + require.Equal(t, "foo", r.Apis[0].Spec.(*mail.Config).Info.Name) + }, + }, + { + name: "Get API spec unknown type", + app: runtimetest.NewHttpApp( + openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + ), + ), + test: func(t *testing.T, s *mcp.Service) { + _, err := s.GetApiSpec(context.Background(), mcp.GetApiSpecInput{Name: "bar", Type: "unknown"}) + require.EqualError(t, err, "unknown type: unknown") + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s := mcp.NewService(tc.app) + tc.test(t, s) + }) + } +} diff --git a/mcp/get_events.go b/mcp/get_events.go new file mode 100644 index 000000000..ca9d71608 --- /dev/null +++ b/mcp/get_events.go @@ -0,0 +1,131 @@ +package mcp + +import ( + "context" + "mokapi/runtime/events" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type GetEventsInput struct { + APIName string `json:"apiName"` + Type string `json:"type"` + Limit *int `json:"limit"` + Traits map[string]string `json:"traits"` +} + +type GetEventsResponse struct { + Events []events.Event `json:"events"` +} + +func (s *Service) registerGetEvents(server *mcp.Server) { + inputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "apiName": map[string]any{ + "type": "string", + "description": "Filter events by API name", + }, + "type": map[string]any{ + "type": "string", + "description": "Filter by event type", + "enum": []string{"http", "kafka"}, + }, + "traits": map[string]any{ + "type": "object", + "description": "Filter events by traits", + "additionalProperties": map[string]interface{}{ + "type": "string", + }, + }, + "limit": map[string]any{ + "type": "integer", + "description": "Maximum number of events to return", + "default": 10, + }, + }, + } + + outputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "events": map[string]any{ + "type": "array", + "description": "List of events", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{ + "type": "string", + "description": "ID of the event", + }, + "traits": map[string]any{ + "type": "object", + "description": "List of traits", + "additionalProperties": map[string]interface{}{ + "type": "string", + }, + }, + "data": map[string]any{ + "type": "object", + "description": "The data of the event", + }, + "time": map[string]any{ + "type": "string", + "description": "Time of the event", + "format": "date-time", + }, + }, + }, + }, + }, + } + + registerTool(server, &mcp.Tool{ + Name: "mokapi_get_events", + Description: `Returns recorded events from Mokapi including HTTP requests/responses and Kafka messages. + +Use this tool when the user asks: +- "What requests were made?" +- "Why did my request fail?" +- "Show recent API activity" +- "What messages were produced to Kafka?" + +Each event contains: +- metadata: id, time, traits +- HTTP data: request (method, URL, parameters, body) and response (status, headers, body, duration) +- Kafka data: message payload, key, headers, partition, offset + +Call this tool after sending requests or producing messages to inspect results and debug behavior.`, + InputSchema: inputSchema, + OutputSchema: outputSchema, + }, s.ProduceKafkaMessage) +} + +func (s *Service) GetEvents(_ context.Context, in GetEventsInput) (GetEventsResponse, error) { + result := GetEventsResponse{} + + traits, err := bindInput[events.Traits](in.Traits) + if err != nil { + return result, err + } + if traits == nil { + traits = events.NewTraits() + } + if in.Type != "" { + traits.WithNamespace(in.Type) + } + + evts := s.app.Events.GetEvents(traits) + + limit := 10 + if in.Limit != nil { + limit = *in.Limit + } + if len(evts) > limit { + result.Events = evts[0:limit] + } else { + result.Events = evts + } + return result, nil +} diff --git a/mcp/get_events_test.go b/mcp/get_events_test.go new file mode 100644 index 000000000..b5213a2b2 --- /dev/null +++ b/mcp/get_events_test.go @@ -0,0 +1,65 @@ +package mcp_test + +import ( + "context" + "mokapi/mcp" + "mokapi/providers/asyncapi3/asyncapi3test" + "mokapi/providers/asyncapi3/kafka/store" + "mokapi/runtime" + "mokapi/runtime/runtimetest" + "mokapi/schema/json/generator" + "mokapi/schema/json/schema/schematest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestService_GetEvents(t *testing.T) { + testcases := []struct { + name string + app *runtime.App + test func(t *testing.T, s *mcp.Service) + }{ + { + name: "Get Events", + app: runtimetest.NewApp( + runtimetest.WithKafka(asyncapi3test.NewConfig( + asyncapi3test.WithInfo("foo", "", ""), + asyncapi3test.WithChannel("topic-1", + asyncapi3test.WithMessage("msg", + asyncapi3test.WithPayload(schematest.New("string")), + ), + ), + )), + ), + test: func(t *testing.T, s *mcp.Service) { + _, err := s.ProduceKafkaMessage(context.Background(), mcp.ProduceKafkaMessageInput{ + APIName: "foo", + Topic: "topic-1", + Partition: 0, + Key: nil, + Value: "hello world", + }) + require.NoError(t, err) + + r, err := s.GetEvents(context.Background(), mcp.GetEventsInput{ + APIName: "foo", + Type: "kafka", + }) + require.NoError(t, err) + require.Len(t, r.Events, 1) + require.IsType(t, &store.KafkaMessageLog{}, r.Events[0].Data) + require.Equal(t, "hello world", r.Events[0].Data.(*store.KafkaMessageLog).Message.Value) + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + generator.Seed(12345) + + s := mcp.NewService(tc.app) + tc.test(t, s) + }) + } +} diff --git a/mcp/get_http_mock_template.go b/mcp/get_http_mock_template.go new file mode 100644 index 000000000..00323a5ac --- /dev/null +++ b/mcp/get_http_mock_template.go @@ -0,0 +1,335 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type GetHttpMockTemplateInput struct { + Scenario string `json:"scenario"` +} + +type GetHttpMockTemplateOutput struct { + Scenario string `json:"scenario"` + Description string `json:"description"` + Code string `json:"code"` +} + +func (s *Service) registerGetHttpMockTemplate(server *mcp.Server) { + inputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "scenario": map[string]any{ + "type": "string", + "description": "Scenario to generate boilerplate", + }, + }, + "required": []string{"scenario"}, + } + + registerTool(server, &mcp.Tool{ + Name: "mokapi_get_http_mock_template", + Description: `Templates demonstrate how to implement logic for HTTP mocks. + +This tool should be used AFTER calling 'get_scenarios' to find available scenarios. + +Use "generate_http_mock_response" to: +- Get a valid response structure based on OpenAPI +- Avoid guessing response formats + +You can: +- Replace result.data with custom data (e.g., from a list or database) +- Modify the response based on request parameters + +Typical pattern: +1. Apply your business logic (e.g., find a resource) +2. Call mokapi_generate_http_mock_response with the correct status code +3. Override result.data if needed +4. Assign result to response +`, + InputSchema: inputSchema, + }, s.GetHttpMockTemplate) +} + +func (s *Service) GetHttpMockTemplate(_ context.Context, in GetHttpMockTemplateInput) (GetHttpMockTemplateOutput, error) { + switch in.Scenario { + case "dynamic-path-params": + return GetHttpMockTemplateOutput{ + Scenario: "dynamic-path-params", + Description: `HTTP mock handler to get a pet stored in a array list. +Demonstrates how to: +- Access request parameters +- Apply custom logic (e.g., lookup, filtering)`, + Code: ` +import { on } from "mokapi" + +let pets = [ + { id: 1, name: 'Fluffy', status: 'available', category: { id: 1, name: 'Dogs' }, photoUrls: [], tags: [] }, + { id: 3, name: 'Hedgie', status: 'pending', category: { id: 2, name: 'Small Animals' }, photoUrls: [], tags: [] } +]; + +export default function () { + on('http', async (request, response) => { + switch(request.key) { + case '/pets/{id}': + if (request.method !== 'GET') { + return + } + const pet = pets.find(x => x.id === request.path.id) + if (pet) { + response.data = pet + } else { + console.log('pet not found', request) + response.rebuild(404) + } + } + }) +} +`, + }, nil + case "conditional-response": + return GetHttpMockTemplateOutput{ + Scenario: "conditional-response", + Description: `HTTP mock handler for terminals. +Demonstrates how to: +- Access request parameters +- Apply custom logic (e.g., lookup, filtering, updates) +`, + Code: ` +import { on } from "mokapi" + +interface Terminal { + id: string + compartments: { + id: string + doorState: 'open' | 'closed' + }[] +} + +const terminals: Terminal[] = [] + +export default function () { + on('http', (request, response) => { + switch(request.key) { + case '/terminals/{id}': { + const terminal = terminals.find(x => x.id === request.path.id) + if (!terminal) { + response.rebuild(404) + response.data = { error: 'terminal not found' } + return + } + + if (request.method === 'GET') { + response.data = terminal + } else if (request.method === 'POST') { + // update the terminal + Object.assign(terminal, request.body) + // mokapi already set the success response, nothing to do + } + // do not raise an error if different method is used, + // maybe there is another event handler in a different file defined + return + } + case '/terminals': { + if (request.method === 'GET') { + response.data = terminals + } else if (request.method === 'POST') { + const terminal = terminals.find(x => x.id === request.path.id) + if (terminal) { + // console output will be displayed in the Mokapi's' dashboard + console.log('terminal already exists', request.body) + response.rebuild(400) + } else { + terminals.push(request.body) + } + } + return + } + } + }) +} +`, + }, nil + case "static-error-simulation": + return GetHttpMockTemplateOutput{ + Scenario: "static-error-simulation", + Description: `Return predefined error responses (e.g., 400, 404, 500) for specific endpoints or conditions without dynamic logic.`, + Code: ` +import { on } from "mokapi" + +export default function () { + on('http', (request, response) => { + switch(request.key) { + case '/bookings': { + if (request.method === 'POST') { + if (request.header['Api-Key'] === 'invalid') { + // console output will be displayed in the Mokapi's' dashboard + console.log('api-key is not valid') + response.rebuild(401) + return + } + if (request.body?.hotel?.code === 'NOT_FOUND') { + console.log('hotel not found') + response.rebuild(404) + return + } + if (request.body.hotel.name === 'INVALID') { + console.log('hotel name is not valid') + response.rebuild(400) + return + } + } + } + } + }) +} +`, + }, nil + case "dynamic-error-simulation": + return GetHttpMockTemplateOutput{ + Scenario: "dynamic-error-simulation", + Description: `Return error responses based on runtime conditions, such as missing resources, validation failures, or conflicting state.`, + Code: ` +import { on } from "mokapi" + +const hotels = [] + +export default function () { + on('http', (request, response) => { + switch(request.key) { + case '/bookings': { + const hotel = hotels.find(x => x.code === request.body?.hotel?.code) + + if (!hotel) { + console.log('hotel not found') + response.rebuild(404) + response.data = { error: 'hotel not found' } + return + } + + // simulate dynamic errors based on hotel simulation config + const type = hotel.simulation?.responseType + switch (type) { + case 'bad-request': + response.rebuild(400) + return + case 'unauthorized': + response.rebuild(401) + return + case 'forbidden': + response.rebuild(403) + return + case 'internal-server-error': + response.rebuild(500) + return + } + // success path: generate valid response + // ... + } + } + }) +} +`, + }, nil + case "delay-latency": + return GetHttpMockTemplateOutput{ + Scenario: "delay-latency", + Description: `Simulate server latency by delaying the response. Useful to test frontend loading states, timeouts, or high-load scenarios. Use generate_http_mock_response for schema-compliant response data.`, + Code: ` +import { on } from "mokapi" + +let pets = [ + { id: 1, name: 'Fluffy', status: 'available', category: { id: 1, name: 'Dogs' }, photoUrls: [], tags: [] }, + { id: 3, name: 'Hedgie', status: 'pending', category: { id: 2, name: 'Small Animals' }, photoUrls: [], tags: [] } +]; + +export default function () { + on('http', async (request, response) => { + switch(request.key) { + case '/pets': { + if (request.method !== 'GET') return + + // simulate network latency (e.g., 2 seconds) + sleep('2s') + + response.data = pets + } + } + }) +} +`, + }, nil + case "forward-request-to-real-backend": + return GetHttpMockTemplateOutput{ + Scenario: "forward-request-to-real-backend", + Description: `Stop API drift in its tracks. Use Mokapi as a validation layer to enforce OpenAPI contracts between clients and backends, +regardless of who's calling or what they're building. This scenario forwards incoming requests to real backend services while +validating both requests and responses against the OpenAPI specification.`, + Code: `import { on } from 'mokapi'; +import { fetch } from 'mokapi/http'; + +export default async function () { + + on('http', async (request, response) => { + + // Map request to backend URL based on OpenAPI spec name + const url = getForwardUrl(request) + + // If no URL could be determined, return an error immediately + if (!url) { + response.statusCode = 500; + response.body = 'Failed to forward request: unknown backend'; + return; + } + + try { + // Forward the request to the backend + const res = await fetch(url, { + method: request.method, + body: request.body, + headers: request.header, + timeout: '30s' + }); + + // Copy status code and headers + response.statusCode = res.statusCode; + response.headers = res.headers + + // Check the content type to decide whether to validate the response + const contentType = res.headers['Content-Type']?.[0] || ''; + + if (contentType.includes('application/json')) { + // Mokapi can validate JSON responses automatically + response.data = res.json(); + } else { + // For other content types, skip validation + response.body = res.body; + } + + } catch (e) { + // Handle any errors that occur while forwarding + response.statusCode = 500; + response.body = e.toString(); + } + }); + + function getForwardUrl(request: HttpRequest): string | undefined { + switch (request.api) { + case 'backend-1': { + return 'https://backend1.example.com' + request.url.path + '?' + request.url.query; + } + case 'backend-2': { + return 'https://backend2.example.com' + request.url.path + '?' + request.url.query; + } + default: + return undefined; + } + } +}`, + }, nil + } + + return GetHttpMockTemplateOutput{}, fmt.Errorf("unknown scenario") +} diff --git a/mcp/get_http_response_schema.go b/mcp/get_http_response_schema.go new file mode 100644 index 000000000..11e616e3f --- /dev/null +++ b/mcp/get_http_response_schema.go @@ -0,0 +1,102 @@ +package mcp + +import ( + "context" + "fmt" + "mokapi/media" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type GetHttpResponseSchemaInput struct { + ApiName string `json:"apiName"` + Path string `json:"path"` + Method string `json:"method"` + StatusCode int `json:"statusCode"` + ContentType string `json:"contentType,omitempty"` +} + +func (s *Service) registerGetHttpResponseSchemaTool(server *mcp.Server) { + inputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "apiName": map[string]any{ + "type": "string", + "description": "The exact name of the API as returned by 'get_api_list'", + }, + "path": map[string]any{ + "type": "string", + "description": "The path template of the endpoint (e.g. /pets/{id})", + }, + "method": map[string]any{ + "type": "string", + "description": "The HTTP method (GET, POST, PUT, DELETE, etc.)", + }, + "statusCode": map[string]any{ + "type": "integer", + "description": "The HTTP status code", + }, + "contentType": map[string]any{ + "type": "string", + "description": `The HTTP content type of the response body. Optional: + If provided, this content type is used. + If the endpoint has only one content type, it will be used automatically. + Otherwise defaults to 'application/json'`, + "default": "application/json", + }, + }, + } + + registerTool(server, &mcp.Tool{ + Name: "get_http_response_schema", + Description: `Get the HTTP response body schema for a specific API endpoint. + +Use this tool **before generating any HTTP mock script**. +The returned schema defines all required fields, types, and nested structures. +All mock responses must strictly conform to this schema. Do not omit required fields or invent extra ones. +`, + InputSchema: inputSchema, + }, s.GetHttpResponseSchema) +} + +func (s *Service) GetHttpResponseSchema(_ context.Context, in GetHttpResponseSchemaInput) (any, error) { + info := s.app.GetHttp(in.ApiName) + if info == nil { + return nil, fmt.Errorf("http api not found") + } + p, ok := info.Paths[in.Path] + if !ok || p.Value == nil { + return nil, fmt.Errorf("path not found") + } + o := p.Value.Operation(in.Method) + if o == nil { + return nil, fmt.Errorf("operation not found") + } + r := o.Responses.GetResponse(in.StatusCode) + if r == nil { + return nil, fmt.Errorf("response not found") + } + + n := len(r.Content) + if n == 0 { + return nil, fmt.Errorf("response has no content") + } + if n == 1 && in.ContentType == "" { + for _, v := range r.Content { + return v.Schema, nil + } + } + contentType := "application/json" + if in.ContentType != "" { + contentType = in.ContentType + } + mt := media.ParseContentType(contentType) + for k, v := range r.Content { + key := media.ParseContentType(k) + if mt.Match(key) { + return v.Schema, nil + } + } + + return nil, fmt.Errorf("content type not found") +} diff --git a/mcp/get_http_response_schema_test.go b/mcp/get_http_response_schema_test.go new file mode 100644 index 000000000..d3068bfd3 --- /dev/null +++ b/mcp/get_http_response_schema_test.go @@ -0,0 +1,60 @@ +package mcp_test + +import ( + "context" + "mokapi/mcp" + "mokapi/providers/openapi/openapitest" + "mokapi/providers/openapi/schema" + "mokapi/providers/openapi/schema/schematest" + "mokapi/runtime" + "mokapi/runtime/runtimetest" + jsonSchema "mokapi/schema/json/schema" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestService_GetHttpResponseSchema(t *testing.T) { + testcases := []struct { + name string + app *runtime.App + test func(t *testing.T, s *mcp.Service) + }{ + { + name: "Get Response Schema", + app: runtimetest.NewApp( + runtimetest.WithHttp(openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + openapitest.WithPath("/foo", + openapitest.WithOperation(http.MethodGet, + openapitest.WithResponse(http.StatusOK, + openapitest.WithContent("application/json", + openapitest.WithSchema(schematest.New("string")), + ), + ), + ), + ), + )), + ), + test: func(t *testing.T, s *mcp.Service) { + result, err := s.GetHttpResponseSchema(context.Background(), mcp.GetHttpResponseSchemaInput{ + ApiName: "foo", + Path: "/foo", + Method: http.MethodGet, + StatusCode: http.StatusOK, + }) + require.NoError(t, err) + require.IsType(t, &schema.Schema{}, result) + require.Equal(t, jsonSchema.Types{"string"}, result.(*schema.Schema).Type) + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s := mcp.NewService(tc.app) + tc.test(t, s) + }) + } +} diff --git a/mcp/get_mokapi_typescript_api.go b/mcp/get_mokapi_typescript_api.go new file mode 100644 index 000000000..fd64162b5 --- /dev/null +++ b/mcp/get_mokapi_typescript_api.go @@ -0,0 +1,548 @@ +package mcp + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type GetMokapiTypeScriptApiInput struct { + Package string `json:"package"` +} + +type GetMokapiTypeScriptApiOutput struct { + Packages []Package `json:"packages"` +} + +type Package struct { + Name string `json:"name"` + Description string `json:"description"` + Types string `json:"types"` +} + +func (s *Service) registerGetMokapiTypeScriptApi(server *mcp.Server) { + inputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "package": map[string]any{ + "type": "string", + "description": "Filter package by name.", + "enum": []string{"mokapi", "mokapi/http", "mokapi/kafka", "mokapi/faker", "mokapi/file"}, + "optional": true, + }, + }, + } + + outputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "packages": map[string]any{ + "type": "array", + "description": "The list of packages", + "items": map[string]any{ + "type": "object", + "description": "The package with the TypeScript types", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "The name of the package", + "enum": []string{"mokapi", "mokapi/http", "mokapi/kafka", "mokapi/faker", "mokapi/file"}, + }, + "description": map[string]any{ + "type": "string", + "description": "The description of the package", + }, + "types": map[string]any{ + "type": "string", + "description": "The types of the package", + }, + }, + "required": []any{"name", "description"}, + }, + }, + }, + "required": []string{"packages"}, + } + + registerTool(server, &mcp.Tool{ + Name: "mokapi_get_typescript_api", + Description: `Returns TypeScript definitions for a specific Mokapi package. + +- DISCOVERY: Call without 'package' to get an overview of all available APIs (names and descriptions). +- DETAILS: Call with a specific 'name' to get the full type definitions. + +The returned types define: +- Event handler signatures +- Request and response structures +- Available properties + +Use the returned types to implement the mock script. +Combine this with "mokapi_get_scenarios" to understand correct usage patterns.`, + InputSchema: inputSchema, + OutputSchema: outputSchema, + }, s.GetMokapiTypeScriptApi) +} + +func (s *Service) GetMokapiTypeScriptApi(_ context.Context, in GetMokapiTypeScriptApiInput) (GetMokapiTypeScriptApiOutput, error) { + if in.Package == "" { + return GetMokapiTypeScriptApiOutput{ + Packages: []Package{ + { + Name: "mokapi", + Description: `Mokapi JavaScript API +This module exposes the core scripting API for Mokapi. +It allows you to intercept and manipulate protocol events (HTTP, Kafka, LDAP, SMTP), +schedule jobs, generate mock data, and share state between scripts.`, + }, + { + Name: "mokapi/http", + Description: `Utilities for sending HTTP requests and handling HTTP interactions within Mokapi scripts. +Use these functions to simulate client calls, test API integrations, or trigger endpoints from scripts.`, + }, + { + Name: "mokapi/kafka", + Description: `Utilities for producing and consuming messages on Kafka topics. +This package allows you to mock message streams, inspect events, and simulate Kafka-based workflows.`, + }, + { + Name: "mokapi/faker", + Description: `Generates realistic random test data based on JSON schemas or attribute names. +Use this to populate mock responses or generate dynamic content for API responses.`, + }, + { + Name: "mokapi/file", + Description: `File system utilities for reading, writing, and manipulating files within Mokapi scripts. +Useful for mocking file-based APIs, loading fixtures, or storing script state.`, + }, + }, + }, nil + } + + switch in.Package { + case "mokapi": + return GetMokapiTypeScriptApiOutput{ + Packages: []Package{ + { + Name: "mokapi", + Description: `Mokapi JavaScript API +This module exposes the core scripting API for Mokapi. +It allows you to intercept and manipulate protocol events (HTTP, Kafka, LDAP, SMTP), +schedule jobs, generate mock data, and share state between scripts.`, + Types: pkgMokapi, + }, + }, + }, nil + case "mokapi/http": + return GetMokapiTypeScriptApiOutput{ + Packages: []Package{ + { + Name: "mokapi/http", + Description: `Utilities for sending HTTP requests and handling HTTP interactions within Mokapi scripts. +Use these functions to simulate client calls, test API integrations, or trigger endpoints from scripts.`, + Types: pkgHttp, + }, + }, + }, nil + case "mokapi/kafka": + return GetMokapiTypeScriptApiOutput{ + Packages: []Package{ + { + Name: "mokapi/kafka", + Description: `Utilities for producing and consuming messages on Kafka topics. +This package allows you to mock message streams, inspect events, and simulate Kafka-based workflows.`, + Types: pkgKafka, + }, + }, + }, nil + case "mokapi/faker": + return GetMokapiTypeScriptApiOutput{ + Packages: []Package{ + { + Name: "mokapi/faker", + Description: `Generates realistic random test data based on JSON schemas or attribute names. +Use this to populate mock responses or generate dynamic content for API responses.`, + Types: pkgFaker, + }, + }, + }, nil + case "mokapi/file": + return GetMokapiTypeScriptApiOutput{ + Packages: []Package{ + { + Name: "mokapi/file", + Description: `File system utilities for reading, writing, and manipulating files within Mokapi scripts. +Useful for mocking file-based APIs, loading fixtures, or storing script state.`, + Types: pkgFile, + }, + }, + }, nil + } + return GetMokapiTypeScriptApiOutput{}, nil +} + +const ( + pkgMokapi = ` +export function on(event: T, handler: EventHandler[T], args?: TypedEventArgs[T]): void; + +export function every(interval: Interval, f: ScheduledEventHandler, args?: ScheduledEventArgs): void; + +export function cron(expr: string, f: ScheduledEventHandler, args?: ScheduledEventArgs): void; + +export function env(name: string): string; + +export function date(args?: DateArgs): string; + +export function sleep(time: number | string): void; + +export type Interval = string; + +export interface EventHandler { + http: HttpEventHandler; + kafka: KafkaEventHandler; + ldap: LdapEventHandler; + smtp: SmtpEventHandler; +} + +export type HttpEventHandler = (request: HttpRequest, response: HttpResponse) => void | Promise; + +export interface HttpRequest { + readonly method: string; + readonly url: Url; + readonly body: any; + readonly path: { [key: string]: any }; + readonly query: { [key: string]: any }; + readonly header: { [key: string]: any }; + readonly cookie: { [key: string]: any }; + readonly querystring: any; + readonly api: string; + readonly key: string; + readonly operationId: string; + toString(): string; +} + +export interface HttpResponse { + headers: { [key: string]: any }; + statusCode: number; + body: string; + data: any; + rebuild: (statusCode?: number, contentType?: string) => void; +} + +export interface Url { + readonly scheme: string; + readonly host: string; + readonly port: number; + readonly path: string; + readonly query: string; + toString(): string; +} + +export type KafkaEventHandler = (message: KafkaEventMessage) => void | Promise; + +export interface KafkaEventMessage { + readonly offset: number; + key: string; + value: string; + headers: { [name: string]: string } | null; +} + +export type LdapEventHandler = (request: LdapSearchRequest, response: LdapSearchResponse) => void | Promise; + +export interface LdapSearchRequest { + baseDN: string; + scope: LdapSearchScope; + dereferencePolicy: number; + sizeLimit: number; + timeLimit: number; + typesOnly: number; + filter: string; + attributes: string[]; +} +export interface LdapSearchResponse { + results: LdapSearchResult[]; + status: LdapResultStatus; + message: string; +} + +export interface LdapSearchResult { + dn: string; + attributes: { [name: string]: string[] }; +} + +export enum LdapSearchScope { + BaseObject, + SingleLevel, + WholeSubtree, +} + +export enum LdapResultStatus { + Success = 0, + OperationsError = 1, + ProtocolError = 2, + SizeLimitExceeded = 4, +} + +export type SmtpEventHandler = (record: SmtpEventMessage) => void | Promise; + +export interface SmtpEventMessage { + server: string; + sender?: Address; + from: Address[]; + to: Address[]; + replyTo?: Address[]; + cc?: Address[]; + bcc?: Address[]; + messageId: string; + inReplyTo?: string; + time?: Date; + subject: string; + contentType: string; + encoding: string; + body: string; + attachments: Attachment[]; +} + +export interface Address { + name?: string; + address: string; +} + +export interface Attachment { + name: string; + contentType: string; + data: Uint8Array; +} + +export interface DateArgs { + layout?: DateLayout | string; + timestamp?: number; +} + +export type DateLayout = + | "DateTime" + | "DateOnly" + | "TimeOnly" + | "UnixDate" + | "RFC882" + | "RFC822Z" + | "RFC850" + | "RFC1123" + | "RFC1123Z" + | "RFC3339" + | "RFC3339Nano"; + +export interface EventArgs { + tags?: { [key: string]: string }; + priority?: number; +} + +export interface TypedEventArgs { + http: HttpEventArgs; + kafka: KafkaEventArgs; + ldap: LdapEventArgs; + smtp: SmtpEventArgs; +} + +export interface HttpEventArgs extends EventArgs { + track?: boolean | ((request: HttpRequest, response: HttpResponse) => boolean); +} + +export interface KafkaEventArgs extends EventArgs { + track?: boolean | ((message: KafkaEventMessage) => boolean); +} + +export interface LdapEventArgs extends EventArgs { + track?: boolean | ((request: LdapSearchRequest, response: LdapSearchResponse) => boolean); +} + +export interface SmtpEventArgs extends EventArgs { + track?: boolean | ((record: SmtpEventMessage) => boolean); +} + +export type ScheduledEventHandler = () => void | Promise; + +export interface ScheduledEventArgs { + tags?: { [key: string]: string }; + times?: number; + runFirstTimeImmediately?: boolean; +} + +export const RFC3339 = "RFC3339"; + +export function patch(target: any, patch: any): any; + +export const Delete: unique symbol; + +export interface SharedMemory { + get(key: string): any; + set(key: string, value: any): void; + update(key: string, updater: (value: T | undefined) => T): T; + has(key: string): boolean; + delete(key: string): void; + clear(): void; + keys(): string[]; + namespace(name: string): SharedMemory; +} + +export const shared: SharedMemory; +` + pkgHttp = `export function get(url: string, args?: Args): Response; +export function post(url: string, body?: any, args?: Args): Response; +export function put(url: string, body?: any, args?: Args): Response; +export function head(url: string, args?: Args): Response; +export function patch(url: string, body?: any, args?: Args): Response; +export function del(url: string, body?: any, args?: Args): Response; +export function options(url: string, body?: any, args?: Args): Response; +export function fetch(url: string, opts?: FetchOptions): Promise + +export interface FetchOptions { + method?: string; + body?: any; + headers?: { [name: string]: string }; + maxRedirects?: number; + timeout?: number | string; +} + +export interface Args { + headers?: { [name: string]: string }; + maxRedirects?: number; + timeout?: number | string; +} + +export interface Response { + body: string; + statusCode: number; + headers: { [name: string]: string[] }; + json(): JSONValue; +} +` + pkgKafka = `export function produce(args?: ProduceArgs): ProduceResult; +export function produceAsync(args?: ProduceArgs): Promise; +export interface ProduceArgs { + cluster?: string; + topic?: string; + messages?: Message[]; + retry?: ProduceRetry; +} + +export interface Message { + partition?: number; + key?: any; + data?: any; + value?: string | number | boolean | null; + headers?: { [name: string]: any }; +} + +export interface ProduceResult { + readonly cluster: string; + readonly topic: string; + messages: MessageResult[]; + readonly partition: number; + readonly offset: number; + readonly key: string; + readonly value: string; + readonly headers: { [name: string]: string }; +} + +export interface MessageResult { + readonly partition: number; + readonly offset: number; + readonly key: string; + readonly value: string; + readonly headers: { [name: string]: string }; +} + +export interface ProduceRetry { + maxRetryTime: string | number; + initialRetryTime: string | number; + factor: number; + retries: number; +}` + pkgFaker = `export function fake(schema: Schema | JSONSchema): any; + +export function findByName(name: string): Node; +export const ROOT_NAME = "root"; +export interface Node { + name: string; + attributes: string[]; + weight: number; + children: Array; + fake: (r: Request) => any; +} + +export interface AddNode { + name: string; + attributes?: string[]; + weight?: number; + children?: Array; + fake: (r: Request) => any; +} + +export interface Request { + path: string[]; + schema: JSONSchema; + context: Context; +} + +export interface Context { + values: { [name: string]: any }; +} + +export interface JSONSchema { + type?: SchemaType | SchemaType[]; + enum?: any[]; + const?: any; + examples?: any[]; + default?: any; + multipleOf?: number; + maximum?: number; + exclusiveMaximum?: number; + minimum?: number; + exclusiveMinimum?: number; + maxLength?: number; + minLength?: number; + pattern?: string; + format?: string; + items?: JSONSchema; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; + properties?: { [name: string]: JSONSchema }; + maxProperties?: number; + minProperties?: number; + required?: string[]; + additionalProperties?: boolean | JSONSchema; + allOf?: JSONSchema[]; + anyOf?: JSONSchema[]; + oneOf?: JSONSchema[]; +} + +export type SchemaType = "object" | "array" | "number" | "integer" | "string" | "boolean" | "null"; + +export interface Schema { + type?: SchemaType | SchemaType[]; + format?: string; + pattern?: string; + minLength?: number; + maxLength?: number; + items?: Schema; + required?: string[]; + enum?: any[]; + minimum?: number; + maximum?: number; + exclusiveMinimum?: number | boolean; + exclusiveMaximum?: number | boolean; + properties?: { [name: string]: Schema }; + additionalProperties?: boolean | Schema | undefined; + anyOf?: Schema[]; + allOf?: Schema[]; + oneOf?: Schema[]; + minItems?: number; + maxItems?: number; + shuffleItems?: boolean; + uniqueItems?: boolean; +}` + pkgFile = `export function read(path: string): string; +export function writeString(path: string, s: string): void; +export function appendString(path: string, s: string): void;` +) diff --git a/mcp/get_scenarios.go b/mcp/get_scenarios.go new file mode 100644 index 000000000..d26343335 --- /dev/null +++ b/mcp/get_scenarios.go @@ -0,0 +1,53 @@ +package mcp + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func (s *Service) registerGetScenarios(server *mcp.Server) { + registerTool(server, &mcp.Tool{ + Name: "mokapi_get_scenarios", + Description: `Lists available scenarios for generating Mokapi scripts. + +Use this tool BEFORE calling template tools (e.g., "mokapi_get_http_mock_template") +to discover supported scenarios. + +Typical workflow: +1. Call this tool to find a suitable scenario +2. Call the corresponding template tool with the selected scenario +3. Adapt the template to your use case`, + }, s.GetScenarios) +} + +func (s *Service) GetScenarios(_ context.Context, _ any) (map[string]any, error) { + return map[string]any{ + "http": []map[string]any{ + { + "name": "dynamic-path-params", + "description": "Access and use path parameters (e.g., /pets/{petId}) to retrieve or process specific resources based on request.path values.", + }, + { + "name": "conditional-response", + "description": "Return different responses based on request data (path, query, headers, or body), such as selecting resources, updating state, or handling different HTTP methods.", + }, + { + "name": "static-error-simulation", + "description": "Return predefined error responses (e.g., 400, 404, 500) for specific endpoints or conditions without dynamic logic.", + }, + { + "name": "dynamic-error-simulation", + "description": "Return error responses based on runtime conditions, such as missing resources, validation failures, or conflicting state.", + }, + { + "name": "delay-latency", + "description": "Simulate network latency or slow backend processing by delaying the response before returning data or errors.", + }, + { + "name": "forward-request-to-real-backend", + "description": "Stop API drift in its tracks. Use Mokapi as a validation layer to enforce OpenAPI contracts between clients and backends,\nregardless of who's calling or what they're building. This scenario forwards incoming requests to real backend services while\nvalidating both requests and responses against the OpenAPI specification.", + }, + }, + }, nil +} diff --git a/mcp/helpers.go b/mcp/helpers.go new file mode 100644 index 000000000..ca38b094b --- /dev/null +++ b/mcp/helpers.go @@ -0,0 +1,35 @@ +package mcp + +import ( + "context" + "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerTool[In any, Out any](server *mcp.Server, tool *mcp.Tool, handler func(ctx context.Context, input In) (Out, error)) { + mcp.AddTool(server, tool, func(ctx context.Context, request *mcp.CallToolRequest, input In) (result *mcp.CallToolResult, output Out, _ error) { + in, err := bindInput[In](input) + if err != nil { + return nil, *new(Out), err + } + + out, err := handler(ctx, in) + return nil, out, err + }) +} + +func bindInput[In any](input any) (In, error) { + if i, ok := input.(In); ok { + return i, nil + } + + var result In + b, err := json.Marshal(input) + if err != nil { + return result, err + } + + err = json.Unmarshal(b, &result) + return result, err +} diff --git a/mcp/list_apis.go b/mcp/list_apis.go new file mode 100644 index 000000000..dc40f0368 --- /dev/null +++ b/mcp/list_apis.go @@ -0,0 +1,124 @@ +package mcp + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" + log "github.com/sirupsen/logrus" +) + +type ListApisInput struct { + Type string `json:"type,omitempty"` +} + +type Api struct { + Name string `json:"name"` + Type string `json:"type"` +} + +type ListApiResponse struct { + Apis []Api `json:"apis"` +} + +func (s *Service) registerListApiTool(server *mcp.Server) { + inputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "type": map[string]any{ + "type": "string", + "description": "Filter APIs by type. Use 'http' for REST/OpenAPI APIs, 'kafka' for AsyncAPI topics, 'ldap' for directory services, or 'mail' for SMTP/IMAP.", + "enum": []string{"http", "kafka", "ldap", "mail"}, + }, + }, + } + + outputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "apis": map[string]any{ + "type": "array", + "description": "The list of mocked apis", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "The name of the API", + }, + "type": map[string]any{ + "type": "string", + "description": "The type of the API", + "enum": []string{"http", "kafka", "ldap", "mail"}, + }, + }, + }, + }, + }, + } + + registerTool(server, &mcp.Tool{ + Name: "get_api_list", + Description: `Returns all available APIs with their name and type. + Use this to discover APIs before calling 'get_api_spec' to retrieve detailed specifications.`, + InputSchema: inputSchema, + OutputSchema: outputSchema, + }, s.ListApis) +} + +func (s *Service) ListApis(_ context.Context, in ListApisInput) (*ListApiResponse, error) { + var result []Api + + if in.Type == "http" || len(in.Type) == 0 { + for _, api := range s.app.ListHttp() { + if api.Info.Name == "" { + log.Warnf("mcp tool get_api_list: skip empty HTTTP API name") + continue + } + result = append(result, Api{ + Name: api.Info.Name, + Type: "http", + }) + } + } + + if in.Type == "kafka" || len(in.Type) == 0 { + for _, api := range s.app.Kafka.List() { + if api.Info.Name == "" { + log.Warnf("mcp tool get_api_list: skip empty Kafka API name") + continue + } + result = append(result, Api{ + Name: api.Info.Name, + Type: "kafka", + }) + } + } + + if in.Type == "ldap" || len(in.Type) == 0 { + for _, api := range s.app.Ldap.List() { + if api.Info.Name == "" { + log.Warnf("mcp tool get_api_list: skip empty LDAP API name") + continue + } + result = append(result, Api{ + Name: api.Info.Name, + Type: "ldap", + }) + } + } + + if in.Type == "mail" || len(in.Type) == 0 { + for _, api := range s.app.Mail.List() { + if api.Info.Name == "" { + log.Warnf("mcp tool get_api_list: skip empty Mail API name") + continue + } + result = append(result, Api{ + Name: api.Info.Name, + Type: "mail", + }) + } + } + + return &ListApiResponse{Apis: result}, nil +} diff --git a/mcp/produce_kafka_message.go b/mcp/produce_kafka_message.go new file mode 100644 index 000000000..da25da8bc --- /dev/null +++ b/mcp/produce_kafka_message.go @@ -0,0 +1,109 @@ +package mcp + +import ( + "context" + "mokapi/engine" + "mokapi/engine/common" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type ProduceKafkaMessageInput struct { + APIName string `json:"apiName"` + Topic string `json:"topic"` + Partition int `json:"partition"` + Key any `json:"key,omitempty"` + Value any `json:"value"` + Headers map[string]string `json:"headers,omitempty"` + ClientId string `json:"clientId"` +} + +type ProduceKafkaMessageResponse struct { + Offset int64 `json:"offset"` +} + +func (s *Service) registerProduceKafkaMessage(server *mcp.Server) { + inputSchema := map[string]any{ + "type": "object", + "required": []string{"apiName", "topic", "value"}, + "properties": map[string]any{ + "apiName": map[string]any{ + "type": "string", + "description": "The name of the Kafka API as returned by 'get_api_list'", + }, + "topic": map[string]any{ + "type": "string", + "description": "Kafka topic name", + }, + "partition": map[string]any{ + "type": "integer", + "description": "Partition number where to write the message to", + }, + "key": map[string]any{ + "description": "Optional message key", + }, + "value": map[string]any{ + "description": "Message payload", + }, + "headers": map[string]any{ + "type": "object", + "description": "Optional message headers", + "additionalProperties": map[string]any{ + "type": "string", + }, + }, + "clientId": map[string]any{ + "type": "string", + "description": "ClientId of the producer", + }, + }, + } + + outputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "offset": map[string]any{ + "type": "integer", + "description": "The offset of the produced message", + }, + }, + } + + registerTool(server, &mcp.Tool{ + Name: "mokapi_produce_kafka_message", + Description: `Produce a message to a Kafka topic. + +Use this tool after retrieving the API specification with 'mokapi_get_api_spec' to understand available topics and message formats. + +Allows sending messages with optional key and headers.`, + InputSchema: inputSchema, + OutputSchema: outputSchema, + }, s.ProduceKafkaMessage) +} + +func (s *Service) ProduceKafkaMessage(_ context.Context, in ProduceKafkaMessageInput) (ProduceKafkaMessageResponse, error) { + result := ProduceKafkaMessageResponse{} + + c := engine.NewKafkaClient(s.app) + r, err := c.Produce(&common.KafkaProduceArgs{ + Cluster: in.APIName, + Topic: in.Topic, + Messages: []common.KafkaMessage{ + { + Key: in.Key, + Data: in.Value, + Headers: in.Headers, + Partition: in.Partition, + }, + }, + Retry: common.KafkaProduceRetry{}, + ClientId: in.ClientId, + }) + if err != nil { + return result, err + } + if len(r.Messages) > 0 { + result.Offset = r.Messages[0].Offset + } + return result, nil +} diff --git a/mcp/produce_kafka_message_test.go b/mcp/produce_kafka_message_test.go new file mode 100644 index 000000000..70bac5869 --- /dev/null +++ b/mcp/produce_kafka_message_test.go @@ -0,0 +1,88 @@ +package mcp_test + +import ( + "context" + "mokapi/mcp" + "mokapi/providers/asyncapi3/asyncapi3test" + "mokapi/runtime" + "mokapi/runtime/runtimetest" + "mokapi/schema/json/generator" + "mokapi/schema/json/schema/schematest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestService_ProduceKafkaMessage(t *testing.T) { + testcases := []struct { + name string + app *runtime.App + test func(t *testing.T, s *mcp.Service) + }{ + { + name: "Produce Kafka Message", + app: runtimetest.NewApp( + runtimetest.WithKafka(asyncapi3test.NewConfig( + asyncapi3test.WithInfo("foo", "", ""), + asyncapi3test.WithChannel("topic-1", + asyncapi3test.WithMessage("msg", + asyncapi3test.WithPayload(schematest.New("string")), + ), + ), + )), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.ProduceKafkaMessage(context.Background(), mcp.ProduceKafkaMessageInput{ + APIName: "foo", + Topic: "topic-1", + Partition: 0, + Key: nil, + Value: "hello world", + }) + require.NoError(t, err) + require.Equal(t, int64(0), r.Offset) + r, err = s.ProduceKafkaMessage(context.Background(), mcp.ProduceKafkaMessageInput{ + APIName: "foo", + Topic: "topic-1", + Partition: 0, + Key: nil, + Value: "hello world 2", + }) + require.NoError(t, err) + require.Equal(t, int64(1), r.Offset) + }, + }, + { + name: "Produce Kafka Message but topic does not exist", + app: runtimetest.NewApp( + runtimetest.WithKafka(asyncapi3test.NewConfig( + asyncapi3test.WithInfo("foo", "", ""), + asyncapi3test.WithChannel("topic-1", + asyncapi3test.WithMessage("msg", + asyncapi3test.WithPayload(schematest.New("string")), + ), + ), + )), + ), + test: func(t *testing.T, s *mcp.Service) { + _, err := s.ProduceKafkaMessage(context.Background(), mcp.ProduceKafkaMessageInput{ + APIName: "foo", + Topic: "topic-2", + Partition: 0, + Key: nil, + Value: "hello world", + }) + require.EqualError(t, err, "kafka topic 'topic-2' not found") + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + generator.Seed(12345) + + s := mcp.NewService(tc.app) + tc.test(t, s) + }) + } +} diff --git a/mcp/send_http_request.go b/mcp/send_http_request.go new file mode 100644 index 000000000..0ef86af8f --- /dev/null +++ b/mcp/send_http_request.go @@ -0,0 +1,146 @@ +package mcp + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type SendHttpRequestInput struct { + APIName string `json:"apiName"` + Method string `json:"method"` + Path string `json:"path"` + Query map[string]string `json:"query,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + +type SendHttpRequestResponse struct { + Status int `json:"status"` + Headers map[string][]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + +func (s *Service) registerSendHttpRequest(server *mcp.Server) { + inputSchema := map[string]any{ + "type": "object", + "required": []string{"apiName", "method", "path"}, + "properties": map[string]any{ + "apiName": map[string]any{ + "type": "string", + "description": "The name of the API as returned by 'get_api_list'", + }, + "method": map[string]any{ + "type": "string", + "description": "HTTP method to use", + "enum": []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + }, + "path": map[string]any{ + "type": "string", + "description": "The endpoint path, e.g. /pets or /pets/{id}", + }, + "query": map[string]any{ + "type": "object", + "description": "Query parameters as key-value pairs", + "additionalProperties": map[string]any{ + "type": "string", + }, + }, + "headers": map[string]any{ + "type": "object", + "description": "HTTP headers as key-value pairs", + "additionalProperties": map[string]any{ + "type": "string", + }, + }, + "body": map[string]any{ + "type": "string", + "description": "Request body (JSON object, string, number, etc.)", + }, + }, + } + + outputSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "status": map[string]any{ + "type": "integer", + "description": "HTTP status code", + }, + "headers": map[string]any{ + "type": "object", + "description": "Response headers", + "additionalProperties": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + }, + }, + "body": map[string]any{ + "description": "Response body", + }, + }, + } + + registerTool(server, &mcp.Tool{ + Name: "mokapi_send_http_request", + Description: `Send an HTTP request to a mocked API. + +Use this tool AFTER retrieving the API specification with 'mokapi_get_api_spec' to understand available endpoints. + +Supports GET, POST, PUT, PATCH, and DELETE requests. +Returns the full response including status code, headers, and body.`, + InputSchema: inputSchema, + OutputSchema: outputSchema, + }, s.SendHttpRequest) +} + +func (s *Service) SendHttpRequest(_ context.Context, in SendHttpRequestInput) (SendHttpRequestResponse, error) { + result := SendHttpRequestResponse{Headers: make(map[string][]string)} + + info := s.app.GetHttp(in.APIName) + if info == nil { + return result, fmt.Errorf("API '%s' not found", in.APIName) + } + + h := info.Handler(s.app.Monitor.Http, s.app.Engine, s.app.Events) + + var body io.Reader + if in.Body != "" { + body = strings.NewReader(in.Body) + } + + r, err := http.NewRequest(in.Method, in.Path, body) + if err != nil { + return result, fmt.Errorf("error creating request: %w", err) + } + + he := h.ServeHTTP(&result, r) + if he != nil { + result.Status = he.StatusCode + if he.StatusCode == http.StatusNotFound && strings.HasPrefix(he.Message, "no matching endpoint found") { + result.Body = fmt.Sprintf("path '%v' not found", in.Path) + } else { + result.Body = he.Message + } + } + return result, nil +} + +func (r *SendHttpRequestResponse) Header() http.Header { + return r.Headers +} + +func (r *SendHttpRequestResponse) WriteHeader(statusCode int) { + r.Status = statusCode +} + +func (r *SendHttpRequestResponse) Write(body []byte) (int, error) { + r.Body = string(body) + return len(body), nil +} diff --git a/mcp/send_http_request_test.go b/mcp/send_http_request_test.go new file mode 100644 index 000000000..1a4656c7c --- /dev/null +++ b/mcp/send_http_request_test.go @@ -0,0 +1,70 @@ +package mcp_test + +import ( + "context" + "mokapi/mcp" + "mokapi/providers/openapi/openapitest" + "mokapi/providers/openapi/schema/schematest" + "mokapi/runtime" + "mokapi/runtime/runtimetest" + "mokapi/schema/json/generator" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestService_SendHttpRequest(t *testing.T) { + testcases := []struct { + name string + app *runtime.App + test func(t *testing.T, s *mcp.Service) + }{ + { + name: "GET request path not specified", + app: runtimetest.NewHttpApp( + openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + ), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.SendHttpRequest(context.Background(), mcp.SendHttpRequestInput{APIName: "foo", Method: "GET", Path: "/foo"}) + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, r.Status) + require.Equal(t, "path '/foo' not found", r.Body) + }, + }, + { + name: "GET request", + app: runtimetest.NewHttpApp( + openapitest.NewConfig("3.1.0", + openapitest.WithInfo("foo", "", ""), + openapitest.WithPath("/foo", + openapitest.WithOperation(http.MethodGet, + openapitest.WithResponse(http.StatusOK, + openapitest.WithContent("application/json", + openapitest.WithSchema(schematest.New("string")), + ), + ), + ), + ), + ), + ), + test: func(t *testing.T, s *mcp.Service) { + r, err := s.SendHttpRequest(context.Background(), mcp.SendHttpRequestInput{APIName: "foo", Method: "GET", Path: "/foo"}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, r.Status) + require.Equal(t, `"Ln8rnaRqlL"`, r.Body) + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + generator.Seed(12345) + + s := mcp.NewService(tc.app) + tc.test(t, s) + }) + } +} diff --git a/mcp/server.go b/mcp/server.go new file mode 100644 index 000000000..8945930ea --- /dev/null +++ b/mcp/server.go @@ -0,0 +1,51 @@ +package mcp + +import ( + "fmt" + "mokapi/config/static" + "mokapi/runtime" + "mokapi/version" + "net/http" + "net/url" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type Service struct { + app *runtime.App +} + +func NewService(app *runtime.App) *Service { + return &Service{app: app} +} + +func NewServer(app *runtime.App) http.Handler { + server := mcp.NewServer(&mcp.Implementation{ + Name: "mokapi-mcp", + Version: version.BuildVersion, + }, nil) + + svc := NewService(app) + svc.registerGetSpecTool(server) + + svc.registerGenerateHttpMockResponseTool(server) + + svc.registerSendHttpRequest(server) + svc.registerProduceKafkaMessage(server) + + svc.registerGetEvents(server) + + svc.registerGetMokapiTypeScriptApi(server) + svc.registerGetScenarios(server) + svc.registerGetHttpMockTemplate(server) + + return mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return server }, + &mcp.StreamableHTTPOptions{}, + ) +} + +func BuildUrl(cfg static.McpServer) (*url.URL, error) { + s := fmt.Sprintf("http://:%v%v", cfg.Port, cfg.Path) + return url.Parse(s) +} diff --git a/mcp/server_test.go b/mcp/server_test.go new file mode 100644 index 000000000..30611f6a3 --- /dev/null +++ b/mcp/server_test.go @@ -0,0 +1,44 @@ +package mcp_test + +import ( + "context" + "mokapi/mcp" + "mokapi/runtime/runtimetest" + "net/http/httptest" + "testing" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +func TestServer(t *testing.T) { + ctx := context.Background() + defer ctx.Done() + + h := mcp.NewServer(runtimetest.NewApp()) + s := httptest.NewServer(h) + defer s.Close() + + client := gomcp.NewClient(&gomcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil) + + transport := &gomcp.StreamableClientTransport{ + Endpoint: s.URL, + } + session, err := client.Connect(ctx, transport, nil) + require.NoError(t, err) + require.NotNil(t, session) + defer func() { _ = session.Close() }() + + list, err := session.ListTools(ctx, &gomcp.ListToolsParams{}) + require.NoError(t, err) + require.Len(t, list.Tools, 8) + // alphabetical order + require.Equal(t, "mokapi_generate_http_mock_response", list.Tools[0].Name) + require.Equal(t, "mokapi_get_api_spec", list.Tools[1].Name) + require.Equal(t, "mokapi_get_events", list.Tools[2].Name) + require.Equal(t, "mokapi_get_http_mock_template", list.Tools[3].Name) + require.Equal(t, "mokapi_get_scenarios", list.Tools[4].Name) + require.Equal(t, "mokapi_get_typescript_api", list.Tools[5].Name) + require.Equal(t, "mokapi_produce_kafka_message", list.Tools[6].Name) + require.Equal(t, "mokapi_send_http_request", list.Tools[7].Name) +} diff --git a/npm/go-mokapi/types/index.d.ts b/npm/go-mokapi/types/index.d.ts index 328e5d640..34d14410a 100644 --- a/npm/go-mokapi/types/index.d.ts +++ b/npm/go-mokapi/types/index.d.ts @@ -267,7 +267,7 @@ export type KafkaEventHandler = (message: KafkaEventMessage) => void | Promise 0 { - r.Value.Summary = patch.Value.Summary + if len(patch.Summary) > 0 { + p.Summary = patch.Summary } - if len(patch.Value.Description) > 0 { - r.Value.Description = patch.Value.Description + if len(patch.Description) > 0 { + p.Description = patch.Description } - if r.Value.Get == nil { - r.Value.Get = patch.Value.Get + if p.Get == nil { + p.Get = patch.Get } else { - r.Value.Get.patch(patch.Value.Get) + p.Get.patch(patch.Get) } - if r.Value.Post == nil { - r.Value.Post = patch.Value.Post + if p.Post == nil { + p.Post = patch.Post } else { - r.Value.Post.patch(patch.Value.Post) + p.Post.patch(patch.Post) } - if r.Value.Put == nil { - r.Value.Put = patch.Value.Put + if p.Put == nil { + p.Put = patch.Put } else { - r.Value.Put.patch(patch.Value.Put) + p.Put.patch(patch.Put) } - if r.Value.Patch == nil { - r.Value.Patch = patch.Value.Patch + if p.Patch == nil { + p.Patch = patch.Patch } else { - r.Value.Patch.patch(patch.Value.Patch) + p.Patch.patch(patch.Patch) } - if r.Value.Delete == nil { - r.Value.Delete = patch.Value.Delete + if p.Delete == nil { + p.Delete = patch.Delete } else { - r.Value.Delete.patch(patch.Value.Delete) + p.Delete.patch(patch.Delete) } - if r.Value.Head == nil { - r.Value.Head = patch.Value.Head + if p.Head == nil { + p.Head = patch.Head } else { - r.Value.Head.patch(patch.Value.Head) + p.Head.patch(patch.Head) } - if r.Value.Options == nil { - r.Value.Options = patch.Value.Options + if p.Options == nil { + p.Options = patch.Options } else { - r.Value.Options.patch(patch.Value.Options) + p.Options.patch(patch.Options) } - if r.Value.Trace == nil { - r.Value.Trace = patch.Value.Trace + if p.Trace == nil { + p.Trace = patch.Trace } else { - r.Value.Trace.patch(patch.Value.Trace) + p.Trace.patch(patch.Trace) } - r.Value.Parameters.Patch(patch.Value.Parameters) + p.Parameters.Patch(patch.Parameters) } diff --git a/providers/openapi/response.go b/providers/openapi/response.go index 6add45a78..c33ea62b7 100644 --- a/providers/openapi/response.go +++ b/providers/openapi/response.go @@ -82,6 +82,14 @@ func (r *ResponseRef) UnmarshalJSON(b []byte) error { return r.Reference.UnmarshalJson(b, &r.Value) } +func (r *ResponseRef) MarshalJSON() ([]byte, error) { + if r.Value != nil { + return json.Marshal(r.Value) + } else { + return json.Marshal(r.Ref) + } +} + func (r *Responses) UnmarshalYAML(value *yaml.Node) error { if value.Kind != yaml.MappingNode { return fmt.Errorf("expected openapi.Responses map, got %v", value.Tag) diff --git a/runtime/runtime.go b/runtime/runtime.go index 754514f4e..cbb5ae2a4 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -3,6 +3,7 @@ package runtime import ( "mokapi/config/dynamic" "mokapi/config/static" + "mokapi/engine/common" "mokapi/runtime/events" "mokapi/runtime/monitor" "mokapi/runtime/search" @@ -22,6 +23,7 @@ type App struct { Monitor *monitor.Monitor Events *events.StoreManager + Engine common.EventEmitter m sync.Mutex cfg *static.Config diff --git a/runtime/runtimetest/app.go b/runtime/runtimetest/app.go index 5a7b12140..de44f4faa 100644 --- a/runtime/runtimetest/app.go +++ b/runtime/runtimetest/app.go @@ -7,20 +7,14 @@ import ( "mokapi/config/static" "mokapi/engine/enginetest" "mokapi/providers/asyncapi3" + "mokapi/providers/directory" + "mokapi/providers/mail" "mokapi/providers/openapi" "mokapi/runtime" ) func NewHttpApp(configs ...*openapi.Config) *runtime.App { - cfg := &static.Config{} - app := runtime.New(cfg, &dynamictest.Reader{}) - for i, cfg := range configs { - app.AddHttp(&dynamic.Config{ - Info: dynamictest.NewConfigInfo(dynamictest.WithUrl(fmt.Sprintf("%d", i))), - Data: cfg, - }) - } - return app + return NewApp(WithHttp(configs...)) } type Options func(app *runtime.App) @@ -30,38 +24,84 @@ type HttpInfoOptions func(hi *runtime.HttpInfo) type KafkaInfoOptions func(ki *runtime.KafkaInfo) func NewKafkaApp(configs ...*asyncapi3.Config) *runtime.App { - cfg := &static.Config{} - app := runtime.New(cfg, &dynamictest.Reader{}) - for i, cfg := range configs { - _, _ = app.Kafka.Add(&dynamic.Config{ - Info: dynamictest.NewConfigInfo(dynamictest.WithUrl(fmt.Sprintf("%d", i))), - Data: cfg, - }, enginetest.NewEngine()) - } - return app + return NewApp(WithKafka(configs...)) } func NewApp(opts ...Options) *runtime.App { cfg := &static.Config{} app := runtime.New(cfg, &dynamictest.Reader{}) + app.Engine = enginetest.NewEngine() for _, opt := range opts { opt(app) } return app } +func WithHttp(configs ...*openapi.Config) Options { + return func(app *runtime.App) { + for i, cfg := range configs { + app.AddHttp(&dynamic.Config{ + Info: dynamictest.NewConfigInfo(dynamictest.WithUrl(fmt.Sprintf("%d", i))), + Data: cfg, + }) + } + } +} + +func WithKafka(configs ...*asyncapi3.Config) Options { + return func(app *runtime.App) { + for i, cfg := range configs { + c := &dynamic.Config{ + Info: dynamictest.NewConfigInfo(dynamictest.WithUrl(fmt.Sprintf("%d", i))), + Data: cfg, + } + + _, err := app.Kafka.Add(c, app.Engine) + if err != nil { + panic(err) + } + } + } +} + func WithKafkaInfo(name string, ki *runtime.KafkaInfo) Options { return func(app *runtime.App) { app.Kafka.Set(name, ki) } } +func WithLdap(configs ...*directory.Config) Options { + return func(app *runtime.App) { + for i, cfg := range configs { + c := &dynamic.Config{ + Info: dynamictest.NewConfigInfo(dynamictest.WithUrl(fmt.Sprintf("%d", i))), + Data: cfg, + } + + app.Ldap.Add(c, app.Engine) + } + } +} + func WithLdapInfo(name string, li *runtime.LdapInfo) Options { return func(app *runtime.App) { app.Ldap.Set(name, li) } } +func WithMail(configs ...*mail.Config) Options { + return func(app *runtime.App) { + for i, cfg := range configs { + c := &dynamic.Config{ + Info: dynamictest.NewConfigInfo(dynamictest.WithUrl(fmt.Sprintf("%d", i))), + Data: cfg, + } + + app.Mail.Add(c) + } + } +} + func WithMailInfo(name string, mi *runtime.MailInfo) Options { return func(app *runtime.App) { app.Mail.Set(name, mi) diff --git a/schema/json/generator/date.go b/schema/json/generator/date.go index 57cd2de3a..d4a8c7501 100644 --- a/schema/json/generator/date.go +++ b/schema/json/generator/date.go @@ -1,8 +1,9 @@ package generator import ( - "github.com/brianvoe/gofakeit/v6" "time" + + "github.com/brianvoe/gofakeit/v6" ) func dates() []*Node { @@ -155,13 +156,17 @@ func fakeDateWithYearRange(r *Request, min time.Time, maxYear int) (any, error) year := gofakeit.IntRange(min.Year(), maxYear) minMonth := int(min.Month()) month := gofakeit.Number(minMonth, 12) - if year == time.Now().Year() { - month = gofakeit.Number(minMonth, 12) - } minDay := min.Day() + 1 if minDay > maxDayInMonth[month-1] { minDay = 1 - month += 1 + if month == 12 { + month = 1 + if year == min.Year() { + year++ + } + } else { + month += 1 + } } day := gofakeit.Number(minDay, maxDayInMonth[month-1]) diff --git a/schema/json/generator/date_test.go b/schema/json/generator/date_test.go index 959dd8f07..32a5a250a 100644 --- a/schema/json/generator/date_test.go +++ b/schema/json/generator/date_test.go @@ -2,6 +2,7 @@ package generator import ( "mokapi/schema/json/schema/schematest" + "strings" "testing" "time" @@ -168,3 +169,10 @@ func TestStringDate(t *testing.T) { }) } } + +func TestFakeDateWithYearRange_NewYearsEve(t *testing.T) { + r, err := fakeDateWithYearRange(&Request{}, time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC), 2027) + require.NoError(t, err) + require.NotEqual(t, "", r) + require.True(t, strings.HasPrefix(r.(string), "2027")) +} diff --git a/schema/json/parser/parser.go b/schema/json/parser/parser.go index 453d3fa61..9cfaca690 100644 --- a/schema/json/parser/parser.go +++ b/schema/json/parser/parser.go @@ -43,14 +43,14 @@ func (p *Parser) Parse(data interface{}) (interface{}, error) { } func (p *Parser) parse(data interface{}, s *schema.Schema) (interface{}, error) { - if s == nil { - return data, nil - } - if e, ok := data.(Exportable); ok { data = e.Export() } + if s == nil { + return data, nil + } + if s.Boolean != nil { if *s.Boolean { return data, nil diff --git a/schema/json/parser/parser_object.go b/schema/json/parser/parser_object.go index 35974570a..6e4e5e729 100644 --- a/schema/json/parser/parser_object.go +++ b/schema/json/parser/parser_object.go @@ -9,8 +9,8 @@ import ( "regexp" "regexp/syntax" "sort" - "strings" "strconv" + "strings" ) func (p *Parser) parseObject(data interface{}, s *schema.Schema, evaluated map[string]bool) (*sortedmap.LinkedHashMap[string, interface{}], error) { @@ -253,7 +253,11 @@ func (p *Parser) parseMap(v reflect.Value, s *schema.Schema, evaluated map[strin name := fmt.Sprintf("%v", k.Interface()) if _, found := obj.Get(name); !found { o := v.MapIndex(k) - obj.Set(name, o.Interface()) + val := o.Interface() + if e, ok := val.(Exportable); ok { + val = e.Export() + } + obj.Set(name, val) } } } diff --git a/schema/json/parser/parser_test.go b/schema/json/parser/parser_test.go index 764bc1d9f..900dfdcf0 100644 --- a/schema/json/parser/parser_test.go +++ b/schema/json/parser/parser_test.go @@ -276,3 +276,60 @@ func TestParser_Null(t *testing.T) { }) } } + +type exportable struct { + export func() any +} + +func (e *exportable) Export() any { + return e.export() +} + +func TestParser_Exportable(t *testing.T) { + testcases := []struct { + name string + data interface{} + schema *schema.Schema + test func(t *testing.T, v interface{}, err error) + }{ + { + name: "schema integer", + data: &exportable{export: func() any { return 123 }}, + schema: schematest.New("integer"), + test: func(t *testing.T, v interface{}, err error) { + require.NoError(t, err) + require.Equal(t, int64(123), v) + }, + }, + { + name: "no schema", + data: &exportable{export: func() any { return 123 }}, + schema: nil, + test: func(t *testing.T, v interface{}, err error) { + require.NoError(t, err) + require.Equal(t, 123, v) + }, + }, + { + name: "exportable as additional property", + data: map[string]interface{}{"foo": &exportable{export: func() any { return 123 }}}, + schema: schematest.New("object"), + test: func(t *testing.T, v interface{}, err error) { + require.NoError(t, err) + require.Equal(t, map[string]any{"foo": 123}, v) + }, + }, + } + + t.Parallel() + for _, tc := range testcases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := &parser.Parser{Schema: tc.schema} + v, err := p.Parse(tc.data) + tc.test(t, v, err) + }) + } +} diff --git a/version/version.go b/version/version.go index c4b28f2e1..f348ad541 100644 --- a/version/version.go +++ b/version/version.go @@ -84,6 +84,10 @@ func (v *Version) UnmarshalJSON(b []byte) error { return nil } +func (v *Version) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%v.%v.%v"`, v.Major, v.Minor, v.Patch)), nil +} + func (v *Version) UnmarshalYAML(value *yaml.Node) error { var s string err := value.Decode(&s) diff --git a/webui/package-lock.json b/webui/package-lock.json index 2bdaeb0d0..e37b80e65 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -22,10 +22,12 @@ "del-cli": "^7.0.0", "express": "^5.2.1", "fuse.js": "^7.1.0", + "highlight.js": "^11.11.1", "http-status-codes": "^2.3.0", "js-yaml": "^4.1.1", "kafkajs": "^2.2.4", "ldapts": "^8.1.7", + "markdown-it": "^14.1.1", "markdown-it-container": "^4.0.0", "mime-types": "^3.0.2", "ncp": "^2.0.0", @@ -33,10 +35,8 @@ "vue": "^3.5.30", "vue-router": "^5.0.4", "vue3-ace-editor": "^2.2.4", - "vue3-highlightjs": "^1.0.5", - "vue3-markdown-it": "^1.0.10", "whatwg-mimetype": "^5.0.0", - "xml-formatter": "^3.6.7" + "xml-formatter": "^3.7.0" }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -44,11 +44,11 @@ "@types/js-yaml": "^4.0.9", "@types/markdown-it-container": "^4.0.0", "@types/node": "^25.5.0", - "@vitejs/plugin-vue": "^6.0.4", + "@vitejs/plugin-vue": "^6.0.5", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", - "@vue/tsconfig": "^0.9.0", - "eslint": "^10.0.3", + "@vue/tsconfig": "^0.9.1", + "eslint": "^10.1.0", "eslint-plugin-vue": "^10.8.0", "npm-run-all": "^4.1.5", "prettier": "^3.8.1", @@ -238,13 +238,13 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", - "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.0" + "@eslint/core": "^1.1.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -835,12 +835,14 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, "license": "MIT", "dependencies": { "@types/linkify-it": "^5", @@ -861,6 +863,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, "license": "MIT" }, "node_modules/@types/mokapi": { @@ -1137,9 +1140,9 @@ } }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", - "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", "dev": true, "license": "MIT", "dependencies": { @@ -1149,7 +1152,7 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, @@ -1413,13 +1416,13 @@ "license": "MIT" }, "node_modules/@vue/tsconfig": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.0.tgz", - "integrity": "sha512-RP+v9Cpbsk1ZVXltCHHkYBr7+624x6gcijJXVjIcsYk7JXqvIpRtMwU2ARLvWDhmy9ffdFYxhsfJnPztADBohQ==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", "dev": true, "license": "MIT", "peerDependencies": { - "typescript": "5.x", + "typescript": ">= 5.8", "vue": "^3.4.0" }, "peerDependenciesMeta": { @@ -2714,16 +2717,16 @@ } }, "node_modules/eslint": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", - "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.2", + "@eslint/config-helpers": "^0.5.3", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", @@ -2736,7 +2739,7 @@ "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", + "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2933,9 +2936,9 @@ } }, "node_modules/espree": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.1.tgz", - "integrity": "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3546,12 +3549,12 @@ } }, "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "license": "BSD-3-Clause", "engines": { - "node": "*" + "node": ">=12.0.0" } }, "node_modules/hookable": { @@ -4487,12 +4490,12 @@ } }, "node_modules/linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", "dependencies": { - "uc.micro": "^1.0.1" + "uc.micro": "^2.0.0" } }, "node_modules/load-json-file": { @@ -4544,12 +4547,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.flow": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", - "integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==", - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4575,35 +4572,20 @@ } }, "node_modules/markdown-it": { - "version": "12.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", - "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "~2.1.0", - "linkify-it": "^3.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, "bin": { - "markdown-it": "bin/markdown-it.js" - } - }, - "node_modules/markdown-it-abbr": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/markdown-it-abbr/-/markdown-it-abbr-1.0.4.tgz", - "integrity": "sha512-ZeA4Z4SaBbYysZap5iZcxKmlPL6bYA8grqhzJIHB1ikn7njnzaP8uwbtuXc4YXD5LicI4/2Xmc0VwmSiFV04gg==", - "license": "MIT" - }, - "node_modules/markdown-it-anchor": { - "version": "8.6.7", - "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", - "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", - "license": "Unlicense", - "peerDependencies": { - "@types/markdown-it": "*", - "markdown-it": "*" + "markdown-it": "bin/markdown-it.mjs" } }, "node_modules/markdown-it-container": { @@ -4612,84 +4594,14 @@ "integrity": "sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==", "license": "MIT" }, - "node_modules/markdown-it-deflist": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/markdown-it-deflist/-/markdown-it-deflist-2.1.0.tgz", - "integrity": "sha512-3OuqoRUlSxJiuQYu0cWTLHNhhq2xtoSFqsZK8plANg91+RJQU1ziQ6lA2LzmFAEes18uPBsHZpcX6We5l76Nzg==", - "license": "MIT" - }, - "node_modules/markdown-it-emoji": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-2.0.2.tgz", - "integrity": "sha512-zLftSaNrKuYl0kR5zm4gxXjHaOI3FAOEaloKmRA5hijmJZvSjmxcokOLlzycb/HXlUFWzXqpIEoyEMCE4i9MvQ==", - "license": "MIT" - }, - "node_modules/markdown-it-footnote": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/markdown-it-footnote/-/markdown-it-footnote-3.0.3.tgz", - "integrity": "sha512-YZMSuCGVZAjzKMn+xqIco9d1cLGxbELHZ9do/TSYVzraooV8ypsppKNmUJ0fVH5ljkCInQAtFpm8Rb3eXSrt5w==", - "license": "MIT" - }, - "node_modules/markdown-it-highlightjs": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/markdown-it-highlightjs/-/markdown-it-highlightjs-3.6.0.tgz", - "integrity": "sha512-ex+Lq3cVkprh0GpGwFyc53A/rqY6GGzopPCG1xMsf8Ya3XtGC8Uw9tChN1rWbpyDae7tBBhVHVcMM29h4Btamw==", - "license": "Unlicense", - "dependencies": { - "highlight.js": "^11.3.1", - "lodash.flow": "^3.5.0" - } - }, - "node_modules/markdown-it-highlightjs/node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/markdown-it-ins": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/markdown-it-ins/-/markdown-it-ins-3.0.1.tgz", - "integrity": "sha512-32SSfZqSzqyAmmQ4SHvhxbFqSzPDqsZgMHDwxqPzp+v+t8RsmqsBZRG+RfRQskJko9PfKC2/oxyOs4Yg/CfiRw==", - "license": "MIT" - }, - "node_modules/markdown-it-mark": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/markdown-it-mark/-/markdown-it-mark-3.0.1.tgz", - "integrity": "sha512-HyxjAu6BRsdt6Xcv6TKVQnkz/E70TdGXEFHRYBGLncRE9lBFwDNLVtFojKxjJWgJ+5XxUwLaHXy+2sGBbDn+4A==", - "license": "MIT" - }, - "node_modules/markdown-it-sub": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/markdown-it-sub/-/markdown-it-sub-1.0.0.tgz", - "integrity": "sha512-z2Rm/LzEE1wzwTSDrI+FlPEveAAbgdAdPhdWarq/ZGJrGW/uCQbKAnhoCsE4hAbc3SEym26+W2z/VQB0cQiA9Q==", - "license": "MIT" - }, - "node_modules/markdown-it-sup": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/markdown-it-sup/-/markdown-it-sup-1.0.0.tgz", - "integrity": "sha512-E32m0nV9iyhRR7CrhnzL5msqic7rL1juWre6TQNxsnApg7Uf+F97JOKxUijg5YwXz86lZ0mqfOnutoryyNdntQ==", - "license": "MIT" - }, - "node_modules/markdown-it-task-lists": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz", - "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==", - "license": "ISC" - }, - "node_modules/markdown-it-toc-done-right": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/markdown-it-toc-done-right/-/markdown-it-toc-done-right-4.2.0.tgz", - "integrity": "sha512-UB/IbzjWazwTlNAX0pvWNlJS8NKsOQ4syrXZQ/C72j+jirrsjVRT627lCaylrKJFBQWfRsPmIVQie8x38DEhAQ==", - "license": "MIT" - }, "node_modules/markdown-it/node_modules/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } @@ -4704,9 +4616,9 @@ } }, "node_modules/mdurl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, "node_modules/media-typer": { @@ -5611,6 +5523,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -6675,9 +6596,9 @@ } }, "node_modules/uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, "node_modules/ufo": { @@ -7081,39 +7002,6 @@ "vue": "^3" } }, - "node_modules/vue3-highlightjs": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/vue3-highlightjs/-/vue3-highlightjs-1.0.5.tgz", - "integrity": "sha512-Q4YNPXu0X5VMBnwPVOk+IQf1Ohp9jFdMitEAmzaz8qVVefcQpN6Dx4BnDGKxja3TLDVF+EgL136wC8YzmoCX9w==", - "license": "ISC", - "dependencies": { - "highlight.js": "^10.3.2" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/vue3-markdown-it": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/vue3-markdown-it/-/vue3-markdown-it-1.0.10.tgz", - "integrity": "sha512-mTvHu0zl7jrh7ojgaZ+tTpCLiS4CVg4bTgTu4KGhw/cRRY5YgIG8QgFAPu6kCzSW6Znc9a52Beb6hFvF4hSMkQ==", - "license": "MIT", - "dependencies": { - "markdown-it": "^12.3.2", - "markdown-it-abbr": "^1.0.4", - "markdown-it-anchor": "^8.4.1", - "markdown-it-deflist": "^2.1.0", - "markdown-it-emoji": "^2.0.0", - "markdown-it-footnote": "^3.0.3", - "markdown-it-highlightjs": "^3.6.0", - "markdown-it-ins": "^3.0.1", - "markdown-it-mark": "^3.0.1", - "markdown-it-sub": "^1.0.0", - "markdown-it-sup": "^1.0.0", - "markdown-it-task-lists": "^2.1.1", - "markdown-it-toc-done-right": "^4.2.0" - } - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -7251,9 +7139,9 @@ "license": "ISC" }, "node_modules/xml-formatter": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/xml-formatter/-/xml-formatter-3.6.7.tgz", - "integrity": "sha512-IsfFYJQuoDqtUlKhm4EzeoBOb+fQwzQVeyxxAQ0sThn/nFnQmyLPTplqq4yRhaOENH/tAyujD2TBfIYzUKB6hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/xml-formatter/-/xml-formatter-3.7.0.tgz", + "integrity": "sha512-+8qTc3zv2UcJ1v9IsSIce37Dl4MQG14Cp7tWrwmy202UaI1wqRukw5QMX1JHsV+DX64yw77EgGsj2s5wGvuMbQ==", "license": "MIT", "dependencies": { "xml-parser-xo": "^4.1.5" diff --git a/webui/package.json b/webui/package.json index 99aff81c1..825cfd99e 100644 --- a/webui/package.json +++ b/webui/package.json @@ -31,10 +31,12 @@ "del-cli": "^7.0.0", "express": "^5.2.1", "fuse.js": "^7.1.0", + "highlight.js": "^11.11.1", "http-status-codes": "^2.3.0", "js-yaml": "^4.1.1", "kafkajs": "^2.2.4", "ldapts": "^8.1.7", + "markdown-it": "^14.1.1", "markdown-it-container": "^4.0.0", "mime-types": "^3.0.2", "ncp": "^2.0.0", @@ -42,10 +44,8 @@ "vue": "^3.5.30", "vue-router": "^5.0.4", "vue3-ace-editor": "^2.2.4", - "vue3-highlightjs": "^1.0.5", - "vue3-markdown-it": "^1.0.10", "whatwg-mimetype": "^5.0.0", - "xml-formatter": "^3.6.7" + "xml-formatter": "^3.7.0" }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -53,11 +53,11 @@ "@types/js-yaml": "^4.0.9", "@types/markdown-it-container": "^4.0.0", "@types/node": "^25.5.0", - "@vitejs/plugin-vue": "^6.0.4", + "@vitejs/plugin-vue": "^6.0.5", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.7.0", - "@vue/tsconfig": "^0.9.0", - "eslint": "^10.0.3", + "@vue/tsconfig": "^0.9.1", + "eslint": "^10.1.0", "eslint-plugin-vue": "^10.8.0", "npm-run-all": "^4.1.5", "prettier": "^3.8.1", diff --git a/webui/src/components/dashboard/SchemaValidate.vue b/webui/src/components/dashboard/SchemaValidate.vue index efdf624be..2178aed52 100644 --- a/webui/src/components/dashboard/SchemaValidate.vue +++ b/webui/src/components/dashboard/SchemaValidate.vue @@ -1,7 +1,7 @@