diff --git a/.asyncapi-tool.json b/.asyncapi-tool.json index c2ddc1965..b04b0f9f0 100644 --- a/.asyncapi-tool.json +++ b/.asyncapi-tool.json @@ -3,7 +3,7 @@ "description": "Your API Mocking Tool for Agile Development. Mock and simulate AsyncAPI and OpenAPI services for local testing, development, and CI pipelines — with a visual dashboard to inspect your mocks and a powerful test data generator fully customizable with JavaScript.", "links": { "websiteUrl": "https://mokapi.io", - "docsUrl": "https://mokapi.io/docs/guides/welcome", + "docsUrl": "https://mokapi.io/docs/welcome", "repoUrl": "https://github.com/marle3003/mokapi" }, "filters": { diff --git a/.github/actions/build-cli-flags-doc/action.yaml b/.github/actions/build-cli-flags-doc/action.yaml index eb0df5230..dd6b09b94 100644 --- a/.github/actions/build-cli-flags-doc/action.yaml +++ b/.github/actions/build-cli-flags-doc/action.yaml @@ -2,17 +2,11 @@ name: "Build Mokapi CLI Flags doc" runs: using: composite steps: - - name: Create directory and file + - name: Create dist directory and file shell: bash run: | mkdir -p webui/dist touch webui/dist/.tmp - name: Build CLI doc shell: bash - run: go run ./cmd/internal/gen-cli-docs/main.go - - name: Add CLI Flags nav entry - working-directory: ./docs - shell: bash - run: | - jq '.Configuration.items.Static.items["CLI Flags"] = "configuration/static/mokapi.md"' ./config.json > ./tmp.json - mv ./tmp.json ./config.json \ No newline at end of file + run: go run ./cmd/internal/gen-cli-docs/main.go \ No newline at end of file diff --git a/.github/actions/build-release-notes/action.yml b/.github/actions/build-release-notes/action.yml index 34da9ee25..e50f4a591 100644 --- a/.github/actions/build-release-notes/action.yml +++ b/.github/actions/build-release-notes/action.yml @@ -9,7 +9,7 @@ runs: VERSION=$(jq -r '.release.tag_name' "$GITHUB_EVENT_PATH") BODY=$(jq -r '.release.body' "$GITHUB_EVENT_PATH") { - echo "# Mokapi $VERSION" + echo "## Mokapi $VERSION" echo echo "$BODY" } > docs/release.md diff --git a/.github/actions/publish-website/action.yml b/.github/actions/publish-website/action.yml index 569e9dbe7..7c5c9c6a4 100644 --- a/.github/actions/publish-website/action.yml +++ b/.github/actions/publish-website/action.yml @@ -51,25 +51,12 @@ runs: if: always() run: docker stop mokapi || true shell: bash - - name: build webui + - uses: ./.github/actions/build-cli-flags-doc + - name: build website working-directory: ./webui run: | npm install npm version ${{ steps.release.outputs.release }} - npm run build - shell: bash - - name: Build CLI doc - run: go run ./cmd/internal/gen-cli-docs/main.go - shell: bash - - name: Add CLI Flags nav entry - run: | - jq '.Configuration.items.Static.items["CLI Flags"] = "configuration/static/mokapi.md"' ./config.json > ./tmp.json - mv ./tmp.json ./config.json - working-directory: ./docs - shell: bash - - name: build website - working-directory: ./webui - run: | npm run copy-docs npm run build-sitemap npm run build-website diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5e39cc46..49ee05b56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,6 @@ jobs: - uses: actions/setup-go@v5 with: go-version: 1.25.5 - - uses: ./.github/actions/build-cli-flags-doc - uses: ./.github/actions/build-release-notes - uses: actions/setup-node@v4 with: diff --git a/README.md b/README.md index 00da4ef74..05ff5d17b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

Download · - Documentation + Documentation

# 🚀 Overview @@ -99,32 +99,32 @@ Mokapi’s dashboard lets you visualize your mock APIs. View requests and respon Explore tutorials that walk you through mocking different protocols and scenarios: -- 🌍 [Get started with REST API](https://mokapi.io/docs/resources/tutorials/get-started-with-rest-api)\ +- 🌍 [Get started with REST API](https://mokapi.io/resources/tutorials/get-started-with-rest-api)\ This tutorial will show you how to mock a REST API using an OpenAPI specification. -- ⚡ [Mocking Kafka with AsyncAPI](https://mokapi.io/docs/resources/tutorials/get-started-with-kafka)\ +- ⚡ [Mocking Kafka with AsyncAPI](https://mokapi.io/resources/tutorials/get-started-with-kafka)\ Mocking a Kafka topic using Mokapi and verifying that a producer generates valid messages. -- 👨‍💻 [Mocking LDAP Authentication](https://mokapi.io/docs/resources/tutorials/mock-ldap-authentication-in-node)\ +- 👨‍💻 [Mocking LDAP Authentication](https://mokapi.io/resources/tutorials/mock-ldap-authentication-in-node)\ Simulate LDAP-based login flows, including group-based permissions. -- 📧 [Mocking SMTP Mail Servers](https://mokapi.io/docs/resources/tutorials/mock-smtp-server-send-mail-using-node)\ +- 📧 [Mocking SMTP Mail Servers](https://mokapi.io/resources/tutorials/mock-smtp-server-send-mail-using-node)\ Use Mokapi to simulate sending and receiving emails in Node.js apps. -- 🖥️ [End-to-End Testing with Jest and GitHub Actions](https://mokapi.io/docs/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline)\ +- 🖥️ [End-to-End Testing with Jest and GitHub Actions](https://mokapi.io/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline)\ Integrate Mokapi into your CI pipeline for full-stack E2E testing. -> More examples are available on [mokapi.io/docs/resources](https://mokapi.io/docs/resources) +> More examples are available on [mokapi.io/resources](https://mokapi.io/resources) # 📚 Documentation -- [Get Started](https://mokapi.io/docs/guides/welcome) -- [HTTP](https://mokapi.io/docs/guides/http) -- [Kafka](https://mokapi.io/docs/guides/kafka) -- [LDAP](https://mokapi.io/docs/guides/ldap) -- [SMTP](https://mokapi.io/docs/guides/mail) -- [Javascript API](https://mokapi.io/docs/javascript-api) -- [Resources](https://mokapi.io/docs/resources) +- [Get Started](https://mokapi.io/docs/welcome) +- [HTTP](https://mokapi.io/docs/http/overview) +- [Kafka](https://mokapi.io/docs/kafka/overview) +- [LDAP](https://mokapi.io/docs/ldap/overview) +- [SMTP](https://mokapi.io/docs/mail/overview) +- [Javascript API](https://mokapi.io/docs/javascript-api/overview) +- [Resources](https://mokapi.io/resources) # ☕ Support diff --git a/Taskfile.yml b/Taskfile.yml index 25e268494..be5e6ea05 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -28,10 +28,12 @@ tasks: GOOS: darwin GOARCH: arm64 build-vue-app: + deps: [ensure-dist-folder] dir: webui cmds: - npm run clean - npm run copy-docs + - go run ../cmd/internal/gen-cli-docs/main.go --output-dir ./webui/src/assets/docs/configuration/static - npm version {{.VERSION}} - npm run build-dashboard build-npm-package: @@ -64,5 +66,9 @@ tasks: env: GOOS: darwin GOARCH: arm64 + ensure-dist-folder: + cmds: + - mkdir -p webui/dist + - touch webui/dist/.tmp diff --git a/acceptance/ldap_test.go b/acceptance/ldap_test.go index 64d0e5ea3..575246ff9 100644 --- a/acceptance/ldap_test.go +++ b/acceptance/ldap_test.go @@ -20,7 +20,7 @@ type LdapSuite struct { func (suite *LdapSuite) SetupSuite() { cfg := static.NewConfig() cfg.Api.Port = try.GetFreePort() - cfg.Providers.File.Directories = []string{"./ldap"} + cfg.Providers.File.Directories = []static.FileConfig{{Path: "./ldap"}} suite.initCmd(cfg) // ensure scripts are executed time.Sleep(2 * time.Second) diff --git a/acceptance/mail_test.go b/acceptance/mail_test.go index 8895c87a3..994c9c61b 100644 --- a/acceptance/mail_test.go +++ b/acceptance/mail_test.go @@ -27,7 +27,7 @@ func (suite *MailSuite) SetupSuite() { wd, err := os.Getwd() require.NoError(suite.T(), err) cfg.ConfigFile = path.Join(wd, "mokapi.yaml") - cfg.Providers.File.Directories = []string{"./mail"} + cfg.Providers.File.Directories = []static.FileConfig{{Path: "./mail"}} cfg.Certificates.Static = []static.Certificate{ {Cert: "./mail/mail.mokapi.local.pem"}, } diff --git a/acceptance/petstore_test.go b/acceptance/petstore_test.go index 6b9733194..6e66831fc 100644 --- a/acceptance/petstore_test.go +++ b/acceptance/petstore_test.go @@ -24,7 +24,7 @@ type PetStoreSuite struct{ BaseSuite } func (suite *PetStoreSuite) SetupSuite() { cfg := static.NewConfig() cfg.Api.Port = try.GetFreePort() - cfg.Providers.File.Directories = []string{"./petstore"} + cfg.Providers.File.Directories = []static.FileConfig{{Path: "./petstore"}} cfg.Api.Search.Enabled = true suite.initCmd(cfg) } diff --git a/api/handler_config.go b/api/handler_config.go index bd205b620..aa761e090 100644 --- a/api/handler_config.go +++ b/api/handler_config.go @@ -2,13 +2,14 @@ package api import ( "fmt" - log "github.com/sirupsen/logrus" "mime" "mokapi/config/dynamic" "net/http" "path/filepath" "strings" "time" + + log "github.com/sirupsen/logrus" ) type config struct { @@ -17,6 +18,7 @@ type config struct { Provider string `json:"provider"` Time time.Time `json:"time"` Refs []configRef `json:"refs,omitempty"` + Tags []string `json:"tags,omitempty"` } type configRef struct { @@ -67,7 +69,7 @@ func (h *handler) getConfigData(w http.ResponseWriter, r *http.Request, key stri } token := r.Header.Get("If-None-Match") - checksum := fmt.Sprintf("%x", c.Info.Checksum) + checksum := fmt.Sprintf(`"%x"`, c.Info.Checksum) if token != "" && token == checksum { w.WriteHeader(http.StatusNotModified) return @@ -116,5 +118,6 @@ func toConfig(cfg *dynamic.Config) config { Time: cfg.Info.Time, Provider: cfg.Info.Provider, Refs: refs, + Tags: cfg.Info.Tags, } } diff --git a/api/handler_config_test.go b/api/handler_config_test.go index d7cd2ceae..f5abbd834 100644 --- a/api/handler_config_test.go +++ b/api/handler_config_test.go @@ -37,7 +37,7 @@ func TestHandler_Config(t *testing.T) { panic(err) } checksum := h.Sum(nil) - etag := fmt.Sprintf("%x", checksum) + etag := fmt.Sprintf(`"%x"`, checksum) testcases := []struct { name string diff --git a/api/handler_mail.go b/api/handler_mail.go index 86731fba5..50e9d4136 100644 --- a/api/handler_mail.go +++ b/api/handler_mail.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "mime" "mokapi/media" "mokapi/providers/mail" "mokapi/runtime" @@ -14,6 +15,8 @@ import ( "sort" "strings" "time" + + log "github.com/sirupsen/logrus" ) type mailSummary struct { @@ -420,7 +423,7 @@ func toMessage(m *smtp.Message) *messageData { MessageId: m.MessageId, InReplyTo: m.InReplyTo, Date: m.Date, - Subject: m.Subject, + Subject: decodeSmtpValue(m.Subject), ContentType: m.ContentType, ContentTransferEncoding: m.ContentTransferEncoding, Body: m.Body, @@ -453,9 +456,19 @@ func toAddress(list []smtp.Address) []address { var r []address for _, a := range list { r = append(r, address{ - Name: a.Name, + Name: decodeSmtpValue(a.Name), Address: a.Address, }) } return r } + +func decodeSmtpValue(s string) string { + dec := new(mime.WordDecoder) + r, err := dec.DecodeHeader(s) + if err != nil { + log.Errorf("failed to decode SMTP header: %v", err) + return s + } + return r +} diff --git a/api/handler_mail_test.go b/api/handler_mail_test.go index e30239d42..c99de6db7 100644 --- a/api/handler_mail_test.go +++ b/api/handler_mail_test.go @@ -330,6 +330,47 @@ func TestHandler_Smtp(t *testing.T) { contentType: "text/plain", responseBody: "foobar", }, + { + name: "get SMTP mail with encodings", + app: func() *runtime.App { + app := runtime.New(&static.Config{}) + app.Mail.Set("foo", &runtime.MailInfo{ + Config: &mail.Config{}, + Store: &mail.Store{ + Name: "foo", + Mailboxes: map[string]*mail.Mailbox{ + "alice@foo.bar": { + Folders: map[string]*mail.Folder{ + "Inbox": { + Messages: []*mail.Mail{ + { + Message: &smtp.Message{ + Sender: nil, + From: []smtp.Address{{Address: "bob@foo.bar"}}, + To: []smtp.Address{ + {Address: "juergen@example.com", Name: "=?UTF-8?Q?J=C3=BCrgen?="}, + }, + MessageId: "foo-1@mokapi.io", + Date: now, + Subject: "=?UTF-8?Q?=C2=A1Buenos_d=C3=ADas!?= and =?UTF-8?B?bW9rYXBp?=", + ContentType: "text/plain", + Body: "foobar", + Size: 10, + }, + }, + }, + }, + }, + }, + }, + }, + }) + return app + }, + requestUrl: "http://foo.api/api/services/mail/messages/foo-1@mokapi.io", + contentType: "application/json", + responseBody: fmt.Sprintf(`{"service":"foo","data":{"from":[{"address":"bob@foo.bar"}],"to":[{"name":"Jürgen","address":"juergen@example.com"}],"messageId":"foo-1@mokapi.io","date":"%v","subject":"¡Buenos días! and mokapi","contentType":"text/plain","body":"foobar","size":10}}`, now.Format(time.RFC3339Nano)), + }, } t.Parallel() diff --git a/cmd/mokapi/main_test.go b/cmd/mokapi/main_test.go index 08e8384b0..47acd92f1 100644 --- a/cmd/mokapi/main_test.go +++ b/cmd/mokapi/main_test.go @@ -23,6 +23,27 @@ func TestMain_Skeleton(t *testing.T) { require.Equal(t, "1.0\n", out) }, }, + { + name: "version short", + args: []string{"-v"}, + test: func(t *testing.T, out string) { + require.Equal(t, "1.0\n", out) + }, + }, + { + name: "help", + args: []string{"--help"}, + test: func(t *testing.T, out string) { + require.Contains(t, out, "Mokapi is an easy, modern and flexible API mocking tool using Go") + }, + }, + { + name: "help short", + args: []string{"-h"}, + test: func(t *testing.T, out string) { + require.Contains(t, out, "Mokapi is an easy, modern and flexible API mocking tool using Go") + }, + }, { name: "generate-cli-skeleton", args: []string{"--generate-cli-skeleton"}, @@ -38,6 +59,7 @@ providers: skipPrefix: - _ include: [] + exclude: [] git: urls: [] pullInterval: "" @@ -84,6 +106,7 @@ data-gen: skipPrefix: - _ include: [] + exclude: [] git: urls: [] pullInterval: "" diff --git a/config/decoders/decoder.go b/config/decoders/decoder.go deleted file mode 100644 index 0f069dbae..000000000 --- a/config/decoders/decoder.go +++ /dev/null @@ -1,21 +0,0 @@ -package decoders - -type ConfigDecoder interface { - Decode(flags map[string][]string, element interface{}) error -} - -func Load(decoders []ConfigDecoder, config interface{}) error { - flags, err := parseFlags() - if err != nil { - return err - } - - for _, decoder := range decoders { - err := decoder.Decode(flags, config) - if err != nil { - return err - } - } - - return nil -} diff --git a/config/decoders/decoder_file.go b/config/decoders/decoder_file.go deleted file mode 100644 index d926448f2..000000000 --- a/config/decoders/decoder_file.go +++ /dev/null @@ -1,234 +0,0 @@ -package decoders - -import ( - "encoding/json" - "fmt" - "golang.org/x/text/cases" - "golang.org/x/text/language" - "gopkg.in/yaml.v3" - "os" - "path/filepath" - "reflect" -) - -var ( - searchPaths = []string{".", "/etc/mokapi"} - fileNames = []string{"mokapi.yaml", "mokapi.yml"} -) - -type ReadFileFS func(path string) ([]byte, error) - -type FileDecoder struct { - filename string - readFile ReadFileFS -} - -func NewDefaultFileDecoder() *FileDecoder { - return NewFileDecoder(os.ReadFile) -} - -func NewFileDecoder(readFile ReadFileFS) *FileDecoder { - return &FileDecoder{readFile: readFile} -} - -func (f *FileDecoder) Decode(flags map[string][]string, element interface{}) error { - if len(f.filename) == 0 { - if val, ok := flags["configfile"]; ok { - delete(flags, "configfile") - f.filename = val[0] - } else if val, ok := flags["config-file"]; ok { - delete(flags, "config-file") - f.filename = val[0] - } else if val, ok := flags["cli-input"]; ok { - delete(flags, "cli-input") - f.filename = val[0] - } - } - - if len(f.filename) > 0 { - return f.read(f.filename, element) - } - - for _, dir := range searchPaths { - for _, name := range fileNames { - path := filepath.Join(dir, name) - if err := f.read(path, element); err == nil { - return nil - } else if !os.IsNotExist(err) { - return err - } - } - } - - return nil -} - -func (f *FileDecoder) read(path string, element interface{}) error { - data, err := f.readFile(path) - if err != nil { - return err - } - switch filepath.Ext(path) { - case ".yaml", ".yml": - err = unmarshalYaml(data, reflect.ValueOf(element)) - case ".json": - err = unmarshalJson(data, reflect.ValueOf(element)) - default: - err = fmt.Errorf("unsupported file extension: %v", filepath.Ext(path)) - } - - if err != nil { - return fmt.Errorf("parse file '%v' failed: %w", path, err) - } - return nil -} - -func unmarshalYaml(b []byte, element reflect.Value) error { - m := map[string]interface{}{} - err := yaml.Unmarshal(b, m) - if err != nil { - return err - } - - return mapConfig(m, element, "yaml") -} - -func unmarshalJson(b []byte, element reflect.Value) error { - m := map[string]interface{}{} - err := yaml.Unmarshal(b, m) - if err != nil { - return err - } - - return mapConfig(m, element, "json") -} - -var caser = cases.Title(language.English) - -func mapConfig(value interface{}, element reflect.Value, format string) (err error) { - defer func() { - r := recover() - if r != nil { - err = fmt.Errorf("cannot unmarshal %v into %v", toTypeName(reflect.ValueOf(value)), toTypeName(element)) - } - }() - - switch element.Type().Kind() { - case reflect.Pointer: - if element.IsNil() { - element.Set(reflect.New(element.Type().Elem())) - } - return mapConfig(value, element.Elem(), format) - case reflect.Bool, reflect.Int, reflect.Float64: - element.Set(reflect.ValueOf(value)) - case reflect.Int64: - switch i := value.(type) { - case int: - element.SetInt(int64(i)) - case int64: - element.SetInt(i) - default: - return fmt.Errorf("cannot unmarshal %v into %v", toTypeName(reflect.ValueOf(value)), toTypeName(element)) - } - - case reflect.String: - if _, ok := value.(string); ok { - t := element.Type() - if !reflect.TypeOf(value).AssignableTo(t) { - element.Set(reflect.ValueOf(value).Convert(t)) - } else { - element.Set(reflect.ValueOf(value)) - } - } else { - var b []byte - b, err = json.Marshal(value) - if err != nil { - return - } - element.Set(reflect.ValueOf(string(b))) - } - case reflect.Slice: - v := reflect.ValueOf(value) - if v.Type().Kind() != reflect.Slice { - ptr := reflect.New(element.Type().Elem()) - err = mapConfig(value, ptr.Elem(), format) - if err != nil { - return - } - element.Set(reflect.Append(element, ptr.Elem())) - } else { - arr, ok := value.([]interface{}) - if !ok { - return fmt.Errorf("expected array, got: %v", value) - } - for _, item := range arr { - err = mapConfig(item, element, format) - if err != nil { - return - } - } - } - case reflect.Struct: - m, ok := value.(map[string]interface{}) - if !ok { - return fmt.Errorf("expected object structure, got: %v", value) - } - for k, v := range m { - f := getFieldByTag(element, k, format) - if f.IsValid() { - err = mapConfig(v, f, format) - if err != nil { - return - } - continue - } - name := caser.String(k) - f = element.FieldByNameFunc(func(f string) bool { return f == name }) - if f.IsValid() { - err = mapConfig(v, f, format) - if err != nil { - return - } - continue - } - f = getFieldByTag(element, k, "explode") - if f.IsValid() { - err = mapConfig(v, f, format) - if err != nil { - return - } - } - } - case reflect.Map: - m, ok := value.(map[string]interface{}) - if !ok { - return fmt.Errorf("expected object structure, got: %v", value) - } - if element.IsNil() { - element.Set(reflect.MakeMap(element.Type())) - } - for k, v := range m { - ptr := reflect.New(element.Type().Elem()) - err = mapConfig(v, ptr.Elem(), format) - if err != nil { - return - } - element.SetMapIndex(reflect.ValueOf(k), ptr.Elem()) - } - default: - return fmt.Errorf("type not supported: %v", element.Type().Kind()) - } - - return -} - -func toTypeName(v reflect.Value) string { - switch v.Type().Kind() { - case reflect.Slice: - return "array" - case reflect.Struct, reflect.Map: - return "object" - default: - return v.Type().Kind().String() - } -} diff --git a/config/decoders/decoder_file_test.go b/config/decoders/decoder_file_test.go deleted file mode 100644 index a3615f6eb..000000000 --- a/config/decoders/decoder_file_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package decoders - -import ( - "fmt" - "github.com/stretchr/testify/require" - "io/fs" - "os" - "path/filepath" - "testing" -) - -func TestFileDecoder_Decode(t *testing.T) { - testcases := []struct { - name string - test func(t *testing.T) - }{ - { - name: "no filename set", - test: func(t *testing.T) { - s := &struct{ Name string }{} - d := NewDefaultFileDecoder() - err := d.Decode(map[string][]string{}, s) - require.NoError(t, err) - }, - }, - { - name: "file in folder mokapi in etc", - test: func(t *testing.T) { - s := &struct{ Name string }{} - f := func(path string) ([]byte, error) { - // if test is executed on windows we get second path - if path == "/etc/mokapi/mokapi.yaml" || path == "\\etc\\mokapi\\mokapi.yaml" { - return []byte("name: foobar"), nil - } - return nil, fs.ErrNotExist - } - d := &FileDecoder{readFile: f} - err := d.Decode(map[string][]string{}, s) - require.NoError(t, err) - require.Equal(t, "foobar", s.Name) - }, - }, - { - name: "file does not exist", - test: func(t *testing.T) { - s := &struct{ Name string }{} - f := func(path string) ([]byte, error) { return []byte(""), fmt.Errorf("file not found") } - d := &FileDecoder{filename: "test.yml", readFile: f} - err := d.Decode(map[string][]string{}, s) - require.Error(t, err) - }, - }, - { - name: "empty file", - test: func(t *testing.T) { - s := &struct{ Name string }{} - f := func(path string) ([]byte, error) { return []byte(""), nil } - d := &FileDecoder{filename: "mokapi.yaml", readFile: f} - err := d.Decode(map[string][]string{}, s) - require.NoError(t, err) - }, - }, - { - name: "yaml schema error", - test: func(t *testing.T) { - s := &struct{ Name int }{} - f := func(path string) ([]byte, error) { return []byte("name: {}"), nil } - d := &FileDecoder{filename: "mokapi.yml", readFile: f} - err := d.Decode(map[string][]string{}, s) - require.EqualError(t, err, "parse file 'mokapi.yml' failed: cannot unmarshal object into int") - }, - }, - { - name: "file with data", - test: func(t *testing.T) { - s := &struct{ Name string }{} - f := func(path string) ([]byte, error) { return []byte("name: foobar"), nil } - d := &FileDecoder{readFile: f} - err := d.Decode(map[string][]string{"configfile": {"mokapi.yaml"}}, s) - require.NoError(t, err) - require.Equal(t, "foobar", s.Name) - }, - }, - { - name: "temp file with data", - test: func(t *testing.T) { - s := &struct{ Name string }{} - d := &FileDecoder{filename: createTempFile(t, "test.yml", "name: foobar"), readFile: os.ReadFile} - err := d.Decode(map[string][]string{"configfile": {d.filename}}, s) - require.NoError(t, err) - require.Equal(t, "foobar", s.Name) - }, - }, - { - name: "pascal case", - test: func(t *testing.T) { - s := &struct { - InstallDir string `yaml:"installDir"` - }{} - d := &FileDecoder{filename: createTempFile(t, "test.yml", "installDir: foobar"), readFile: os.ReadFile} - err := d.Decode(map[string][]string{"configfile": {d.filename}}, s) - require.NoError(t, err) - require.Equal(t, "foobar", s.InstallDir) - }, - }, - { - name: "map", - test: func(t *testing.T) { - s := &struct { - Key map[string]string - }{} - d := &FileDecoder{filename: createTempFile(t, "test.yml", "key: {foo: bar}"), readFile: os.ReadFile} - err := d.Decode(map[string][]string{"configFile": {d.filename}}, s) - require.NoError(t, err) - require.Equal(t, map[string]string{"foo": "bar"}, s.Key) - }, - }, - { - name: "array", - test: func(t *testing.T) { - s := &struct { - Key []string - }{} - d := &FileDecoder{filename: createTempFile(t, "test.yml", "key: [bar]"), readFile: os.ReadFile} - err := d.Decode(map[string][]string{"configFile": {d.filename}}, s) - require.NoError(t, err) - require.Equal(t, []string{"bar"}, s.Key) - }, - }, - { - name: "map with array", - test: func(t *testing.T) { - s := &struct { - Key map[string][]string - }{} - d := &FileDecoder{filename: createTempFile(t, "test.yml", "key: {foo: [bar]}"), readFile: os.ReadFile} - err := d.Decode(map[string][]string{"configFile": {d.filename}}, s) - require.NoError(t, err) - require.Equal(t, map[string][]string{"foo": {"bar"}}, s.Key) - }, - }, - { - name: "map pointer struct", - test: func(t *testing.T) { - type test struct { - Name string - Foo string - } - s := &struct { - Key map[string]*test - }{} - d := &FileDecoder{filename: createTempFile(t, "test.yml", "key: {foo: {name: Bob, foo: bar}}"), readFile: os.ReadFile} - err := d.Decode(map[string][]string{"configFile": {d.filename}}, s) - require.NoError(t, err) - require.Equal(t, "Bob", s.Key["foo"].Name) - require.Equal(t, "bar", s.Key["foo"].Foo) - }, - }, - { - name: "map struct", - test: func(t *testing.T) { - type test struct { - Name string - Foo string - } - s := &struct { - Key map[string]test - }{} - d := &FileDecoder{filename: createTempFile(t, "test.yml", "key: {foo: {name: Bob, foo: bar}}"), readFile: os.ReadFile} - err := d.Decode(map[string][]string{"configFile": {d.filename}}, s) - require.NoError(t, err) - require.Equal(t, "Bob", s.Key["foo"].Name) - require.Equal(t, "bar", s.Key["foo"].Foo) - }, - }, - } - - for _, tc := range testcases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - tc.test(t) - }) - } -} - -func createTempFile(t *testing.T, filename, data string) string { - path := filepath.Join(t.TempDir(), filename) - file, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - defer file.Close() - - _, err = file.Write([]byte(data)) - if err != nil { - t.Fatal(err) - } - - return path -} diff --git a/config/decoders/decoder_flag.go b/config/decoders/decoder_flag.go deleted file mode 100644 index 1902d52ab..000000000 --- a/config/decoders/decoder_flag.go +++ /dev/null @@ -1,487 +0,0 @@ -package decoders - -import ( - "bytes" - "encoding/json" - "fmt" - "mokapi/config/dynamic/provider/file" - "net/url" - "reflect" - "slices" - "sort" - "strconv" - "strings" -) - -type FlagDecoder struct { - fs file.FSReader -} - -type context struct { - path string - paths []string - element reflect.Value - value []string -} - -func NewFlagDecoder() *FlagDecoder { - return &FlagDecoder{fs: &file.Reader{}} -} - -func NewFlagDecoderWithReader(reader file.FSReader) *FlagDecoder { - return &FlagDecoder{fs: reader} -} - -func (f *FlagDecoder) Decode(flags map[string][]string, element interface{}) error { - keys := make([]string, 0, len(flags)) - for k := range flags { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, name := range keys { - paths := ParsePath(name) - ctx := &context{path: name, paths: paths, value: flags[name], element: reflect.ValueOf(element)} - err := f.setValue(ctx) - if err != nil { - return fmt.Errorf("configuration error '%v' value '%v': %w", name, flags[name], err) - } - } - - return nil -} - -func (f *FlagDecoder) setValue(ctx *context) error { - switch ctx.element.Kind() { - case reflect.Struct: - if len(ctx.paths) == 0 { - return f.convert(ctx.value[0], ctx.element) - } - err := ctx.setFieldFromStruct() - if err != nil { - return f.explode(ctx.element, ctx.paths[0], ctx.value) - } - return f.setValue(ctx) - case reflect.Pointer: - if ctx.element.IsNil() { - ctx.element.Set(reflect.New(ctx.element.Type().Elem())) - } - ctx.element = ctx.element.Elem() - return f.setValue(ctx) - case reflect.String: - if len(ctx.value) > 1 { - return fmt.Errorf("expected a single string, but received multiple values") - } - s := strings.Trim(ctx.value[0], "\"") - ctx.element.SetString(s) - return nil - case reflect.Int64: - i, err := strconv.ParseInt(ctx.value[0], 10, 64) - if err != nil { - return fmt.Errorf("parse int64 failed: %v", err) - } - ctx.element.SetInt(i) - return nil - case reflect.Int: - i, err := strconv.Atoi(ctx.value[0]) - if err != nil { - return fmt.Errorf("parse int64 failed: %v", err) - } - ctx.element.SetInt(int64(i)) - return nil - case reflect.Bool: - b := false - if ctx.value[0] == "" { - b = true - } else { - var err error - b, err = strconv.ParseBool(ctx.value[0]) - if err != nil { - return fmt.Errorf("value %v cannot be parsed as bool: %v", ctx.value[0], err.Error()) - } - } - ctx.element.SetBool(b) - return nil - case reflect.Slice: - return f.setArray(ctx) - case reflect.Map: - return f.setMap(ctx) - case reflect.Interface: - if len(ctx.value) == 0 || ctx.value[0] == "" { - ctx.element.Set(reflect.ValueOf(true)) - } else { - ctx.element.Set(reflect.ValueOf(ctx.value[0])) - } - return nil - default: - return fmt.Errorf("unsupported config type: %v", ctx.element.Kind()) - } -} - -func ParsePath(key string) []string { - var paths []string - split := strings.FieldsFunc(key, func(r rune) bool { - return r == '.' || r == '-' - }) - - for _, v := range split { - if strings.HasSuffix(v, "]") { - index := strings.Index(v, "[") - paths = append(paths, v[:index], v[index:]) - } else { - paths = append(paths, v) - } - } - return paths -} - -func (f *FlagDecoder) setArray(ctx *context) error { - if len(ctx.paths) > 0 { - index, err := f.parseArrayIndex(ctx.paths[0]) - if err != nil { - return fmt.Errorf("parse array index failed: %v", err) - } - - if index >= ctx.element.Cap() { - n := index + 1 - nCap := 2 * n - if nCap < 4 { - nCap = 4 - } - if ctx.element.IsNil() { - s := reflect.MakeSlice(ctx.element.Type(), n, nCap) - ctx.element.Set(s) - } else { - s := reflect.MakeSlice(ctx.element.Type(), n, nCap) - reflect.Copy(s, ctx.element) - ctx.element.Set(s) - } - } - if index >= ctx.element.Len() { - ctx.element.SetLen(index + 1) - } - - return f.setValue(ctx.Next(ctx.element.Index(index))) - } else { - if len(ctx.value) == 1 { - ctx.value = splitArrayItems(ctx.value[0]) - } - - if len(ctx.value) > 1 { - // reset slice; remove default values - ctx.element.Set(reflect.MakeSlice(ctx.element.Type(), 0, len(ctx.value))) - } - - for _, v := range ctx.value { - ptr := reflect.New(ctx.element.Type().Elem()) - ctxItem := &context{ - paths: ctx.paths, - element: ptr, - value: []string{v}, - } - if err := f.setValue(ctxItem); err != nil { - return err - } - ctx.element.Set(reflect.Append(ctx.element, ptr.Elem())) - } - } - - return nil -} - -func (f *FlagDecoder) parseArrayIndex(path string) (int, error) { - if strings.HasPrefix(path, "[") { - s := strings.TrimPrefix(path, "[") - s = strings.TrimSuffix(s, "]") - return strconv.Atoi(s) - - } - return strconv.Atoi(path) -} - -func (f *FlagDecoder) setMap(ctx *context) error { - m := ctx.element - if m.IsNil() { - m.Set(reflect.MakeMap(ctx.element.Type())) - } - - i := m.Interface() - _ = i - - var key reflect.Value - if len(ctx.paths) >= 1 { - key = reflect.ValueOf(ctx.paths[0]) - } else if len(ctx.paths) == 0 { - if len(ctx.value) == 1 { - kv := strings.Split(ctx.value[0], "=") - if len(kv) != 2 { - return fmt.Errorf("expected value with key value pair for map like key=value: %s", ctx.value[0]) - } - key = reflect.ValueOf(kv[0]) - ctx.value = []string{kv[1]} - } - } - - if !key.IsValid() { - return fmt.Errorf("expected key to set map value") - } - - v := m.MapIndex(key) - if !v.IsValid() { - v = reflect.New(m.Type().Elem()) - } else { - p := reflect.New(m.Type().Elem()) - p.Elem().Set(v) - v = p - } - - ctx.element = v - - if len(ctx.paths) >= 1 { - v = ctx.element - if err := f.setValue(ctx.Next(ctx.element)); err != nil { - return err - } - } else { - if err := f.setValue(ctx); err != nil { - return err - } - } - - m.SetMapIndex(key, v.Elem()) - - return nil -} - -func (f *FlagDecoder) explode(v reflect.Value, name string, value []string) error { - field := getFieldByTag(v, name, "explode") - if !field.IsValid() { - return fmt.Errorf("not found") - } - - for _, val := range value { - o := reflect.New(field.Type().Elem()) - err := f.convert(val, o.Elem()) - if err != nil { - return err - } - field.Set(reflect.Append(field, o.Elem())) - } - - return nil -} - -func getFieldByTag(structValue reflect.Value, name, tag string) reflect.Value { - for i := 0; i < structValue.NumField(); i++ { - v := structValue.Type().Field(i).Tag.Get(tag) - tagValues := strings.Split(v, ",") - for _, tagValue := range tagValues { - if tagValue == name { - return structValue.Field(i) - } - } - } - return reflect.Value{} -} - -func (f *FlagDecoder) convert(s string, v reflect.Value) error { - u, err := url.ParseRequestURI(s) - if err == nil { - switch u.Scheme { - case "file": - var path string - if len(u.Host) > 0 { - path = u.Host - } - if len(u.Path) > 0 { - path += u.Path - } - if len(u.Opaque) > 0 { - path = u.Opaque - } - b, err := f.fs.ReadFile(path) - if err != nil { - return err - } - // remove bom sequence if present - if len(b) >= 4 && bytes.Equal(b[0:3], file.Bom) { - b = b[3:] - } - s = string(b) - } - } - - kind := v.Type().Kind() - if kind == reflect.Struct { - err = f.convertJson(s, v) - if err == nil { - return nil - } - } - - if kind == reflect.Struct { - pairs := strings.Split(s, ",") - for _, pair := range pairs { - kv := strings.Split(pair, "=") - if len(kv) != 2 { - return fmt.Errorf("parse shorthand failed: %v", s) - } - err = f.setValue(&context{paths: []string{kv[0]}, value: []string{kv[1]}, element: v}) - if err != nil { - return err - } - } - return nil - } else if kind == reflect.Slice { - return f.setValue(&context{paths: []string{}, element: v, value: []string{s}}) - } else if kind == reflect.String { - v.Set(reflect.ValueOf(s)) - return nil - } - - return fmt.Errorf("not supported") -} - -func (f *FlagDecoder) convertJson(s string, v reflect.Value) error { - m := map[string]interface{}{} - err := json.Unmarshal([]byte(s), &m) - if err != nil { - return err - } - - return f.setJson(v, m) -} - -func splitArrayItems(s string) []string { - quoted := false - return strings.FieldsFunc(s, func(r rune) bool { - if r == '"' { - quoted = !quoted - } - return !quoted && r == ' ' - }) -} - -func (f *FlagDecoder) setJson(element reflect.Value, i interface{}) error { - switch o := i.(type) { - case float64: - // currently, config uses only int64 as number - i = int64(o) - element.Set(reflect.ValueOf(i)) - case int64, string, bool: - element.Set(reflect.ValueOf(i)) - case []interface{}: - for _, item := range o { - ptr := reflect.New(element.Type().Elem()) - err := f.setJson(ptr.Elem(), item) - if err != nil { - return err - } - element.Set(reflect.Append(element, ptr.Elem())) - } - case map[string]interface{}: - for k, v := range o { - field := element.FieldByNameFunc(func(f string) bool { return strings.ToLower(f) == strings.ToLower(k) }) - if field.IsValid() { - err := f.setJson(field, v) - if err != nil { - return err - } - } else { - field = getFieldByTag(element, k, "explode") - if !field.IsValid() { - return fmt.Errorf("configuration not found") - } - ptr := reflect.New(field.Type().Elem()) - err := f.setJson(ptr.Elem(), v) - if err != nil { - return err - } - field.Set(reflect.Append(field, ptr.Elem())) - } - } - } - - return nil -} - -func invertFlag(value string) (string, error) { - flag := false - if value != "" { - b, err := strconv.ParseBool(value) - if err != nil { - return "", fmt.Errorf("value %v cannot be parsed as bool: %v", value, err.Error()) - } - flag = !b - } - return fmt.Sprintf("%v", flag), nil -} - -func (c *context) setFieldFromStruct() error { - name := strings.ToLower(c.paths[0]) - field := c.element.FieldByNameFunc(func(f string) bool { - return strings.ToLower(f) == name - }) - if field.IsValid() { - c.Next(field) - return nil - } - - if c.paths[0] == "no" && len(c.paths) == 2 { - name = strings.ToLower(c.paths[1]) - field = c.element.FieldByNameFunc(func(f string) bool { - return strings.ToLower(f) == c.paths[1] - }) - if field.IsValid() { - value, err := invertFlag(c.value[0]) - if err != nil { - return err - } - c.value = []string{value} - c.Next(field) - return nil - } - } - - for i := 0; i < c.element.NumField(); i++ { - f := c.element.Type().Field(i) - - var names []string - tag := f.Tag.Get("flag") - if len(tag) == 0 { - tag = f.Tag.Get("name") - if len(tag) > 0 { - names = append(names, tag) - } else { - tag = f.Tag.Get("aliases") - if len(tag) > 0 { - names = append(names, strings.Split(tag, ",")...) - } else { - continue - } - } - } else { - names = append(names, tag) - } - - name = "" - for j := 0; j < len(c.paths); j++ { - if len(name) > 0 { - name += "-" - } - name += c.paths[j] - if slices.Contains(names, name) { - c.element = c.element.Field(i) - c.paths = c.paths[j+1:] - return nil - } - } - } - return fmt.Errorf("no configuration found") -} - -func (c *context) Next(element reflect.Value) *context { - c.paths = c.paths[1:] - c.element = element - return c -} diff --git a/config/decoders/decoder_flag_test.go b/config/decoders/decoder_flag_test.go deleted file mode 100644 index 99b5cad03..000000000 --- a/config/decoders/decoder_flag_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package decoders - -import ( - "github.com/stretchr/testify/require" - "mokapi/config/dynamic/provider/file/filetest" - "testing" -) - -func TestFlagDecoder_Decode(t *testing.T) { - testcases := []struct { - name string - f func(t *testing.T) - }{ - { - name: "string", - f: func(t *testing.T) { - s := &struct { - Name string - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"name": {"foobar"}}, s) - require.NoError(t, err) - require.Equal(t, "foobar", s.Name) - }, - }, - { - name: "config is string but multiple argument values", - f: func(t *testing.T) { - s := &struct { - Name string - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"name": {"foo", "bar"}}, s) - require.EqualError(t, err, "configuration error 'name' value '[foo bar]': expected a single string, but received multiple values") - }, - }, - { - name: "bool", - f: func(t *testing.T) { - s := &struct { - Flag1 bool - Flag2 bool - Flag3 bool - Flag4 bool - }{ - Flag3: true, - Flag4: true, - } - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"flag1": {"true"}, "flag2": {"1"}, "no-flag3": {""}, "no-flag4": {"false"}}, s) - require.NoError(t, err) - require.True(t, s.Flag1) - require.True(t, s.Flag2) - require.False(t, s.Flag3) - require.True(t, s.Flag4) - }, - }, - { - name: "not a bool", - f: func(t *testing.T) { - s := &struct { - Flag1 bool - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"flag1": {"foo"}}, s) - require.EqualError(t, err, "configuration error 'flag1' value '[foo]': value foo cannot be parsed as bool: strconv.ParseBool: parsing \"foo\": invalid syntax") - require.False(t, s.Flag1) - }, - }, - { - name: "nested with dot (old)", - f: func(t *testing.T) { - s := &struct { - Key string - Value struct { - Flag bool - } - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"key": {"foo"}, "value.flag": {"true"}}, s) - require.NoError(t, err) - require.Equal(t, "foo", s.Key) - require.True(t, s.Value.Flag) - }, - }, - { - name: "nested with - (new)", - f: func(t *testing.T) { - s := &struct { - Key string - Value struct { - Flag bool - } - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"key": {"foo"}, "value-flag": {"true"}}, s) - require.NoError(t, err) - require.Equal(t, "foo", s.Key) - require.True(t, s.Value.Flag) - }, - }, - { - name: "capitalized", - f: func(t *testing.T) { - s := &struct { - Key string - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"Key": {"foo"}}, s) - require.NoError(t, err) - require.Equal(t, "foo", s.Key) - }, - }, - { - name: "map", - f: func(t *testing.T) { - s := &struct { - Key map[string]string - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"Key.foo": {"bar"}}, s) - require.NoError(t, err) - require.Equal(t, map[string]string{"foo": "bar"}, s.Key) - }, - }, - { - name: "array", - f: func(t *testing.T) { - s := &struct { - Key []string - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"Key[0]": {"bar"}, "Key[1]": {"foo"}}, s) - require.NoError(t, err) - require.Equal(t, []string{"bar", "foo"}, s.Key) - }, - }, - { - name: "array shorthand", - f: func(t *testing.T) { - s := &struct { - Key []string - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"Key": {"bar foo"}}, s) - require.NoError(t, err) - require.Equal(t, []string{"bar", "foo"}, s.Key) - }, - }, - { - name: "array shorthand with item contains a space", - f: func(t *testing.T) { - s := &struct { - Key []string - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"Key": {"bar foo \"foo bar\""}}, s) - require.NoError(t, err) - require.Equal(t, []string{"bar", "foo", "foo bar"}, s.Key) - }, - }, - { - name: "map with array", - f: func(t *testing.T) { - s := &struct { - Key map[string][]string - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"Key.foo[0]": {"bar"}}, s) - require.NoError(t, err) - require.Equal(t, map[string][]string{"foo": {"bar"}}, s.Key) - }, - }, - { - name: "map pointer struct", - f: func(t *testing.T) { - type test struct { - Name string - Foo string - } - s := &struct { - Key map[string]*test - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"Key.foo.Name": {"Bob"}, "Key.foo.Foo": {"bar"}}, s) - require.NoError(t, err) - require.Equal(t, "Bob", s.Key["foo"].Name) - require.Equal(t, "bar", s.Key["foo"].Foo) - }, - }, - { - name: "map struct", - f: func(t *testing.T) { - type test struct { - Name string - Foo string - } - s := &struct { - Key map[string]test - }{} - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"Key.foo.Name": {"Bob"}, "Key.foo.Foo": {"bar"}}, s) - require.NoError(t, err) - require.Equal(t, "Bob", s.Key["foo"].Name) - require.Equal(t, "bar", s.Key["foo"].Foo) - }, - }, - { - name: "parameters from file in current directory", - f: func(t *testing.T) { - type test struct { - Name string - Foo string - } - s := &struct { - Key map[string]test - }{} - - fs := &filetest.MockFS{Entries: []*filetest.Entry{ - { - Name: "test.json", - IsDir: false, - Data: []byte(`{"name": "Bob", "foo": "bar"}`), - }}} - - d := &FlagDecoder{fs: fs} - err := d.Decode(map[string][]string{"Key.foo": {"file://test.json"}}, s) - require.NoError(t, err) - require.Equal(t, "Bob", s.Key["foo"].Name) - require.Equal(t, "bar", s.Key["foo"].Foo) - }, - }, - { - name: "parameters from file absolute path", - f: func(t *testing.T) { - type test struct { - Name string - Foo string - } - s := &struct { - Key map[string]test - }{} - - fs := &filetest.MockFS{Entries: []*filetest.Entry{ - { - Name: "/tmp/test.json", - IsDir: false, - Data: []byte(`{"name": "Bob", "foo": "bar"}`), - }}} - - d := &FlagDecoder{fs: fs} - err := d.Decode(map[string][]string{"Key.foo": {"file:///tmp/test.json"}}, s) - require.NoError(t, err) - require.Equal(t, "Bob", s.Key["foo"].Name) - require.Equal(t, "bar", s.Key["foo"].Foo) - }, - }, - { - name: "parameters from file absolute path", - f: func(t *testing.T) { - s := &struct { - SkipName string `flag:"skip-name"` - }{} - - d := &FlagDecoder{} - err := d.Decode(map[string][]string{"skip-name": {"foo"}}, s) - require.NoError(t, err) - require.Equal(t, "foo", s.SkipName) - }, - }, - } - - for _, tc := range testcases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - tc.f(t) - }) - } -} diff --git a/config/decoders/decoder_test.go b/config/decoders/decoder_test.go deleted file mode 100644 index ade18aa2a..000000000 --- a/config/decoders/decoder_test.go +++ /dev/null @@ -1,366 +0,0 @@ -package decoders - -import ( - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/require" - "os" - "strings" - "testing" -) - -func TestLoad(t *testing.T) { - testcases := []struct { - name string - f func(t *testing.T) - }{ - { - name: "args", - f: func(t *testing.T) { - s := &struct { - Name string - Flag1 bool - Flag2 bool - }{ - Flag2: true, - } - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--name=bar") - os.Args = append(os.Args, "--flag1") - os.Args = append(os.Args, "--no-flag2") - - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, "bar", s.Name) - require.Equal(t, true, s.Flag1) - require.Equal(t, false, s.Flag2) - }, - }, - { - name: "without =", - f: func(t *testing.T) { - s := &struct { - Name string - }{} - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--name") - os.Args = append(os.Args, "bar") - - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, "bar", s.Name) - }, - }, - { - name: "array explode", - f: func(t *testing.T) { - s := &struct { - Names []string `explode:"name"` - }{} - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--name", "foo", "--name", "bar") - - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, []string{"foo", "bar"}, s.Names) - }, - }, - { - name: "array list", - f: func(t *testing.T) { - s := &struct { - Names []string `explode:"name"` - }{} - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--name", "foo", "bar") - - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, []string{"foo", "bar"}, s.Names) - }, - }, - { - name: "array with item contains a space", - f: func(t *testing.T) { - s := &struct { - Names []string - }{} - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--names") - os.Args = append(os.Args, "bar", "foo", "foo bar") - - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, []string{"bar", "foo", "foo bar"}, s.Names) - }, - }, - { - name: "array override with index operator", - f: func(t *testing.T) { - s := &struct { - Names []string `explode:"name"` - }{} - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--name", "foo", "--name", "bar", "--names[0]", "x") - - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, []string{"x", "bar"}, s.Names) - }, - }, - { - name: "env var", - f: func(t *testing.T) { - s := &struct { - Name string - SkipName string `flag:"skip-name"` - }{} - os.Args = append(os.Args, "mokapi.exe") - err := os.Setenv("MOKAPI_name", "bar") - defer os.Unsetenv("MOKAPI_name") - require.NoError(t, err) - err = os.Setenv("MOKAPI_SKIP_NAME", "bar") - defer os.Unsetenv("MOKAPI_SKIP_NAME") - require.NoError(t, err) - - err = Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, "bar", s.Name) - require.Equal(t, "bar", s.SkipName) - }, - }, - { - name: "env var overrides cli args", - f: func(t *testing.T) { - s := &struct { - Name string - }{} - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--name=bar") - err := os.Setenv("MOKAPI_name", "barr") - defer os.Unsetenv("MOKAPI_name") - require.NoError(t, err) - - err = Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, "barr", s.Name) - }, - }, - { - name: "env var overrides cli array", - f: func(t *testing.T) { - s := &struct { - Name []string - }{} - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--name", "foo", "--name", "bar") - err := os.Setenv("MOKAPI_name", "barr") - defer os.Unsetenv("MOKAPI_name") - require.NoError(t, err) - - err = Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, []string{"barr"}, s.Name) - }, - }, - { - name: "env var array single", - f: func(t *testing.T) { - s := &struct { - Urls []string - }{} - os.Args = append(os.Args, "mokapi.exe") - err := os.Setenv("MOKAPI_urls", "https://foo.bar") - defer os.Unsetenv("MOKAPI_urls") - require.NoError(t, err) - - err = Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Contains(t, s.Urls, "https://foo.bar") - require.Equal(t, 1, len(s.Urls)) - require.Equal(t, 1, cap(s.Urls)) - }, - }, - { - name: "env var array", - f: func(t *testing.T) { - s := &struct { - Urls []string - }{} - os.Args = append(os.Args, "mokapi.exe") - err := os.Setenv("MOKAPI_urls[0]", "https://foo.bar") - defer os.Unsetenv("MOKAPI_urls[0]") - require.NoError(t, err) - err = os.Setenv("MOKAPI_urls[1]", "https://mokapi.io") - require.NoError(t, err) - defer os.Unsetenv("MOKAPI_urls[1]") - err = os.Setenv("MOKAPI_urls_2", "https://foo.com") - require.NoError(t, err) - defer os.Unsetenv("MOKAPI_urls_2") - err = os.Setenv("MOKAPI_urls.10", "https://bar.com") - require.NoError(t, err) - defer os.Unsetenv("MOKAPI_urls.10") - - err = Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Contains(t, s.Urls, "https://foo.bar") - require.Contains(t, s.Urls, "https://mokapi.io") - require.Contains(t, s.Urls, "https://foo.com") - require.Contains(t, s.Urls, "https://bar.com") - require.Equal(t, 11, len(s.Urls)) - require.Equal(t, 22, cap(s.Urls)) - }, - }, - { - name: "env var array update index with [0]", - f: func(t *testing.T) { - s := &struct { - Items []struct { - Name string - Value int64 - } - }{} - os.Args = append(os.Args, "mokapi.exe") - err := os.Setenv("MOKAPI_items[0].name", "mokapi") - defer os.Unsetenv("MOKAPI_items[0].name") - require.NoError(t, err) - err = os.Setenv("MOKAPI_items[0].value", "123") - defer os.Unsetenv("MOKAPI_items[0].value") - require.NoError(t, err) - - err = Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Len(t, s.Items, 1) - require.Equal(t, s.Items[0].Name, "mokapi") - require.Equal(t, s.Items[0].Value, int64(123)) - }, - }, - { - name: "env var array update index with _0_", - f: func(t *testing.T) { - s := &struct { - Items []struct { - Name string - Value int64 - } - }{} - os.Args = append(os.Args, "mokapi.exe") - err := os.Setenv("MOKAPI_items_0_name", "mokapi") - defer os.Unsetenv("MOKAPI_items_0_name") - require.NoError(t, err) - err = os.Setenv("MOKAPI_items_0_value", "123") - defer os.Unsetenv("MOKAPI_items_0_value") - require.NoError(t, err) - - err = Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Len(t, s.Items, 1) - require.Equal(t, s.Items[0].Name, "mokapi") - require.Equal(t, s.Items[0].Value, int64(123)) - }, - }, - { - name: "env var array update index with .0.", - f: func(t *testing.T) { - s := &struct { - Items []struct { - Name string - Value int64 - } - }{} - os.Args = append(os.Args, "mokapi.exe") - err := os.Setenv("MOKAPI_items_0_name", "mokapi") - defer os.Unsetenv("MOKAPI_items_0_name") - require.NoError(t, err) - err = os.Setenv("MOKAPI_ITEMS_0_VALUE", "123") - require.NoError(t, err) - defer os.Unsetenv("MOKAPI_ITEMS_0_VALUE") - err = os.Setenv("MOKAPI_items.0.value", "123") - require.NoError(t, err) - defer os.Unsetenv("MOKAPI_items.0.value") - - err = Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Len(t, s.Items, 1) - require.Equal(t, s.Items[0].Name, "mokapi") - require.Equal(t, s.Items[0].Value, int64(123)) - }, - }, - { - name: "unknown argument", - f: func(t *testing.T) { - s := &struct { - Name string - }{} - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--foo=bar") - - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.EqualError(t, err, "configuration error 'foo' value '[bar]': not found") - }, - }, - { - name: "argument after positional argument", - f: func(t *testing.T) { - s := &struct { - Foo string - Args []string `json:"args" yaml:"-" aliases:"args"` - }{} - os.Args = append(os.Args, "mokapi.exe", "file.json", "--foo=bar") - - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.NoError(t, err) - require.Equal(t, s.Foo, "bar") - require.Equal(t, s.Args, []string{"file.json"}) - }, - }, - { - name: "after -- an additional flag argument", - f: func(t *testing.T) { - s := &struct { - Foo string - Args []string `json:"args" yaml:"-" aliases:"args"` - }{} - os.Args = append(os.Args, "mokapi.exe", "--", "--foo=bar") - - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.EqualError(t, err, "unknown positional argument: '--foo=bar'") - }, - }, - } - - for _, tc := range testcases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - log.Infof("Test: %s", tc.name) - os.Args = nil - log.Infof("Args: %s", os.Args) - tc.f(t) - }) - } -} - -func TestLoad_Invalid(t *testing.T) { - testcases := []struct { - args string - }{ - {args: "name=bar"}, - {args: "-"}, - {args: "-=bar"}, - {args: "---name=bar"}, - } - - for _, tc := range testcases { - tc := tc - t.Run(tc.args, func(t *testing.T) { - os.Args = nil - os.Args = append(os.Args, "mokapi.exe") - args := strings.Split(tc.args, " ") - os.Args = append(os.Args, args...) - s := &struct { - Name string - }{} - err := Load([]ConfigDecoder{&FlagDecoder{}}, s) - require.Error(t, err) - }) - } -} diff --git a/config/decoders/parser.go b/config/decoders/parser.go deleted file mode 100644 index 9083784d4..000000000 --- a/config/decoders/parser.go +++ /dev/null @@ -1,105 +0,0 @@ -package decoders - -import ( - "fmt" - "os" - "strings" -) - -const DefaultEnvNamePrefix = "MOKAPI_" - -func parseFlags() (map[string][]string, error) { - flags, err := parseArgs(os.Args[1:]) // first argument is the program path - if err != nil { - return nil, err - } - - envs := parseEnv(os.Environ()) - - // merge maps. env flags overwrites cli flags - for k, v := range envs { - flags[k] = []string{v} - } - - return flags, nil -} - -func parseEnv(environ []string) map[string]string { - dictionary := make(map[string]string) - - for _, s := range environ { - kv := strings.SplitN(s, "=", 2) - if strings.HasPrefix(strings.ToUpper(kv[0]), DefaultEnvNamePrefix) { - key := strings.Replace(kv[0], DefaultEnvNamePrefix, "", 1) - name := strings.ReplaceAll(strings.ToLower(key), "_", "-") - dictionary[name] = kv[1] - } - } - - return dictionary -} - -func parseArgs(args []string) (map[string][]string, error) { - dictionary := make(map[string][]string) - inPositionalArgs := false - for i := 0; i < len(args); i++ { - s := args[i] - if len(s) < 2 || s[0] != '-' { - dictionary["args"] = append(dictionary["args"], s) - continue - } else if inPositionalArgs { - // currently, no positional argument with prefix -- are defined - return nil, fmt.Errorf("unknown positional argument: '%s'", s) - } - - index := 1 - - if s[1] == '-' { - index++ - if len(s) == 2 { - inPositionalArgs = true - continue - } - } - - name := s[index:] - value := "" - hasValue := false - - if len(name) == 0 || name[0] == '-' || name[0] == '=' { - return nil, fmt.Errorf("invalid argument %v", s) - } - - // search for = - for i := 1; i < len(name); i++ { // = can not be first - if name[i] == '=' { - value = name[i+1:] - name = name[0:i] - hasValue = true - break - } - } - - param := strings.ToLower(name) - if hasValue { - dictionary[param] = append(dictionary[param], value) - continue - } - - // value is next args - for i++; i < len(args); i++ { - if strings.HasPrefix(args[i], "--") { - i-- - break - } - value = args[i] - dictionary[param] = append(dictionary[param], value) - } - - if len(dictionary[param]) == 0 { - dictionary[param] = append(dictionary[param], "") - } - } - - return dictionary, nil -} diff --git a/config/dynamic/config_info.go b/config/dynamic/config_info.go index ace6da02d..113a3ca0e 100644 --- a/config/dynamic/config_info.go +++ b/config/dynamic/config_info.go @@ -3,11 +3,12 @@ package dynamic import ( "crypto/md5" "encoding/hex" - "github.com/google/uuid" - log "github.com/sirupsen/logrus" "net/url" "strings" "time" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" ) type ConfigInfo struct { @@ -16,6 +17,7 @@ type ConfigInfo struct { Checksum []byte Time time.Time inner *ConfigInfo + Tags []string } func (ci *ConfigInfo) Path() string { diff --git a/config/dynamic/mail/parse.go b/config/dynamic/mail/parse.go index e64ff64cb..7cf9aa5b9 100644 --- a/config/dynamic/mail/parse.go +++ b/config/dynamic/mail/parse.go @@ -10,7 +10,7 @@ func (c *Config) Parse(config *dynamic.Config, _ dynamic.Reader) error { return nil } - log.Warnf("Deprecated mail configuration in %s. This format is deprecated and will be removed in future versions. Please migrate to the new format. More info: https://mokapi.io/docs/guides/email", config.Info.Path()) + log.Warnf("Deprecated mail configuration in %s. This format is deprecated and will be removed in future versions. Please migrate to the new format. More info: https://mokapi.io/docs/email/overview", config.Info.Path()) converted := c.Convert() config.Data = converted diff --git a/config/dynamic/mail/parse_test.go b/config/dynamic/mail/parse_test.go index c8a9b7713..eaca9db9e 100644 --- a/config/dynamic/mail/parse_test.go +++ b/config/dynamic/mail/parse_test.go @@ -33,5 +33,5 @@ func TestConfig_Parse(t *testing.T) { require.Len(t, hook.Entries, 1) require.Equal(t, logrus.WarnLevel, hook.Entries[0].Level) - require.Equal(t, "Deprecated mail configuration in file://foo.yml. This format is deprecated and will be removed in future versions. Please migrate to the new format. More info: https://mokapi.io/docs/guides/email", hook.Entries[0].Message) + require.Equal(t, "Deprecated mail configuration in file://foo.yml. This format is deprecated and will be removed in future versions. Please migrate to the new format. More info: https://mokapi.io/docs/email/overview", hook.Entries[0].Message) } diff --git a/config/dynamic/provider/file/file.go b/config/dynamic/provider/file/file.go index a034af260..0cd8cac8e 100644 --- a/config/dynamic/provider/file/file.go +++ b/config/dynamic/provider/file/file.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "errors" "fmt" "io" "io/fs" @@ -92,20 +93,26 @@ func (p *Provider) Read(u *url.URL) (*dynamic.Config, error) { func (p *Provider) Start(ch chan dynamic.ConfigEvent, pool *safe.Pool) error { p.ch = ch - var path []string + var files []static.FileConfig if len(p.cfg.Directories) > 0 { - path = p.cfg.Directories + for _, dir := range p.cfg.Directories { + for _, d := range strings.Split(dir.Path, string(os.PathListSeparator)) { + files = append(files, static.FileConfig{Path: d, Include: dir.Include, Exclude: dir.Exclude}) + } + } } else if len(p.cfg.Filenames) > 0 { - path = p.cfg.Filenames + for _, file := range p.cfg.Filenames { + for _, f := range strings.Split(file, string(os.PathListSeparator)) { + files = append(files, static.FileConfig{Path: f}) + } + } } - if len(path) > 0 { + if len(files) > 0 { pool.Go(func(ctx context.Context) { - for _, pathItem := range path { - for _, i := range strings.Split(pathItem, string(os.PathListSeparator)) { - if err := p.walk(i); err != nil { - log.Errorf("file provider: %v", err) - } + for _, file := range files { + if err := p.walk(file); err != nil { + log.Errorf("file provider: %v", err) } } p.isInit = false @@ -118,7 +125,7 @@ func (p *Provider) Start(ch chan dynamic.ConfigEvent, pool *safe.Pool) error { func (p *Provider) Watch(dir string, pool *safe.Pool) { pool.Go(func(ctx context.Context) { - if err := p.walk(dir); err != nil { + if err := p.walk(static.FileConfig{Path: dir}); err != nil { log.Errorf("file provider: %v", err) } }) @@ -170,13 +177,30 @@ func (p *Provider) watch(pool *safe.Pool) error { return nil } -func (p *Provider) skip(path string, isDir bool) bool { +func (p *Provider) skip(path string, isDir bool, info static.FileConfig) bool { if p.isWatchPath(path) { return false } - if !isDir && len(p.cfg.Include) > 0 { - return !include(p.cfg.Include, path) + if !isDir { + inc := p.cfg.Include + if len(info.Include) > 0 { + inc = append(inc, info.Include...) + } + if len(inc) > 0 { + if !include(inc, path) { + return true + } + } + ex := p.cfg.Exclude + if len(info.Exclude) > 0 { + ex = append(ex, info.Exclude...) + } + if len(ex) > 0 { + if include(ex, path) { + return true + } + } } if isMokapiIgnoreFile(path) { @@ -227,20 +251,20 @@ func (p *Provider) readFile(path string) (*dynamic.Config, error) { }, nil } -func (p *Provider) walk(root string) error { - p.readMokapiIgnore(root) +func (p *Provider) walk(fileInfo static.FileConfig) error { + p.readMokapiIgnore(fileInfo.Path) walkDir := func(path string, fi fs.DirEntry, err error) error { if err != nil { return err } if fi.IsDir() { - if p.skip(path, true) && path != root { + if p.skip(path, true, fileInfo) && path != fileInfo.Path { log.Debugf("skip dir: %v", path) return filepath.SkipDir } p.readMokapiIgnore(path) p.watchPath(path) - } else if !p.skip(path, false) { + } else if !p.skip(path, false, fileInfo) { if c, err := p.readFile(path); err != nil { log.Error(err) } else if len(c.Raw) > 0 { @@ -254,7 +278,7 @@ func (p *Provider) walk(root string) error { return nil } - return p.fs.Walk(root, walkDir) + return p.fs.Walk(fileInfo.Path, walkDir) } func (p *Provider) readMokapiIgnore(path string) { @@ -345,10 +369,12 @@ func (p *Provider) processEvents(events []fsnotify.Event) { } dir, _ := filepath.Split(evt.Name) - if dir == evt.Name && !p.skip(dir, true) { - p.watchPath(dir) - } else { - if !p.skip(evt.Name, false) { + isDir := dir == evt.Name + + if !p.skipEvent(e, isDir) { + if isDir { + p.watchPath(dir) + } else { e.Config, err = p.readFile(evt.Name) if err != nil { log.Errorf("unable to read file %v", evt.Name) @@ -371,13 +397,31 @@ Walk: } doneWalk = append(doneWalk, dir) - err := p.walk(dir) + cfg, err := p.getFileConfig(dir) + if err != nil { + log.Debugf("skip event: unable to get file config for %v: %v", dir, err) + } + + cfg.Path = dir + err = p.walk(cfg) if err != nil { log.Errorf("unable to process dir %v: %v", dir, err) } } } +func (p *Provider) skipEvent(evt dynamic.ConfigEvent, isDir bool) bool { + if p.isWatchPath(evt.Name) { + return false + } + cfg, err := p.getFileConfig(evt.Name) + if err != nil { + log.Debugf("skip event: unable to get file config for %v: %v", evt.Name, err) + } + + return p.skip(evt.Name, isDir, cfg) +} + func (p *Provider) isWatchPath(path string) bool { p.m.Lock() defer p.m.Unlock() @@ -386,6 +430,15 @@ func (p *Provider) isWatchPath(path string) bool { return ok } +func (p *Provider) getFileConfig(path string) (static.FileConfig, error) { + for _, cfg := range p.cfg.Directories { + if isSub(cfg.Path, path) { + return cfg, nil + } + } + return static.FileConfig{}, errors.New("directory config not found") +} + func include(s []string, v string) bool { for _, i := range s { if Match(i, v) { @@ -399,3 +452,15 @@ func isMokapiIgnoreFile(path string) bool { name := filepath.Base(path) return name == mokapiIgnoreFile } + +func isSub(parent, sub string) bool { + up := ".." + string(os.PathSeparator) + rel, err := filepath.Rel(parent, sub) + if err != nil { + return false + } + if !strings.HasPrefix(rel, up) && rel != ".." { + return true + } + return false +} diff --git a/config/dynamic/provider/file/file_test.go b/config/dynamic/provider/file/file_test.go index 8334b7611..418e3408d 100644 --- a/config/dynamic/provider/file/file_test.go +++ b/config/dynamic/provider/file/file_test.go @@ -2,7 +2,6 @@ package file import ( "context" - "github.com/stretchr/testify/require" "io" "mokapi/config/dynamic" "mokapi/config/dynamic/provider/file/filetest" @@ -15,6 +14,8 @@ import ( "strings" "testing" "time" + + "github.com/stretchr/testify/require" ) func TestProvider(t *testing.T) { @@ -40,7 +41,7 @@ func TestProvider(t *testing.T) { IsDir: false, Data: []byte("foobar"), }}}, - cfg: static.FileProvider{Directories: []string{"./foo"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./foo"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 0) }, @@ -54,7 +55,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), ModTime: mustTime("2024-01-02T15:04:05Z"), }}}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) require.Equal(t, "foo.txt", filepath.Base(events[0].Config.Info.Path())) @@ -70,7 +71,7 @@ func TestProvider(t *testing.T) { IsDir: false, Data: []byte("fo"), }}}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) require.Equal(t, "foo.txt", filepath.Base(events[0].Config.Info.Path())) @@ -85,7 +86,7 @@ func TestProvider(t *testing.T) { IsDir: false, Data: []byte{0xEF, 0xBB, 0xBF, 'f', 'o', 'o', 'b', 'a', 'r'}, }}}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) require.Equal(t, "foo.txt", filepath.Base(events[0].Config.Info.Path())) @@ -100,7 +101,7 @@ func TestProvider(t *testing.T) { IsDir: false, Data: []byte("foobar"), }}}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 0) }, @@ -119,7 +120,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}, SkipPrefix: []string{"$"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}, SkipPrefix: []string{"$"}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) require.Equal(t, "_foo.txt", filepath.Base(events[0].Config.Info.Path())) @@ -138,7 +139,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) require.Equal(t, "foo.txt", filepath.Base(events[0].Config.Info.Path())) @@ -157,7 +158,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 0) }, @@ -176,7 +177,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 0) }, @@ -199,7 +200,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 0) }, @@ -227,7 +228,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) require.Equal(t, "foobar", string(events[0].Config.Raw)) @@ -247,7 +248,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) }, @@ -266,7 +267,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 0) }, @@ -290,7 +291,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) }, @@ -319,7 +320,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) require.True(t, strings.HasSuffix(events[0].Config.Info.Path(), filepath.Join("dir", "foo.js"))) @@ -354,7 +355,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 2) require.True(t, strings.HasSuffix(events[0].Config.Info.Path(), filepath.Join("dir", "foo.js"))) @@ -390,7 +391,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 2) sort.Slice(events, func(i, j int) bool { @@ -414,7 +415,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 0) }, @@ -433,7 +434,7 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}, Include: []string{"foo"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}, Include: []string{"foo"}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 1) }, @@ -477,11 +478,97 @@ func TestProvider(t *testing.T) { Data: []byte("foobar"), }, }}, - cfg: static.FileProvider{Directories: []string{"./"}, Include: []string{"*.js"}}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./"}}, Include: []string{"*.js"}}, test: func(t *testing.T, events []dynamic.ConfigEvent) { require.Len(t, events, 4) }, }, + { + name: "include index.js", + fs: &filetest.MockFS{Entries: []*filetest.Entry{ + { + Name: "/bar.txt", + IsDir: false, + Data: []byte("foobar"), + }, + { + Name: "dir/index.js", + IsDir: false, + Data: []byte("foobar"), + }, + { + Name: "dir/foo.js", + IsDir: false, + Data: []byte("foobar"), + }, + }}, + cfg: static.FileProvider{Directories: []static.FileConfig{{Path: "./", Include: []string{"*.js"}}}}, + test: func(t *testing.T, events []dynamic.ConfigEvent) { + require.Len(t, events, 2) + require.Equal(t, "dir/index.js", events[0].Name) + require.Equal(t, "dir/foo.js", events[1].Name) + }, + }, + { + name: "include and exclude", + fs: &filetest.MockFS{Entries: []*filetest.Entry{ + { + Name: "/bar.txt", + IsDir: false, + Data: []byte("foobar"), + }, + { + Name: "dir/index.js", + IsDir: false, + Data: []byte("foobar"), + }, + { + Name: "dir/foo.js", + IsDir: false, + Data: []byte("foobar"), + }, + }}, + cfg: static.FileProvider{Directories: []static.FileConfig{{ + Path: "./", + Include: []string{"*.js"}, + Exclude: []string{"*foo.js"}, + }}}, + test: func(t *testing.T, events []dynamic.ConfigEvent) { + require.Len(t, events, 1) + require.Equal(t, "dir/index.js", events[0].Name) + }, + }, + { + name: "extend include", + fs: &filetest.MockFS{Entries: []*filetest.Entry{ + { + Name: "/bar.txt", + IsDir: false, + Data: []byte("foobar"), + }, + { + Name: "dir/index.js", + IsDir: false, + Data: []byte("foobar"), + }, + { + Name: "dir/foo.ts", + IsDir: false, + Data: []byte("foobar"), + }, + }}, + cfg: static.FileProvider{ + Include: []string{"*.js"}, + Directories: []static.FileConfig{{ + Path: "./", + Include: []string{"*.ts"}, + }}}, + test: func(t *testing.T, events []dynamic.ConfigEvent) { + require.Len(t, events, 2) + require.Equal(t, "dir/index.js", events[0].Name) + require.Equal(t, "dir/foo.ts", events[1].Name) + }, + }, } t.Parallel() @@ -595,8 +682,8 @@ func TestProvider_File(t *testing.T) { func TestWatch_AddFile(t *testing.T) { ch := make(chan dynamic.ConfigEvent) tempDir := t.TempDir() - t.Cleanup(func() { os.RemoveAll(tempDir) }) - p := New(static.FileProvider{Directories: []string{tempDir}}) + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + p := New(static.FileProvider{Directories: []static.FileConfig{{Path: tempDir}}}) pool := safe.NewPool(context.Background()) defer pool.Stop() @@ -604,7 +691,7 @@ func TestWatch_AddFile(t *testing.T) { require.NoError(t, err) time.Sleep(500 * time.Millisecond) - err = createTempFile("./test/openapi.yml", p.cfg.Directories[0]) + err = createTempFile("./test/openapi.yml", p.cfg.Directories[0].Path) require.NoError(t, err) timeout := time.After(5 * time.Second) @@ -620,8 +707,8 @@ func TestWatch_AddFile(t *testing.T) { func TestWatch_Create_SubFolder_And_Add_File(t *testing.T) { ch := make(chan dynamic.ConfigEvent) tempDir := t.TempDir() - t.Cleanup(func() { os.RemoveAll(tempDir) }) - p := New(static.FileProvider{Directories: []string{tempDir}}) + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + p := New(static.FileProvider{Directories: []static.FileConfig{{Path: tempDir}}}) pool := safe.NewPool(context.Background()) defer pool.Stop() @@ -629,7 +716,7 @@ func TestWatch_Create_SubFolder_And_Add_File(t *testing.T) { require.NoError(t, err) time.Sleep(500 * time.Millisecond) - err = createTempFile("./test/openapi.yml", filepath.Join(p.cfg.Directories[0], "foo")) + err = createTempFile("./test/openapi.yml", filepath.Join(p.cfg.Directories[0].Path, "foo")) require.NoError(t, err) timeout := time.After(5 * time.Second) @@ -647,9 +734,9 @@ func TestWatch_UpdateFile_When_Skipped_But_Referenced(t *testing.T) { defer close(ch) tempDir := t.TempDir() - t.Cleanup(func() { os.RemoveAll(tempDir) }) + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) - p := New(static.FileProvider{Directories: []string{tempDir}}) + p := New(static.FileProvider{Directories: []static.FileConfig{{Path: tempDir}}}) pool := safe.NewPool(context.Background()) defer func() { pool.Stop() @@ -687,7 +774,7 @@ func TestWatch_UpdateFile_When_Skipped_But_Referenced(t *testing.T) { func createAndStartFileProvider(t *testing.T, files ...string) (*Provider, chan dynamic.ConfigEvent) { tempDir := t.TempDir() - t.Cleanup(func() { os.RemoveAll(tempDir) }) + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) for _, file := range files { if len(file) > 0 { @@ -712,7 +799,7 @@ func createDirectoryProvider(t *testing.T, files ...string) *Provider { for _, file := range files { tempDir := t.TempDir() dirs = append(dirs, tempDir) - t.Cleanup(func() { os.RemoveAll(tempDir) }) + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) if len(file) > 0 { err := createTempFile(file, tempDir) @@ -720,7 +807,7 @@ func createDirectoryProvider(t *testing.T, files ...string) *Provider { } } - p := New(static.FileProvider{Directories: []string{strings.Join(dirs, string(os.PathListSeparator))}}) + p := New(static.FileProvider{Directories: []static.FileConfig{{Path: strings.Join(dirs, string(os.PathListSeparator))}}}) return p } diff --git a/config/dynamic/provider/git/git.go b/config/dynamic/provider/git/git.go index 6474d7d90..31724ec7a 100644 --- a/config/dynamic/provider/git/git.go +++ b/config/dynamic/provider/git/git.go @@ -140,17 +140,20 @@ func (p *Provider) initRepository(r *repository, ch chan dynamic.ConfigEvent, po r.repo, err = git.PlainClone(r.localPath, false, options) if err != nil { + p.cleanup(r) return fmt.Errorf("unable to clone git %q: %v", r.repoUrl, err) } r.wt, err = r.repo.Worktree() if err != nil { + p.cleanup(r) return fmt.Errorf("unable to get git worktree: %v", err.Error()) } r.pullOptions = &git.PullOptions{SingleBranch: true, Depth: 1} ref, err := r.repo.Head() if err != nil { + p.cleanup(r) return fmt.Errorf("unable to get git head: %w", err) } r.hash = ref.Hash() @@ -166,6 +169,7 @@ func (p *Provider) initRepository(r *repository, ch chan dynamic.ConfigEvent, po for { select { case <-ctx.Done(): + p.cleanup(r) return case e := <-chFile: path := e.Name @@ -188,7 +192,7 @@ func (p *Provider) initRepository(r *repository, ch chan dynamic.ConfigEvent, po } func (p *Provider) startFileProvider(dir string, ch chan dynamic.ConfigEvent, pool *safe.Pool) { - f := file.New(static.FileProvider{Directories: []string{dir}}) + f := file.New(static.FileProvider{Directories: []static.FileConfig{{Path: dir}}}) f.SkipPrefix = append(f.SkipPrefix, ".git") err := f.Start(ch, pool) if err != nil { @@ -196,12 +200,10 @@ func (p *Provider) startFileProvider(dir string, ch chan dynamic.ConfigEvent, po } } -func (p *Provider) cleanup() { - for _, repo := range p.repositories { - err := os.RemoveAll(repo.localPath) - if err != nil { - log.Debugf("unable to remove temp dir %q: %v", repo.localPath, err.Error()) - } +func (p *Provider) cleanup(r *repository) { + err := os.RemoveAll(r.localPath) + if err != nil { + log.Debugf("unable to remove temp dir %q: %v", r.localPath, err.Error()) } } @@ -327,6 +329,11 @@ func wrapConfig(c *dynamic.Config, r *repository) { // to query git log takes too long Time: time.Now(), } + h, err := r.repo.Head() + if err == nil { + ref := h.Name() + info.Tags = append(info.Tags, ref.Short()) + } dynamic.Wrap(info, c) } diff --git a/config/dynamic/provider/git/git_test.go b/config/dynamic/provider/git/git_test.go index 5fba0a36a..8b83e698c 100644 --- a/config/dynamic/provider/git/git_test.go +++ b/config/dynamic/provider/git/git_test.go @@ -2,9 +2,6 @@ package git import ( "context" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/stretchr/testify/require" "mokapi/config/dynamic" "mokapi/config/static" "mokapi/safe" @@ -14,6 +11,10 @@ import ( "strings" "testing" "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/require" ) var gitFiles = map[string]struct{}{ @@ -54,6 +55,7 @@ func TestGit(t *testing.T) { require.Equal(t, "https://github.com/marle3003/mokapi-example.git?file=/"+name, c.Config.Info.Path()) require.Contains(t, gitFiles, name) require.NotNil(t, c.Config.Info.Checksum) + require.Equal(t, []string{"main"}, c.Config.Info.Tags) } } require.Equal(t, 4, count) @@ -225,8 +227,38 @@ Stop: require.Len(t, files, 1) } -// go-git requires git installed for file:// repositories -func testGitSimpleUrl(t *testing.T) { +func TestGitFileUpdate(t *testing.T) { + repo := newGitRepo(t, t.Name()) + repo.commit(t, "foo.txt", "foo") + + g := New(static.GitProvider{Urls: []string{repo.url.String()}, PullInterval: "3s"}) + p := safe.NewPool(context.Background()) + defer p.Stop() + + ch := make(chan dynamic.ConfigEvent) + defer close(ch) + + err := g.Start(ch, p) + require.NoError(t, err) + + // wait init + e := wait(ch, 3*time.Second) + initChecksum := e.Config.Info.Checksum + initTime := e.Config.Info.Time + + repo.commit(t, "foo.txt", "bar") + + // git deletes the file first + e = wait(ch, 5*time.Second) + require.Equal(t, dynamic.Delete, e.Event) + e = wait(ch, 5*time.Second) + require.NotNil(t, e.Config) + require.Equal(t, []byte("bar"), e.Config.Raw) + require.NotEqual(t, initChecksum, e.Config.Info.Checksum) + require.Greater(t, e.Config.Info.Time, initTime) +} + +func TestGitSimpleUrl(t *testing.T) { repo := newGitRepo(t, t.Name()) defer func() { err := os.RemoveAll(repo.dir) @@ -247,7 +279,7 @@ func testGitSimpleUrl(t *testing.T) { case <-time.After(1 * time.Second): t.Fatal("Timeout") case e := <-ch: - require.Equal(t, "foo.txt", filepath.Base(e.Config.Info.Url.String())) + require.Equal(t, "TestGitSimpleUrl?file=%2Ffoo.txt", filepath.Base(e.Config.Info.Url.String())) } } @@ -330,3 +362,17 @@ func (g *gitTestRepo) commit(t *testing.T, file, content string) { _, err = w.Commit("added "+file, &git.CommitOptions{Author: &object.Signature{When: ts}}) require.NoError(t, err) } + +func wait(ch chan dynamic.ConfigEvent, timeout time.Duration) *dynamic.ConfigEvent { + chTimeout := time.After(timeout) +Stop: + for { + select { + case <-chTimeout: + break Stop + case e := <-ch: + return &e + } + } + return nil +} diff --git a/config/static/static_config.go b/config/static/static_config.go index 38976a4ef..a29557435 100644 --- a/config/static/static_config.go +++ b/config/static/static_config.go @@ -10,6 +10,7 @@ import ( "strings" log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" ) type Config struct { @@ -69,10 +70,17 @@ type Search struct { } type FileProvider struct { - Filenames []string `aliases:"filename" explode:"filename"` - Directories []string `aliases:"directory" explode:"directory"` - SkipPrefix []string `yaml:"skipPrefix" json:"skipPrefix" flag:"skip-prefix"` + Filenames []string `aliases:"filename" explode:"filename"` + Directories []FileConfig `aliases:"directory" explode:"directory"` + SkipPrefix []string `yaml:"skipPrefix" json:"skipPrefix" flag:"skip-prefix"` Include []string + Exclude []string +} + +type FileConfig struct { + Path string + Include []string + Exclude []string } type GitProvider struct { @@ -228,3 +236,51 @@ func (d *DataGen) OptionalPropertiesProbability() float64 { } return f } + +func (fc *FileConfig) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + fc.Path = value.Value + return nil + } + type alias FileConfig + tmp := alias(*fc) + err := value.Decode(&tmp) + if err != nil { + return err + } + *fc = FileConfig(tmp) + return nil +} + +func (fc *FileConfig) UnmarshalJSON(b []byte) error { + switch b[0] { + case '"': + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + fc.Path = s + return nil + case '{': + dec := json.NewDecoder(bytes.NewReader(b)) + type alias FileConfig + tmp := alias(*fc) + err := dec.Decode(&tmp) + if err != nil { + return err + } + *fc = FileConfig(tmp) + return nil + default: + return fmt.Errorf("unexpected JSON type: %s", string(b)) + } +} + +func (fc *FileConfig) Set(v any) error { + switch x := v.(type) { + case string: + fc.Path = x + return nil + } + return fmt.Errorf("expected string, got %T", v) +} diff --git a/config/static/static_config_test.go b/config/static/static_config_test.go index a7cbf76e1..a1582cbb5 100644 --- a/config/static/static_config_test.go +++ b/config/static/static_config_test.go @@ -2,874 +2,128 @@ package static_test import ( "encoding/json" - "mokapi/config/decoders" - "mokapi/config/dynamic/provider/file/filetest" "mokapi/config/static" - "os" "testing" - "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) -func TestStaticConfig(t *testing.T) { +func TestYaml(t *testing.T) { testcases := []struct { name string - test func(t *testing.T) + data string + test func(t *testing.T, cfg *static.Config) }{ { - name: "assign with =", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `--ConfigFile=foo`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, "foo", cfg.ConfigFile) - }, - }, - { - name: "assign without =", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `--ConfigFile`, "foo") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, "foo", cfg.ConfigFile) - }, - }, - { - name: "--help", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `--help`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, true, cfg.Help) - }, - }, - { - name: "-h", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `-h`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, true, cfg.Help) - }, - }, - { - name: "--version", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `--version`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, true, cfg.Version) - }, - }, - { - name: "-v", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `-v`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, true, cfg.Version) - }, - }, - { - name: "json", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `--providers.file={"filename":"foo.yaml","directory":"foo", "skipPrefix":["_"]}`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, []string{"foo.yaml"}, cfg.Providers.File.Filenames) - require.Equal(t, []string{"foo"}, cfg.Providers.File.Directories) - require.Equal(t, []string{"_"}, cfg.Providers.File.SkipPrefix) - }, - }, - { - name: "shorthand object", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `--providers.file`, "filename=foo.yaml") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, []string{"foo.yaml"}, cfg.Providers.File.Filenames) - }, - }, - { - name: "args", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--providers.git.repositories[0].url=https://github.com/PATH-TO/REPOSITORY?ref=branch-name") - os.Args = append(os.Args, "--providers.git.repositories[0].pullInterval=5m") - os.Args = append(os.Args, "--providers.git.repositories[1].pullInterval=5h") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, "https://github.com/PATH-TO/REPOSITORY?ref=branch-name", cfg.Providers.Git.Repositories[0].Url) - require.Equal(t, "5m", cfg.Providers.Git.Repositories[0].PullInterval) - require.Equal(t, "", cfg.Providers.Git.Repositories[1].Url) - require.Equal(t, "5h", cfg.Providers.Git.Repositories[1].PullInterval) - }, - }, - { - name: "shorthand array", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `--providers.git.repositories`, "url=foo,pullInterval=5m url=bar,pullInterval=5h") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Len(t, cfg.Providers.Git.Repositories, 2) - require.Equal(t, "foo", cfg.Providers.Git.Repositories[0].Url) - require.Equal(t, "5m", cfg.Providers.Git.Repositories[0].PullInterval) - require.Equal(t, "bar", cfg.Providers.Git.Repositories[1].Url) - require.Equal(t, "5h", cfg.Providers.Git.Repositories[1].PullInterval) - }, - }, - { - name: "explode with json", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, `--providers.git.repository={"url":"https://github.com/PATH-TO/REPOSITORY?ref=branch-name","pullInterval":"5m"}`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - require.Equal(t, "https://github.com/PATH-TO/REPOSITORY?ref=branch-name", cfg.Providers.Git.Repositories[0].Url) - require.Equal(t, "5m", cfg.Providers.Git.Repositories[0].PullInterval) - }, - }, - { - name: "env var", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - err := os.Setenv("MOKAPI_Providers_GIT_Repositories[0]_Url", "https://github.com/PATH-TO/REPOSITORY") - require.NoError(t, err) - defer os.Unsetenv("MOKAPI_Providers_GIT_Repositories[0]_Url") - err = os.Setenv("MOKAPI_Providers_GIT_Repositories[0]_PullInterval", "3m") - require.NoError(t, err) - defer os.Unsetenv("MOKAPI_Providers_GIT_Repositories[0]_PullInterval") - - cfg := static.Config{} - err = decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, "https://github.com/PATH-TO/REPOSITORY", cfg.Providers.Git.Repositories[0].Url) - require.Equal(t, "3m", cfg.Providers.Git.Repositories[0].PullInterval) - }, - }, - { - name: "api port as env var", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - err := os.Setenv("MOKAPI_API_PORT", "1234") - require.NoError(t, err) - defer os.Unsetenv("MOKAPI_API_PORT") - - cfg := static.Config{} - err = decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, 1234, cfg.Api.Port) - }, - }, - { - name: "file provider include", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.file.include", `mokapi/**/*.json mokapi/**/*.yaml "foo bar/**/*.yaml"`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"mokapi/**/*.json", "mokapi/**/*.yaml", "foo bar/**/*.yaml"}, cfg.Providers.File.Include) - }, - }, - { - name: "file provider include with space", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.file.include", `"C:\Documents and Settings\" C:\Work"`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"C:\\Documents and Settings\\", "C:\\Work"}, cfg.Providers.File.Include) - }, - }, - { - name: "file provider include twice", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.file.include", "foo", "--Providers.file.include", "bar") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"foo", "bar"}, cfg.Providers.File.Include) - }, - }, - { - name: "file provider include overwrite", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.file.include", "foo", "--Providers.file.include[0]", "bar") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"bar"}, cfg.Providers.File.Include) - }, - }, - { - name: "git provider set url", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.git.Url", `foo`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"foo"}, cfg.Providers.Git.Urls) - }, - }, - { - name: "git provider set urls", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.git.Urls", `foo`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"foo"}, cfg.Providers.Git.Urls) - }, - }, - { - name: "http provider set url", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.Http.Url", `foo`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"foo"}, cfg.Providers.Http.Urls) - }, - }, - { - name: "http provider set urls using explode", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.Http.Url", `foo`) - os.Args = append(os.Args, "--Providers.Http.Url", `bar`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"foo", "bar"}, cfg.Providers.Http.Urls) - }, - }, - { - name: "http provider set urls", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.Http.Urls", `foo bar`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"foo", "bar"}, cfg.Providers.Http.Urls) - }, - }, - { - name: "http provider", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--Providers.Http", `urls=foo bar,pollInterval=5s,pollTimeout=30s,proxy=bar,tlsSkipVerify=true`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"foo", "bar"}, cfg.Providers.Http.Urls) - require.Equal(t, "5s", cfg.Providers.Http.PollInterval) - require.Equal(t, "30s", cfg.Providers.Http.PollTimeout) - require.Equal(t, true, cfg.Providers.Http.TlsSkipVerify) - require.Equal(t, "bar", cfg.Providers.Http.Proxy) - }, - }, - { - name: "npm provider global folders", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--providers-npm-global-folders", `/etc/foo`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, "/etc/foo", cfg.Providers.Npm.GlobalFolders[0]) - }, - }, - { - name: "config", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--config", `{"openapi": "3.0"}`) - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Len(t, cfg.Configs, 1) - require.Equal(t, "{\"openapi\": \"3.0\"}", cfg.Configs[0]) - }, - }, - { - name: "config file://", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, "--config", `file://C:/temp/patch.yaml`) - - cfg := static.Config{} - err := decoders.Load( - []decoders.ConfigDecoder{ - decoders.NewFlagDecoderWithReader(&filetest.MockFS{ - Entries: []*filetest.Entry{ - { - Name: "/temp/patch.yaml", - Data: []byte("{\"openapi\": \"3.0\"}"), - }, - }, - WorkingDir: "", - })}, &cfg) - require.NoError(t, err) - - require.Len(t, cfg.Configs, 1) - require.Equal(t, "{\"openapi\": \"3.0\"}", cfg.Configs[0]) - }, - }, - { - name: "configfile json", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--configfile", "foo.json") - - read := func(path string) ([]byte, error) { - return []byte(`{"configs": [ { "openapi": "3.0", "info": { "name": "foo" } } ]}`), nil - } - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFileDecoder(read), decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - - require.Len(t, cfg.Configs, 1) - - actual := map[string]interface{}{} - err = json.Unmarshal([]byte(cfg.Configs[0]), &actual) - require.NoError(t, err) - expected := map[string]interface{}{ - "openapi": "3.0", - "info": map[string]interface{}{ - "name": "foo", - }, - } - require.Equal(t, expected, actual) - }, - }, - { - name: "configfile yaml", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--configfile", "foo.yaml") - - read := func(path string) ([]byte, error) { - return []byte(` -configs: - - openapi: "3.0" - info: - name: foo -`), nil - } - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFileDecoder(read), decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - - actual := map[string]interface{}{} - err = json.Unmarshal([]byte(cfg.Configs[0]), &actual) - require.NoError(t, err) - expected := map[string]interface{}{ - "openapi": "3.0", - "info": map[string]interface{}{ - "name": "foo", - }, - } - - require.Len(t, cfg.Configs, 1) - require.Equal(t, expected, actual) - }, - }, - { - name: "config-file", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--config-file", "foo.json") - - read := func(path string) ([]byte, error) { - return []byte(`{"log": { "level": "error" } }`), nil - } - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFileDecoder(read), decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - - require.Equal(t, "error", cfg.Log.Level) - }, - }, - { - name: "cli-input", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--cli-input", "foo.json") - - read := func(path string) ([]byte, error) { - return []byte(`{"log": { "level": "error" } }`), nil - } - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFileDecoder(read), decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - - require.Equal(t, "error", cfg.Log.Level) - }, - }, - { - name: "cli-input file provider directories", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--cli-input", "foo.yaml") - - read := func(path string) ([]byte, error) { - return []byte(` + name: "directories as string array", + data: ` providers: file: - directory: foo -`), nil - } - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFileDecoder(read), decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"foo"}, cfg.Providers.File.Directories) + directories: + - ./dir +`, + test: func(t *testing.T, cfg *static.Config) { + require.Equal(t, []static.FileConfig{{Path: "./dir"}}, cfg.Providers.File.Directories) }, }, { - name: "cli-input file provider directories", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--cli-input", "foo.yaml") - - read := func(path string) ([]byte, error) { - return []byte(` + name: "directories as file config", + data: ` providers: file: - directories: ["/foo", "/bar"] -`), nil - } - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFileDecoder(read), decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"/foo", "/bar"}, cfg.Providers.File.Directories) - }, - }, - { - name: "cli-input file provider directory", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--cli-input", "foo.json") - - read := func(path string) ([]byte, error) { - return []byte(`{"providers":{"file":{"directory":"foo"}}}`), nil - } - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFileDecoder(read), decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - - require.Equal(t, []string{"foo"}, cfg.Providers.File.Directories) - }, - }, - { - name: "positional parameter file", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "foo.json") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.NoError(t, err) - - require.Equal(t, []string{"foo.json"}, cfg.Args) - require.Equal(t, "foo.json", cfg.Providers.File.Filenames[0]) - }, - }, - { - name: "positional parameter http", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "http://foo.io/foo.json") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.NoError(t, err) - - require.Equal(t, "http://foo.io/foo.json", cfg.Providers.Http.Urls[0]) - }, - }, - { - name: "positional parameter https", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "https://foo.io/foo.json") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.NoError(t, err) - - require.Equal(t, "https://foo.io/foo.json", cfg.Providers.Http.Urls[0]) - }, - }, - { - name: "positional parameter git with https", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "git+https://foo.io/foo.json") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.NoError(t, err) - - require.Equal(t, "https://foo.io/foo.json", cfg.Providers.Git.Urls[0]) - }, - }, - { - name: "positional parameter npm", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "npm://bar/foo.txt?scope=@foo") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.NoError(t, err) - - require.Equal(t, "npm://bar/foo.txt?scope=@foo", cfg.Providers.Npm.Packages[0].Name) - }, - }, - { - name: "positional parameter not supported", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "foo://bar") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.EqualError(t, err, "positional argument is not supported: foo://bar") - }, - }, - { - name: "positional parameter Windows path", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "C:\\bar") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.Equal(t, "C:\\bar", cfg.Providers.File.Filenames[0]) - }, - }, - { - name: "not defined data-gen optional properties", - test: func(t *testing.T) { - hook := test.NewGlobal() - - os.Args = append(os.Args, "mokapi.exe") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.Equal(t, 0.85, cfg.DataGen.OptionalPropertiesProbability()) - require.Len(t, hook.Entries, 0) - }, - }, - { - name: "data-gen optional properties", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--data-gen-optionalProperties", "often") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.Equal(t, 0.85, cfg.DataGen.OptionalPropertiesProbability()) - }, - }, - { - name: "data-gen optional properties always", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--data-gen-optionalProperties", "always") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.Equal(t, 1.0, cfg.DataGen.OptionalPropertiesProbability()) - }, - }, - { - name: "data-gen optional properties sometimes", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--data-gen-optionalProperties", "sometimes") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.Equal(t, 0.5, cfg.DataGen.OptionalPropertiesProbability()) - }, - }, - { - name: "data-gen optional properties 0.3", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe", "--data-gen-optionalProperties", "0.3") - - cfg := static.Config{} - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFlagDecoder()}, &cfg) - require.NoError(t, err) - err = cfg.Parse() - require.Equal(t, 0.3, cfg.DataGen.OptionalPropertiesProbability()) + directories: + - path: ./dir +`, + test: func(t *testing.T, cfg *static.Config) { + require.Equal(t, []static.FileConfig{{Path: "./dir"}}, cfg.Providers.File.Directories) }, }, { - name: "data-gen env var", - test: func(t *testing.T) { - os.Args = append(os.Args, "mokapi.exe") - err := os.Setenv("MOKAPI_DATA_GEN_OPTIONALPROPERTIES", "sometimes") - require.NoError(t, err) - defer os.Unsetenv("MOKAPI_DATA_GEN_OPTIONALPROPERTIES") - - cfg := static.Config{} - err = decoders.Load([]decoders.ConfigDecoder{&decoders.FlagDecoder{}}, &cfg) - require.NoError(t, err) - - require.Equal(t, 0.5, cfg.DataGen.OptionalPropertiesProbability()) + name: "directories as mixed", + data: ` +providers: + file: + directories: + - path: ./dir + include: ['*.js'] + - ./foo +`, + test: func(t *testing.T, cfg *static.Config) { + require.Equal(t, []static.FileConfig{ + {Path: "./dir", Include: []string{"*.js"}}, + {Path: "./foo"}, + }, cfg.Providers.File.Directories) }, }, } for _, tc := range testcases { - tc := tc t.Run(tc.name, func(t *testing.T) { - os.Args = nil - tc.test(t) + var cfg *static.Config + err := yaml.Unmarshal([]byte(tc.data), &cfg) + require.NoError(t, err) + tc.test(t, cfg) }) } } -func TestFileProvider(t *testing.T) { +func TestJson(t *testing.T) { testcases := []struct { name string - args []string - test func(t *testing.T, cfg *static.Config, err error) + data string + test func(t *testing.T, cfg *static.Config) }{ { - name: "skipPrefix single element appends to default value", - args: []string{"--providers-file-skip-prefix", "foo"}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Len(t, cfg.Providers.File.SkipPrefix, 2) - require.Contains(t, cfg.Providers.File.SkipPrefix, "foo") - require.Contains(t, cfg.Providers.File.SkipPrefix, "_") - }, - }, - { - name: "skipPrefix list replace default value", - args: []string{"--providers-file-skip-prefix", "foo", "bar"}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Len(t, cfg.Providers.File.SkipPrefix, 2) - require.Contains(t, cfg.Providers.File.SkipPrefix, "foo") - require.Contains(t, cfg.Providers.File.SkipPrefix, "bar") - }, - }, - { - name: "feature foo", - args: []string{"--feature", "foo"}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Len(t, cfg.Features, 1) - require.Equal(t, "foo", cfg.Features[0]) - }, - }, - { - name: "event store size", - args: []string{"--event-store-default", "size=200"}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Equal(t, int64(200), cfg.Event.Store["default"].Size) - }, - }, - { - name: "event store size", - args: []string{"--event-store-default-size", "200"}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Equal(t, int64(200), cfg.Event.Store["default"].Size) - }, - }, - { - name: "default event store size", - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Equal(t, int64(100), cfg.Event.Store["default"].Size) - }, - }, - { - name: "event store for foo", - args: []string{"--event-store-foo-size", "250"}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Equal(t, int64(250), cfg.Event.Store["foo"].Size) - }, - }, - { - name: "event store name with spaces", - args: []string{"--event-store", `Swagger PetStore API={"size": 250}`}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Equal(t, int64(250), cfg.Event.Store["Swagger PetStore API"].Size) - }, - }, - { - name: "event store name with spaces followed by positional parameter: error", - args: []string{"--event-store", `Swagger PetStore API={"size": 250}`, "smtp.yaml"}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.EqualError(t, err, "configuration error 'event-store' value '[Swagger PetStore API={\"size\": 250} smtp.yaml]': expected key to set map value") - }, - }, - { - name: "event store name with spaces and positional parameter", - args: []string{"--event-store", `Swagger PetStore API={"size": 250}`, "--", "smtp.yaml"}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Equal(t, int64(250), cfg.Event.Store["Swagger PetStore API"].Size) + name: "directories as string array", + data: `{ +"providers": { + "file": { + "directories": ["./dir"] + } +}} +`, + test: func(t *testing.T, cfg *static.Config) { + require.Equal(t, []static.FileConfig{{Path: "./dir"}}, cfg.Providers.File.Directories) }, }, { - name: "positional parameter followed event store name with spaces and ", - args: []string{"smtp.yaml", "--event-store", `Swagger PetStore API={"size": 250}`}, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Equal(t, int64(250), cfg.Event.Store["Swagger PetStore API"].Size) - }, - }, - } - - for _, tc := range testcases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - os.Args = nil - os.Args = append(os.Args, "mokapi.exe") - os.Args = append(os.Args, tc.args...) - - cfg := static.NewConfig() - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewDefaultFileDecoder(), decoders.NewFlagDecoder()}, cfg) - - tc.test(t, cfg, err) - }) - } -} - -func TestFileProvider_File(t *testing.T) { - testcases := []struct { - name string - content string - test func(t *testing.T, cfg *static.Config, err error) - }{ - { - name: "empty file", - content: "", - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) + name: "directories as file config", + data: `{ +"providers": { + "file": { + "directories": [{"path":"./dir"}] + } +}} +`, + test: func(t *testing.T, cfg *static.Config) { + require.Equal(t, []static.FileConfig{{Path: "./dir"}}, cfg.Providers.File.Directories) }, }, { - name: "git repo with GitHub Auth", - content: ` -providers: - git: - repositories: - - auth: - github: - appId: 1234 + name: "directories as mixed", + data: `{ +"providers": { + "file": { + "directories": [{"path":"./dir","include":["*.js"]}, "./foo"] + } +}} `, - test: func(t *testing.T, cfg *static.Config, err error) { - require.NoError(t, err) - require.Len(t, cfg.Providers.Git.Repositories, 1) - require.NotNil(t, cfg.Providers.Git.Repositories[0].Auth.GitHub) - require.Equal(t, int64(1234), cfg.Providers.Git.Repositories[0].Auth.GitHub.AppId) + test: func(t *testing.T, cfg *static.Config) { + require.Equal(t, []static.FileConfig{ + {Path: "./dir", Include: []string{"*.js"}}, + {Path: "./foo"}, + }, cfg.Providers.File.Directories) }, }, } for _, tc := range testcases { - tc := tc t.Run(tc.name, func(t *testing.T) { - cfg := static.NewConfig() - - read := func(path string) ([]byte, error) { - return []byte(tc.content), nil - } - - err := decoders.Load([]decoders.ConfigDecoder{decoders.NewFileDecoder(read)}, cfg) - - tc.test(t, cfg, err) + var cfg *static.Config + err := json.Unmarshal([]byte(tc.data), &cfg) + require.NoError(t, err) + tc.test(t, cfg) }) } } diff --git a/docs/config.json b/docs/config.json index 9f8589d3a..92ec25bc3 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1,223 +1,660 @@ { - "Guides": { - "items": { - "Welcome": "guides/get-started/welcome.md", - "Get Started": { - "items": { - "Installation": "guides/get-started/installation.md", - "Running": "guides/get-started/running.md", - "Test Data": "guides/get-started/test-data.md", - "Dashboard": "guides/get-started/dashboard.md" - } + "Docs": { + "type": "root", + "items": [ + { + "label": "Welcome", + "source": "get-started/welcome.md", + "path": "/docs/welcome" }, - "HTTP": { - "index": "guides/http/overview.md", - "items": { - "Quick Start": "guides/http/quick-start.md", - "HTTPS & TLS": "guides/http/tls.md", - "Dashboard": "guides/http/dashboard.md" - } - }, - "Kafka": { - "index": "guides/kafka/overview.md", - "items": { - "Quick Start": "guides/kafka/quick-start.md", - "Config": "guides/kafka/config.md" - } - }, - "LDAP": { - "index": "guides/ldap/intro.md", - "items": { - "Quick Start": "guides/ldap/quick-start.md" - } - }, - "Mail": { - "index": "guides/mail/overview.md", - "items": { - "Quick Start": "guides/mail/quick-start.md", - "Client": "guides/mail/client.md", - "Rules": "guides/mail/rules.md" - } - } - } - }, - "Configuration": { - "items": { - "Introduction": "configuration/introduction.md", - "Patching": "configuration/patching.md", - "Static": { - "expanded": true, - "items": { - "CLI Usage": "configuration/static/cli.md" - } - }, - "Dynamic": { - "expanded": true, - "items": { - "Overview": "configuration/dynamic/overview.md", - "File": "configuration/dynamic/file.md", - "HTTP": "configuration/dynamic/http.md", - "GIT": "configuration/dynamic/git.md", - "NPM": "configuration/dynamic/npm.md" - } - }, - "Reference": "configuration/reference.md" - } - }, - "JavaScript API": { - "items": { - "Overview": "javascript-api/overview.md", - "Modules": "javascript-api/modules.md", - "globals": { - "items": { - "open": "javascript-api/open.md" - } - }, - "mokapi": { - "items": { - "cron": "javascript-api/mokapi/cron.md", - "date": "javascript-api/mokapi/date.md", - "env": "javascript-api/mokapi/env.md", - "every": "javascript-api/mokapi/every.md", - "on": "javascript-api/mokapi/on.md", - "sleep": "javascript-api/mokapi/sleep.md", - "marshal": "javascript-api/mokapi/marshal.md", - "EventHandler": { - "index": "javascript-api/mokapi/eventhandler/eventhandler.md", - "items": { - "EventArgs": "javascript-api/mokapi/eventhandler/eventargs.md", - "HttpEventHandler": "javascript-api/mokapi/eventhandler/httpeventhandler.md", - "HttpRequest": "javascript-api/mokapi/eventhandler/httprequest.md", - "HttpResponse": "javascript-api/mokapi/eventhandler/httpresponse.md", - "KafkaEventHandler": "javascript-api/mokapi/eventhandler/kafkaeventhandler.md", - "KafkaEventMessage": "javascript-api/mokapi/eventhandler/kafkaeventmessage.md", - "ScheduledEventArgs": "javascript-api/mokapi/eventhandler/scheduledeventargs.md" - } + { + "label": "Get Started", + "type": "headline", + "items": [ + { + "label": "Installation", + "source": "get-started/installation.md", + "path": "/docs/get-started/installation" + }, + { + "label": "Running", + "source": "get-started/running.md", + "path": "/docs/get-started/running" + }, + { + "label": "Test Data", + "source": "get-started/test-data.md", + "path": "/docs/get-started/test-data" + }, + { + "label": "Dashboard", + "source": "get-started/dashboard.md", + "path": "/docs/get-started/dashboard" } - } - }, - "mokapi/faker": { - "items": { - "fake": "javascript-api/mokapi-faker/fake.md" - } + ] }, - "mokapi/http": { - "items": { - "get": "javascript-api/mokapi-http/get.md", - "post": "javascript-api/mokapi-http/post.md", - "put": "javascript-api/mokapi-http/put.md", - "head": "javascript-api/mokapi-http/head.md", - "delete": "javascript-api/mokapi-http/delete.md", - "options": "javascript-api/mokapi-http/options.md", - "patch": "javascript-api/mokapi-http/patch.md", - "fetch": "javascript-api/mokapi-http/fetch.md", - "HttpResponse": "javascript-api/mokapi-http/httpresponse.md", - "FetchOptions": "javascript-api/mokapi-http/fetchoptions.md", - "Args": "javascript-api/mokapi-http/args.md" - } - }, - "mokapi/kafka": { - "items": { - "produce": "javascript-api/mokapi-kafka/produce.md", - "ProduceArgs": "javascript-api/mokapi-kafka/produceargs.md", - "ProduceResult": "javascript-api/mokapi-kafka/produceresult.md", - "Message": "javascript-api/mokapi-kafka/message.md", - "MessageResult": "javascript-api/mokapi-kafka/messageresult.md", - "ProduceRetry": "javascript-api/mokapi-kafka/produceretry.md" - } - }, - "mokapi/mustache": { - "items": { - "render": "javascript-api/mokapi-mustache/render.md" - } + { + "label": "Protocol", + "type": "headline", + "items": [ + { + "label": "HTTP", + "items": [ + { + "label": "Overview", + "source": "http/overview.md", + "path": "/docs/http/overview" + }, + { + "label": "Quick Start", + "source": "http/quick-start.md", + "path": "/docs/http/quick-start" + }, + { + "label": "HTTPS & TLS", + "source": "http/tls.md", + "path": "/docs/http/tls" + }, + { + "label": "Dashboard", + "source": "http/dashboard.md", + "path": "/docs/http/dashboard" + } + ] + }, + { + "label": "Kafka", + "items": [ + { + "label": "Overview", + "source": "kafka/overview.md", + "path": "/docs/kafka/overview" + }, + { + "label": "Quick Start", + "source": "kafka/quick-start.md", + "path": "/docs/kafka/quick-start" + }, + { + "label": "Config", + "source": "kafka/config.md", + "path": "/docs/kafka/config" + } + ] + }, + { + "label": "LDAP", + "items": [ + { + "label": "Overview", + "source": "ldap/intro.md", + "path": "/docs/ldap/overview" + }, + { + "label": "Quick Start", + "source": "ldap/quick-start.md", + "path": "/docs/ldap/quick-start" + } + ] + }, + { + "label": "Mail", + "items": [ + { + "label": "Overview", + "source": "mail/overview.md", + "path": "/docs/mail/overview" + }, + { + "label": "Clients", + "source": "mail/client.md", + "path": "/docs/mail/client" + }, + { + "label": "Rules", + "source": "mail/rules.md", + "path": "/docs/mail/rules" + } + ] + } + ] }, - "mokapi/yaml": { - "items": { - "parse": "javascript-api/mokapi-yaml/parse.md", - "stringify": "javascript-api/mokapi-yaml/stringify.md" - } + { + "label": "Configuration", + "type": "headline", + "items": [ + { + "label": "Overview", + "source": "configuration/introduction.md", + "path": "/docs/configuration/overview" + }, + { + "label": "Patching", + "source": "configuration/patching.md", + "path": "/docs/configuration/patching" + }, + { + "label": "Static", + "items": [ + { + "label": "CLI Usage", + "source": "configuration/static/cli.md", + "path": "/docs/configuration/static/cli-usage" + }, + { + "label": "CLI Flags", + "source": "configuration/static/mokapi.md", + "path": "/docs/configuration/static/cli-flags" + } + ] + }, + { + "label": "Dynamic", + "items": [ + { + "label": "Overview", + "source": "configuration/dynamic/overview.md", + "path": "/docs/configuration/dynamic/overview" + }, + { + "label": "File", + "source": "configuration/dynamic/file.md", + "path": "/docs/configuration/dynamic/file" + }, + { + "label": "HTTP", + "source": "configuration/dynamic/http.md", + "path": "/docs/configuration/dynamic/http" + }, + { + "label": "GIT", + "source": "configuration/dynamic/git.md", + "path": "/docs/configuration/dynamic/git" + }, + { + "label": "NPM", + "source": "configuration/dynamic/npm.md", + "path": "/docs/configuration/dynamic/npm" + }, + { + "label": "Reference", + "source": "configuration/reference.md", + "path": "/docs/configuration/reference" + } + ] + } + ] }, - "mokapi/encoding": { - "items": { - "base64 encode": "javascript-api/mokapi-encoding/base64-encode.md", - "base64 decode": "javascript-api/mokapi-encoding/base64-decode.md" - } + { + "label": "JavaScript API", + "type": "headline", + "items": [ + { + "label": "Overview", + "source": "javascript-api/overview.md", + "path": "/docs/javascript-api/overview" + }, + { + "label": "Modules", + "source": "javascript-api/modules.md", + "path": "/docs/javascript-api/modules" + }, + { + "label": "globals", + "items": [ + { + "label": "open", + "source": "javascript-api/open.md", + "path": "/docs/javascript-api/open" + } + ] + }, + { + "label": "mokapi", + "items": [ + { + "label": "cron", + "source": "javascript-api/mokapi/cron.md", + "path": "/docs/javascript-api/mokapi/cron" + }, + { + "label": "date", + "source": "javascript-api/mokapi/date.md", + "path": "/docs/javascript-api/mokapi/date" + }, + { + "label": "env", + "source": "javascript-api/mokapi/env.md", + "path": "/docs/javascript-api/mokapi/env" + }, + { + "label": "every", + "source": "javascript-api/mokapi/every.md", + "path": "/docs/javascript-api/mokapi/every" + }, + { + "label": "on", + "source": "javascript-api/mokapi/on.md", + "path": "/docs/javascript-api/mokapi/on" + }, + { + "label": "sleep", + "source": "javascript-api/mokapi/sleep.md", + "path": "/docs/javascript-api/mokapi/sleep" + }, + { + "label": "marshal", + "source": "javascript-api/mokapi/marshal.md", + "path": "/docs/javascript-api/mokapi/marshal" + }, + { + "label": "EventHandler", + "items": [ + { + "label": "Overview", + "source": "javascript-api/mokapi/eventhandler/eventhandler.md", + "path": "/docs/javascript-api/mokapi/eventhandler/eventhandler" + }, + { + "label": "EventArgs", + "source": "javascript-api/mokapi/eventhandler/eventargs.md", + "path": "/docs/javascript-api/mokapi/eventhandler/eventargs" + }, + { + "label": "HttpEventHandler", + "source": "javascript-api/mokapi/eventhandler/httpeventhandler.md", + "path": "/docs/javascript-api/mokapi/eventhandler/httpeventhandler" + }, + { + "label": "HttpRequest", + "source": "javascript-api/mokapi/eventhandler/httprequest.md", + "path": "/docs/javascript-api/mokapi/eventhandler/httprequest" + }, + { + "label": "HttpResponse", + "source": "javascript-api/mokapi/eventhandler/httpresponse.md", + "path": "/docs/javascript-api/mokapi/eventhandler/httpresponse" + }, + { + "label": "KafkaEventHandler", + "source": "javascript-api/mokapi/eventhandler/kafkaeventhandler.md", + "path": "/docs/javascript-api/mokapi/eventhandler/kafkaeventhandler" + }, + { + "label": "KafkaEventMessage", + "source": "javascript-api/mokapi/eventhandler/kafkaeventmessage.md", + "path": "/docs/javascript-api/mokapi/eventhandler/kafkaeventmessage" + }, + { + "label": "KafkaEventMessage", + "source": "javascript-api/mokapi/eventhandler/scheduledeventargs.md", + "path": "/docs/javascript-api/mokapi/eventhandler/scheduledeventargs" + } + ] + } + ] + }, + { + "label": "mokapi/faker", + "items": [ + { + "label": "fake", + "source": "javascript-api/mokapi-faker/fake.md", + "path": "/docs/javascript-api/mokapi-faker/fake" + } + ] + }, + { + "label": "mokapi/http", + "items": [ + { + "label": "get", + "source": "javascript-api/mokapi-http/get.md", + "path": "/docs/javascript-api/mokapi-http/get" + }, + { + "label": "post", + "source": "javascript-api/mokapi-http/post.md", + "path": "/docs/javascript-api/mokapi-http/post" + }, + { + "label": "put", + "source": "javascript-api/mokapi-http/put.md", + "path": "/docs/javascript-api/mokapi-http/put" + }, + { + "label": "head", + "source": "javascript-api/mokapi-http/head.md", + "path": "/docs/javascript-api/mokapi-http/head" + }, + { + "label": "delete", + "source": "javascript-api/mokapi-http/delete.md", + "path": "/docs/javascript-api/mokapi-http/delete" + }, + { + "label": "options", + "source": "javascript-api/mokapi-http/options.md", + "path": "/docs/javascript-api/mokapi-http/options" + }, + { + "label": "patch", + "source": "javascript-api/mokapi-http/patch.md", + "path": "/docs/javascript-api/mokapi-http/patch" + }, + { + "label": "fetch", + "source": "javascript-api/mokapi-http/fetch.md", + "path": "/docs/javascript-api/mokapi-http/fetch" + }, + { + "label": "HttpResponse", + "source": "javascript-api/mokapi-http/httpresponse.md", + "path": "/docs/javascript-api/mokapi-http/httpresponse" + }, + { + "label": "FetchOptions", + "source": "javascript-api/mokapi-http/fetchoptions.md", + "path": "/docs/javascript-api/mokapi-http/fetchoptions" + }, + { + "label": "Args", + "source": "javascript-api/mokapi-http/args.md", + "path": "/docs/javascript-api/mokapi-http/args" + } + ] + }, + { + "label": "mokapi/kafka", + "items": [ + { + "label": "produce", + "source": "javascript-api/mokapi-kafka/produce.md", + "path": "/docs/javascript-api/mokapi-kafka/produce" + }, + { + "label": "ProduceArgs", + "source": "javascript-api/mokapi-kafka/produceargs.md", + "path": "/docs/javascript-api/mokapi-kafka/produceargs" + }, + { + "label": "ProduceResult", + "source": "javascript-api/mokapi-kafka/produceresult.md", + "path": "/docs/javascript-api/mokapi-kafka/produceresult" + }, + { + "label": "Message", + "source": "javascript-api/mokapi-kafka/message.md", + "path": "/docs/javascript-api/mokapi-kafka/message" + }, + { + "label": "MessageResult", + "source": "javascript-api/mokapi-kafka/messageresult.md", + "path": "/docs/javascript-api/mokapi-kafka/messageresult" + }, + { + "label": "ProduceRetry", + "source": "javascript-api/mokapi-kafka/produceretry.md", + "path": "/docs/javascript-api/mokapi-kafka/produceretry" + } + ] + }, + { + "label": "mokapi/mustache", + "items": [ + { + "label": "render", + "source": "javascript-api/mokapi-mustache/render.md", + "path": "/docs/javascript-api/mokapi-mustache/render" + } + ] + }, + { + "label": "mokapi/yaml", + "items": [ + { + "label": "parse", + "source": "javascript-api/mokapi-yaml/parse.md", + "path": "/docs/javascript-api/mokapi-yaml/parse" + }, + { + "label": "stringify", + "source": "javascript-api/mokapi-yaml/stringify.md", + "path": "/docs/javascript-api/mokapi-yaml/stringify" + } + ] + }, + { + "label": "mokapi/encoding", + "items": [ + { + "label": "base64 encode", + "source": "javascript-api/mokapi-encoding/base64-encode.md", + "path": "/docs/javascript-api/mokapi-encoding/base64-encode" + }, + { + "label": "base64 decode", + "source": "javascript-api/mokapi-encoding/base64-decode.md", + "path": "/docs/javascript-api/mokapi-encoding/base64-decode" + } + ] + }, + { + "label": "mokapi/file", + "items": [ + { + "label": "read", + "source": "javascript-api/mokapi-file/read.md", + "path": "/docs/javascript-api/mokapi-file/read" + }, + { + "label": "writeString", + "source": "javascript-api/mokapi-file/write-string.md", + "path": "/docs/javascript-api/mokapi-file/write-string" + }, + { + "label": "appendString", + "source": "javascript-api/mokapi-file/append-string.md", + "path": "/docs/javascript-api/mokapi-file/append-string" + } + ] + } + ] } - } + ] }, "Resources": { + "path": "/resources", + "type": "root", "index": { "component": "examples", "hideNavigation": true, "hideInNavigation": true, - "canonical": "https://mokapi.io/docs/resources", + "canonical": "https://mokapi.io/resources", "title": "Explore Mokapi Resources: Tutorials, Examples, and Blog Articles", "description": "Explore Mokapi's resources including tutorials, examples, and blog articles. Learn to mock APIs, validate schemas, and streamline your development." }, - "items": { - "Tutorials": { + "items": [ + { + "label": "Tutorials", + "path": "/resources/tutorials", "index": { "component": "examples", "hideNavigation": true, "hideInNavigation": true, - "canonical": "https://mokapi.io/docs/resources/tutorials", + "canonical": "https://mokapi.io/resources/tutorials", "title": "Explore Mokapi Resources: Tutorials, Examples, and Blog Articles", "description": "Explore Mokapi's resources including tutorials, examples, and blog articles. Learn to mock APIs, validate schemas, and streamline your development." }, - "items": { - "Get started with REST API": "resources/tutorials/simple-http-api.md", - "Mock OpenAPI Authentication API Key & Bearer Token": "resources/tutorials/mock-openapi-auth-apikey-bearer.md", - "Mock OpenAPI Authentication OAuth2 and JWT": "resources/tutorials/mock-oauth2-jwt-api.md", - "Get started with Kafka": "resources/tutorials/simple-kafka-api.md", - "Running Mokapi in a CI/CD Pipeline": "resources/tutorials/running-mokapi-github-action.md", - "Mock LDAP Authentication in Node": "resources/tutorials/mock-ldap-authentication.md", - "Mock LDAP Group Permission in Node": "resources/tutorials/mock-ldap-group-permission.md", - "Mock SMTP Server send Mail using Node": "resources/tutorials/mock-smtp-server-send-mail-using-node.md", - "Using Mokapi in Local Tests": "resources/tutorials/mokapi-in-locale-tests.md", - "Fullstack Dev Made Easier": "resources/tutorials/fullstack-dev-made-easier.md" - } + "items": [ + { + "label": "Get started with REST API", + "source": "resources/tutorials/simple-http-api.md", + "path": "/resources/tutorials/get-started-with-rest-api", + "hideNavigation": true + }, + { + "label": "Mock OpenAPI Authentication API Key & Bearer Token", + "source": "resources/tutorials/mock-openapi-auth-apikey-bearer.md", + "path": "/resources/tutorials/mock-openapi-auth-apikey-bearer", + "hideNavigation": true + }, + { + "label": "Mock OpenAPI Authentication OAuth2 and JWT", + "source": "resources/tutorials/mock-oauth2-jwt-api.md", + "path": "/resources/tutorials/mock-oauth2-jwt-api", + "hideNavigation": true + }, + { + "label": "Get started with Kafka", + "source": "resources/tutorials/simple-kafka-api.md", + "path": "/resources/tutorials/get-started-with-kafka", + "hideNavigation": true + }, + { + "label": "Running Mokapi in a CI/CD Pipeline", + "source": "resources/tutorials/running-mokapi-github-action.md", + "path": "/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline", + "hideNavigation": true + }, + { + "label": "Mock LDAP Authentication in Node", + "source": "resources/tutorials/mock-ldap-authentication.md", + "path": "/resources/tutorials/mock-ldap-authentication-in-node", + "hideNavigation": true + }, + { + "label": "Mock LDAP Group Permission in Node", + "source": "resources/tutorials/mock-ldap-group-permission.md", + "path": "/resources/tutorials/mock-ldap-group-permission-in-node", + "hideNavigation": true + }, + { + "label": "Mock SMTP Server send Mail using Node", + "source": "resources/tutorials/mock-smtp-server-send-mail-using-node.md", + "path": "/resources/tutorials/mock-smtp-server-send-mail-using-node", + "hideNavigation": true + }, + { + "label": "Using Mokapi in Local Tests", + "source": "resources/tutorials/mokapi-in-locale-tests.md", + "path": "/resources/tutorials/mokapi-in-locale-tests", + "hideNavigation": true + }, + { + "label": "Fullstack Dev Made Easier", + "source": "resources/tutorials/fullstack-dev-made-easier.md", + "path": "/resources/tutorials/fullstack-dev-made-easier", + "hideNavigation": true + } + ] }, - "Examples": { + { + "label": "Examples", + "path": "/resources/examples", "index": { "component": "examples", "hideNavigation": true, "hideInNavigation": true, - "canonical": "https://mokapi.io/docs/resources/examples", + "canonical": "https://mokapi.io/resources/examples", "title": "Explore Mokapi Resources: Tutorials, Examples, and Blog Articles", "description": "Explore Mokapi's resources including tutorials, examples, and blog articles. Learn to mock APIs, validate schemas, and streamline your development." }, - "items": { - "Mokapi behind Reverse Proxy": "resources/examples/mokapi-behind-proxy.md", - "Mokapi with custom base image": "resources/examples/mokapi-with-custom-base-image.md" - } + "items": [ + { + "label": "Mokapi behind Reverse Proxy", + "source": "resources/examples/mokapi-behind-proxy.md", + "path": "/resources/examples/mokapi-behind-proxy", + "hideNavigation": true + }, + { + "label": "Mokapi with custom base image", + "source": "resources/examples/mokapi-with-custom-base-image.md", + "path": "/resources/examples/mokapi-with-custom-base-image", + "hideNavigation": true + } + ] }, - "Blogs": { + { + "label": "Blogs", + "path": "/resources/blogs", "index": { "component": "examples", "hideNavigation": true, "hideInNavigation": true, - "canonical": "https://mokapi.io/docs/resources/blogs", + "canonical": "https://mokapi.io/resources/blogs", "title": "Explore Mokapi Resources: Tutorials, Examples, and Blog Articles", "description": "Explore Mokapi's resources including tutorials, examples, and blog articles. Learn to mock APIs, validate schemas, and streamline your development." }, - "items": { - "Ensuring API Contract Compliance with Mokapi": "resources/blogs/ensuring-api-contract-compliance-with-mokapi.md", - "Mock APIs based on OpenAPI and AsyncAPI": "resources/blogs/mock-api-based-on-openapi-asyncapi.md", - "Automation Testing in Agile Development": "resources/blogs/automation-testing-agile-development.md", - "Contract Testing": "resources/blogs/contract-testing.md", - "End to End Testing with Mocked APIs": "resources/blogs/end-to-end-testing-mocked-apis.md", - "Bring Your Mock APIs to Life with Mokapi and JavaScript": "resources/blogs/dynamic-mocks-with-javascript.md", - "Debugging Mokapi Scripts": "resources/blogs/debugging-mokapi-scripts.md", - "Acceptance Testing with Mokapi: Focus on What Matters": "resources/blogs/acceptance-testing.md", - "Testing Email Workflows with Playwright and Mokapi": "resources/blogs/testing-email-workflows-playwright.md", - "Testing Kafka Workflows with Playwright and Mokapi": "resources/blogs/testing-kafka-workflows-playwright.md" - } + "items": [ + { + "label": "Ensuring API Contract Compliance with Mokapi", + "source": "resources/blogs/ensuring-api-contract-compliance-with-mokapi.md", + "path": "/resources/blogs/ensuring-api-contract-compliance-with-mokapi", + "hideNavigation": true + }, + { + "label": "Mock APIs based on OpenAPI and AsyncAPI", + "source": "resources/blogs/mock-api-based-on-openapi-asyncapi.md", + "path": "/resources/blogs/mock-api-based-on-openapi-asyncapi", + "hideNavigation": true + }, + { + "label": "Automation Testing in Agile Development", + "source": "resources/blogs/automation-testing-agile-development.md", + "path": "/resources/blogs/automation-testing-agile-development", + "hideNavigation": true + }, + { + "label": "Contract Testing", + "source": "resources/blogs/contract-testing.md", + "path": "/resources/blogs/contract-testing", + "hideNavigation": true + }, + { + "label": "End to End Testing with Mocked APIs", + "source": "resources/blogs/end-to-end-testing-mocked-apis.md", + "path": "/resources/blogs/end-to-end-testing-with-mocked-apis", + "hideNavigation": true + }, + { + "label": "Bring Your Mock APIs to Life with Mokapi and JavaScript", + "source": "resources/blogs/dynamic-mocks-with-javascript.md", + "path": "/resources/blogs/dynamic-mocks-with-javascript", + "hideNavigation": true + }, + { + "label": "Debugging Mokapi Scripts", + "source": "resources/blogs/debugging-mokapi-scripts.md", + "path": "/resources/blogs/debugging-mokapi-scripts", + "hideNavigation": true + }, + { + "label": "Acceptance Testing with Mokapi: Focus on What Matters", + "source": "resources/blogs/acceptance-testing.md", + "path": "/resources/blogs/acceptance-testing", + "hideNavigation": true + }, + { + "label": "Testing Email Workflows with Playwright and Mokapi", + "source": "resources/blogs/testing-email-workflows-playwright.md", + "path": "/resources/blogs/testing-email-workflows-with-playwright-and-mokapi", + "hideNavigation": true + }, + { + "label": "Testing Kafka Workflows with Playwright and Mokapi", + "source": "resources/blogs/testing-kafka-workflows-playwright.md", + "path": "/resources/blogs/testing-kafka-workflows-playwright", + "hideNavigation": true + }, + { + "label": "Record & Replay: API Interactions with Mokapi", + "source": "resources/blogs/record-and-replay-api-interactions.md", + "path": "/resources/blogs/record-and-replay-api-interactions", + "hideNavigation": true + } + ] } - } - }, - "References": { - "items": { - "Declarative Data": "references/declarative-data.md" - } + ] } } \ No newline at end of file diff --git a/docs/configuration/dynamic/file.md b/docs/configuration/dynamic/file.md index bb23f302b..bfbc0afd0 100644 --- a/docs/configuration/dynamic/file.md +++ b/docs/configuration/dynamic/file.md @@ -76,7 +76,10 @@ You can also define multiple file names or directory by separating them with sys ``` ### Include -One or more patterns that a file must match, except when empty. The filter is only applied to files. +A list of glob patterns that files must match to be processed. +- Applied only to files (not directories) +- If empty or omitted, all files are included +- If specified, a file must match at least one pattern to be included ```yaml tab=File (YAML) providers: @@ -90,6 +93,42 @@ providers: MOKAPI_PROVIDERS_FILE_INCLUDE="*.json *.yaml" ``` +### Exclude +A list of glob patterns that files must not match to be processed. +- Applied only to files (not directories) +- If empty or omitted, no files are excluded +- Exclusion is evaluated after include filtering +- If a file matches any exclude pattern, it is skipped + +```yaml tab=File (YAML) +providers: + file: + exclude: ["debug.json"] +``` +```bash tab=CLI +--providers-file-include debug.json +``` +```bash tab=Env +MOKAPI_PROVIDERS_FILE_INCLUDE="debug.json" +``` + +### Include + Exclude together + +When both are set: +1. Files are first filtered by include +2. Matching files are then filtered by exclude + +```yaml +providers: + file: + include: ["*.json"] + exclude: ["debug.json"] +``` + +- ✔ config.json → included +- ✖ debug.json → excluded +- ✖ config.yaml → not included + ### Ignoring Files and Directories You can create a `.mokapiignore` file in your directory to tell Mokapi which files and directories to ignore. The structure of this diff --git a/docs/configuration/introduction.md b/docs/configuration/introduction.md index 1f05b1c37..8175a3c88 100644 --- a/docs/configuration/introduction.md +++ b/docs/configuration/introduction.md @@ -2,7 +2,7 @@ title: Introduction to Mokapi Configuration | Static & Dynamic Setup Explained description: Discover how to configure Mokapi using static files or dynamic updates. Learn startup options, hot-reloading, and flexible setup for your mocked APIs. --- -# Introduction +# Configuration Mokapi has two types of configuration: - The startup configuration (referred as the static configuration) diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 912e2d220..e146c648e 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -5,7 +5,7 @@ description: A complete list of all Mokapi options and how to set the option in # Complete Options Reference Options define Mokapi's run behavior that can be passed in multiple places. -Mokapi chooses the value from the [highest order of precedence](/docs/configuration/introduction.md). +Mokapi chooses the value from the [highest order of precedence](/docs/configuration/overview.md). ## Log Mokapi log level (default is info) diff --git a/docs/configuration/static/mokapi.md b/docs/configuration/static/mokapi.md new file mode 100644 index 000000000..273485d97 --- /dev/null +++ b/docs/configuration/static/mokapi.md @@ -0,0 +1,3 @@ +# Release Notes + +> ⚠️ This file is automatically generated during the release pipeline. \ No newline at end of file diff --git a/docs/references/declarative-data.md b/docs/data-generator/declarative-data.md similarity index 100% rename from docs/references/declarative-data.md rename to docs/data-generator/declarative-data.md diff --git a/docs/guides/get-started/dashboard.md b/docs/get-started/dashboard.md similarity index 100% rename from docs/guides/get-started/dashboard.md rename to docs/get-started/dashboard.md diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md new file mode 100644 index 000000000..f402e131e --- /dev/null +++ b/docs/get-started/installation.md @@ -0,0 +1,89 @@ +--- +title: "Install Mokapi: Quick & Easy Setup Guide" +description: Learn how to install Mokapi effortlessly across Windows, macOS, and Linux. Follow the step-by-step guide for a smooth setup experience. +--- +# Install Mokapi + +Mokapi is an open-source tool designed to simplify API mocking and schema validation. +It enables developers to prototype, test, and demonstrate APIs with realistic data and +scenarios. This guide provides straightforward instructions to install Mokapi on various +platforms. + +## Installation Options + +Mokapi can be installed via direct download or through package managers on supported platforms. +Choose your preferred method below: + +::: tabs + +@tab "macOS" + +### Homebrew + +```bash +brew tap marle3003/tap +brew install mokapi +``` + +### Direct Download + +Download the latest macOS version from [GitHub](https://github.com/marle3003/mokapi/releases) + +@tab "Windows" + +### Chocolatey + +```Powershell +choco install mokapi +``` + +### Direct Download + +Download the latest Windows version from [GitHub](https://github.com/marle3003/mokapi/releases) + +@tab "Linux" + +### Direct Download + +Download file appropriate for your Linux distribution and ARCH from the [release page](https://github.com/marle3003/mokapi/releases), then install with + +```tab=deb +dpkg -i mokapi_{version}_linux_{arch}.deb +``` + +```tab=rpm +rpm -i mokapi_{version}_linux_{arch}.rpm +``` + +@tab "Docker" + +To get started with Mokapi using Docker, visit [DockerHub](https://hub.docker.com/r/mokapi/mokapi/tags) for a list of available images. +You can also use a custom base Docker image as demonstrated in [these examples](/resources/examples/mokapi-with-custom-base-image.md). + +``` +docker pull mokapi/mokapi +``` + +@tab "NPM" + +If you prefer to install Mokapi as a Node.js package, use the following command: + +```bash +npm install go-mokapi +``` + +::: + +### Mokapi Scripts Type Definitions + +Mokapi allows you to write **custom scripts** to handle API events or modify responses. +For full type safety and autocompletion in TypeScript, you can install the [`@types/mokapi`](https://www.npmjs.com/package/@types/mokapi`) package: + +```bash +npm install --save-dev @types/mokapi +``` + +## Next steps + +- [Create your first Mock](/docs/get-started/running.md) +- [Install @types/mokapi](https://www.npmjs.com/package/@types/mokapi) \ No newline at end of file diff --git a/docs/guides/get-started/running.md b/docs/get-started/running.md similarity index 91% rename from docs/guides/get-started/running.md rename to docs/get-started/running.md index 57c65cbe2..43e6559fb 100644 --- a/docs/guides/get-started/running.md +++ b/docs/get-started/running.md @@ -6,7 +6,7 @@ description: Learn how to run your first mocked REST API using Mokapi. Monitor H This quick-start guide will help you set up Mokapi and mock an HTTP REST API using the [Swagger Petstore](https://swagger.io/) specification. If you haven’t installed -Mokapi yet, check out the [Installation Guide](/docs/guides/get-started/installation.md). +Mokapi yet, check out the [Installation Guide](/docs/get-started/installation.md). ## Mocking Petstore API Run the following command to start Mokapi using the Petstore OpenAPI specification directly from the web: @@ -56,7 +56,7 @@ You can analyze your request and the corresponding response by clicking on the l ## Next Steps -- [Test-Data](/docs/guides/get-started/test-data.md) -- [Dashboard](/docs/guides/get-started/dashboard.md) -- [Mocking HTTP API](/docs/guides/http) +- [Test-Data](/docs/get-started/test-data.md) +- [Dashboard](/docs/get-started/dashboard.md) +- [Mocking HTTP API](/docs/http/overview) diff --git a/docs/guides/get-started/test-data.md b/docs/get-started/test-data.md similarity index 96% rename from docs/guides/get-started/test-data.md rename to docs/get-started/test-data.md index 1c813667f..cb2c8ce23 100644 --- a/docs/guides/get-started/test-data.md +++ b/docs/get-started/test-data.md @@ -153,5 +153,4 @@ guide you through the process in more detail. ## Next Steps - [Using Mokapi's Dashboard](dashboard.md) -- [Explore Mokapi Scripts](../../javascript-api/overview.md) -- [Learn More About Declarative Test Data](../../references/declarative-data.md) +- [Explore Mokapi Scripts](../javascript-api/overview.md) diff --git a/docs/get-started/welcome.md b/docs/get-started/welcome.md new file mode 100644 index 000000000..9ed831374 --- /dev/null +++ b/docs/get-started/welcome.md @@ -0,0 +1,71 @@ +--- +title: "Getting Started with Mokapi" +description: Learn how to set up Mokapi to mock APIs and validate requests using OpenAPI or AsyncAPI. No account needed—free, open-source, and easy to use. +cards: + items: + - title: Run your first mocked REST API + href: /docs/get-started/running + description: Learn how to quickly set up and run your first mock REST API and view the results in the Mokapi dashboard. + - title: Using Mokapi + href: /docs/configuration/overview + description: Get an overview of Mokapi’s core features, configuration options, and how to patch and use configuration providers effectively. + - title: Mock Event-Driven APIs with Apache Kafka + href: /docs/kafka/quick-start + description: Learn how to mock Kafka topics and simulate event-driven architectures for realistic API testing. + - title: Mokapi JavaScript API + href: /docs/javascript-api/overview + description: Discover how to control and customize your mocked APIs programmatically with Mokapi’s JavaScript API. + - title: Random Data Generator + href: /docs/get-started/test-data + description: Explore Mokapi’s random data generator, and learn how to customize the data for your API testing needs. + - title: Mokapi Dashboard + href: /docs/get-started/dashboard + description: Dive into Mokapi’s dashboard to analyze and monitor APIs, requests, and responses in real time for efficient debugging. +--- + +# Mocking APIs with Mokapi + +**Welcome to Mokapi!** + +Mokapi is your go-to platform for mocking APIs, making it easy to build, +test, and monitor API-driven applications without the hassle. + +> *Think of Mokapi as your ever-reliable API contract guardian—lightweight, transparent, and specification-driven.* + +## Build Better Software with Mokapi + +In today's world, modern applications rely on multiple external APIs. +When these APIs are slow, unreliable, or unavailable, they can impede +your development process. Mokapi eliminates these obstacles, enabling +you to: + +- **Develop Faster:** Eliminate waiting times by working independently of external systems. +- **Test with Confidence:** Simulate realistic API behaviors, including edge cases. +- **Automate Your Pipelines:** Enhance CI/CD reliability with consistent mock responses. +- **Ensure Compliance:** Seamlessly integrate tools like Dependabot or Renovate for future-proof dependencies. + +## Key Features + +-

Spec-Driven Mocking:
Quickly create OpenAPI or AsyncAPI mock servers for REST, SOAP, and event-driven architectures with minimal setup.

+-

No-Code Configuration:
Jump right in—no complex coding required!

+-

Live Monitoring:
Use the [Mokapi Dashboard](docs/get-started/dashboard) to track requests and responses in real-time, simplifying your debugging process.

+-

Dynamic Test Data:
Utilize the built-in random data generator to create realistic payloads tailored to your API needs.

+-

Local & Secure:
An offline-first tool, Mokapi keeps your data on your machine—no accounts, no cloud syncs, and no identity tracking.

+-

Open Source:
Free and transparent, explore Mokapi's source code on [GitHub](https://github.com/marle3003/mokapi).

+ +## Data Privacy and Security + +Mokapi prioritizes your privacy: + +- **Local Control:** Your data remains on your machine at all times. +- **No Account Needed:** Enjoy a hassle-free experience without the need for user accounts or authentication. +- **No Cloud Connectivity:** Mokapi operates entirely offline, ensuring your data—including API specifications, mock configurations, requests, and responses—stays private. + +## Explore how you can mock your APIs with Mokapi + +Whether you're mocking APIs for local testing or validating event-driven +systems, Mokapi streamlines the process. With its powerful tools and +integrations, you can accelerate your development cycle, minimize errors, +and enhance the quality of your APIs. + +{{ card-grid key="cards" }} diff --git a/docs/guides/get-started/installation.md b/docs/guides/get-started/installation.md deleted file mode 100644 index 8bd6b15d4..000000000 --- a/docs/guides/get-started/installation.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: "Install Mokapi: Quick & Easy Setup Guide" -description: Learn how to install Mokapi effortlessly across Windows, macOS, and Linux. Follow the step-by-step guide for a smooth setup experience. ---- -# Install Mokapi - -Mokapi is an open-source tool designed to simplify API mocking and schema validation, enabling developers to prototype, test, and demonstrate APIs with realistic data and scenarios. This guide provides step-by-step instructions to install Mokapi on various platforms, ensuring a smooth setup process. - -## Docker - -Visit [DockerHub](https://hub.docker.com/r/mokapi/mokapi/tags) for a list of all available images. -You can also use a custom base Docker image as shown in [these examples](/docs/resources/examples/mokapi-with-custom-base-image.md). - -``` -docker pull mokapi/mokapi -``` - -## Linux - -Download file appropriate for your Linux distribution and ARCH from the [release page](https://github.com/marle3003/mokapi/releases), then install with - -```tab=deb -dpkg -i mokapi_{version}_linux_{arch}.deb -``` - -```tab=rpm -rpm -i mokapi_{version}_linux_{arch}.rpm -``` - -## MacOS - -Using [Homebrew](https://brew.sh/): - -``` -brew tap marle3003/tap -brew install mokapi -``` - -## Windows - -Install Mokapi by [Chocolatey package manager](https://chocolatey.org/) with: - -```Powershell -choco install mokapi -``` - -## NodeJS npm package - -```bash -npm install go-mokapi -``` - -## Download binary - -You can download Mokapi's binary file from [GitHub Releases page](https://github.com/marle3003/mokapi/releases) - -## Next steps - -- [Create your first Mock](/docs/guides/get-started/running.md) -- [Install @types/mokapi](https://www.npmjs.com/package/@types/mokapi) \ No newline at end of file diff --git a/docs/guides/get-started/welcome.md b/docs/guides/get-started/welcome.md deleted file mode 100644 index db62c3d76..000000000 --- a/docs/guides/get-started/welcome.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: "Getting Started with Mokapi" -description: Learn how to set up Mokapi to mock APIs and validate requests using OpenAPI or AsyncAPI. No account needed—free, open-source, and easy to use. -cards: - items: - - title: Run your first mocked REST API - href: /docs/guides/get-started/running - description: Learn how to quickly set up and run your first mock REST API and view the results in the Mokapi dashboard. - - title: Using Mokapi - href: /docs/configuration/introduction - description: Get an overview of Mokapi’s core features, configuration options, and how to patch and use configuration providers effectively. - - title: Mock Event-Driven APIs with Apache Kafka - href: /docs/guides/kafka/quick-start - description: Learn how to mock Kafka topics and simulate event-driven architectures for realistic API testing. - - title: Mokapi JavaScript API - href: /docs/javascript-api/overview - description: Discover how to control and customize your mocked APIs programmatically with Mokapi’s JavaScript API. - - title: Random Data Generator - href: /docs/guides/get-started/test-data - description: Explore Mokapi’s random data generator, and learn how to customize the data for your API testing needs. - - title: Mokapi Dashboard - href: /docs/guides/get-started/dashboard - description: Dive into Mokapi’s dashboard to analyze and monitor APIs, requests, and responses in real time for efficient debugging. ---- - -# Mocking APIs with Mokapi - -Welcome to Mokapi! Mokapi is a powerful, flexible platform for building, testing, and monitoring API-driven applications. This guide will help you quickly understand how to get started with Mokapi and introduce you to its key features. - -## Build Better Software with Mokapi - -Modern applications rely on dozens of APIs—many of them outside your control. That creates friction: slow testing, fragile pipelines, and blocked teams. - -Mokapi was created to remove those barriers. -It empowers developers to mock, simulate, and explore APIs with ease, so you can: - -- **Develop without waiting** for external systems to be ready. -- **Test with confidence** against realistic API behavior. -- **Automate your workflows with CI/CD pipelines that stay reliable.** -- **Stay future-proof** by safely integrating with tools like Dependabot or Renovate. - -Mokapi is more than just a mocking tool—it’s a way to unlock faster feedback, smoother collaboration, and higher quality software. - -> Because building and testing great software shouldn’t depend on systems you can’t control. - -## Why Choose Mokapi? - -Mokapi offers several key benefits: - --

Easy API Mocking:
Quickly mock REST, SOAP, or event-driven APIs with minimal setup. Mokapi automatically generates mock servers based on your OpenAPI or AsyncAPI specifications, allowing you to simulate and test APIs in seconds.

--

No-Code Setup:
Mokapi allows you to mock and test APIs with no coding effort.

--

Real-Time Monitoring and Analytics:
With Mokapi’s interactive dashboard, you can monitor and analyze requests and responses in real-time. Get detailed insights into API behavior, errors, and status codes to help streamline debugging and testing.

--

Flexible Test Data Generation:
Mokapi’s random data generator allows you to simulate realistic test data. You can customize data patterns and even create dynamic data based on your APIs needs, making it ideal for testing edge cases and complex workflows.

--

Seamless CI/CD Integration:
Mokapi easily integrates into your continuous integration and deployment (CI/CD) pipeline. Run automated tests and validations for your APIs without any manual intervention, improving efficiency and reducing human error.

--

Free and Open-Source:
Mokapi is an open-source project, so you can start using it without any licensing fees. It’s free to use and offers transparency, flexibility, and the ability to contribute to the project’s development.

--

Rapid API Prototyping:
Use Mokapi for fast prototyping of new APIs and services. Mock APIs before they’re built or deployed to ensure that your development teams can begin their work immediately, even in the absence of a fully functional backend.

--

Collaborative API Development:
Mokapi enables teams to collaborate efficiently by providing an easily accessible testing environment. Developers, QA testers, and product teams can work together on mocking and validating APIs in real time.

- -## Explore how you can mock your APIs with Mokapi - -Whether you are mocking APIs for local testing or validating event-driven systems, Mokapi makes the process seamless and efficient. With Mokapi’s powerful tools and integrations, you can accelerate your development cycle, reduce errors, and improve the quality of your APIs. - -{{ card-grid key="cards" }} diff --git a/docs/guides/http/overview.md b/docs/guides/http/overview.md deleted file mode 100644 index 106e27ebc..000000000 --- a/docs/guides/http/overview.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: How to mock HTTP APIs with Mokapi -description: Mock any HTTP API with OpenAPI specification ---- -# Mocking HTTP APIs - -Mokapi makes it easy to mock HTTP APIs, enabling developers to test and debug their applications with minimal effort. Whether you need to validate request handling, simulate complex API responses, or troubleshoot edge cases, Mokapi provides a versatile and developer-friendly solution tailored for HTTP API testing. - -Designed to integrate seamlessly with your projects, Mokapi lets you create mock APIs using the OpenAPI Specification. It generates dynamic HTTP responses based on your API definitions, eliminating the need for a live server during development. This flexibility empowers developers to experiment, prototype, and troubleshoot more effectively. - -With Mokapi, you can go beyond basic mocks by writing custom scripts to control the behavior of your APIs. This allows you to simulate a wide range of scenarios, such as conditional responses or stateful interactions. Mokapi also supports fetching API definitions directly from URLs or files, making it simple to get started with existing OpenAPI documents. - -By leveraging Mokapi, you can streamline your development workflow, reduce dependencies on external systems, and deliver robust HTTP API integrations with confidence. - -Learn how to create your first HTTP API mock with Mokapi and begin ensuring the reliability and robustness of your application. - -## Before you start - -You can run Mokapi in multiple ways based on your needs. Learn how to configure and launch Mokapi on your local machine by following the instructions [here](/docs/guides/get-started/installation.md). - - -## Launch Mokapi with Swagger's PetStore API - - -To get started quickly, you can use Swagger's PetStore API specification hosted online: - -```bash tab=CLI -mokapi --providers-http-url https://petstore3.swagger.io/api/v3/openapi.json -``` - -That’s all you need! Open your browser and navigate to http://localhost/api/v3/pet/12 to see Mokapi's generated API response based on the PetStore specification. - -``` box=info -If you encounter issues fetching the specification file from swagger.io, you might need to configure a proxy server using this command: ---providers-http-proxy http://proxy.server.com:port -``` - -## Customizing HTTP Responses with Mokapi - -For more dynamic control, Mokapi allows you to define custom HTTP responses using Mokapi Scripts. This lets you simulate various scenarios, adjust responses based on request parameters, or handle specific conditions. - -### Example: Custom Response for particular Pet ID - -Create a petstore.ts script to define a custom response for /pet/12: - -```typescript tab=petstore.ts (TypeScript) -import { on } from 'mokapi' - -export default function() { - on('http', (request, response) => { - if (request.path.petId === 12) { - response.data = { - id: 12, - name: 'Garfield', - category: { - id: 3, - name: 'Cats' - }, - photoUrls: [] - } - } - }) -} -``` - -Start the mock server with the following command, referencing both the API specification and your custom script: - -```bash tab=CLI -mokapi --providers-http-url https://petstore3.swagger.io/api/v3/openapi.json --providers-file-filename /path/to/petstore.ts -``` - -Now, when you visit [http://localhost/api/v3/pet/12](http://localhost/api/v3/pet/12), Mokapi will return your custom-defined response for Garfield. Requests for other pet IDs will still generate random data based on the API specification. - -For further details on creating dynamic data, see [Test-Data](/docs/guides/get-started/test-data.md). - -## Swagger 2.0 support - -Mokapi supports the Swagger 2.0 specification, making it easy to work with older API definitions. When you provide a Swagger 2.0 file, Mokapi automatically converts it to OpenAPI 3.0, ensuring compatibility and consistent response generation. This seamless conversion allows you to benefit from OpenAPI 3.0 features while using your existing Swagger 2.0 specifications. Keep this in mind when referencing elements from a Swagger 2.0 file, as the structure and syntax might differ slightly in OpenAPI 3.0. - -### Example: Referencing a Schema from Swagger 2.0 - -Here is a simple Swagger 2.0 specification file: - -```yaml -swagger: '2.0' -info: - title: A Swagger 2.0 specification file - version: 1.0.0 -paths: - /pets: - get: - responses: - '200': - description: A list of pets. - schema: - $ref: '#/definitions/Pet' -definitions: - Pet: - type: object -``` - -In OpenAPI 3.0, you can reference the Pet schema from the Swagger 2.0 specification file as shown below: - -```yaml -openapi: '3.0' -info: - title: A OpenAPI 3.0 specification file - version: 1.0.0 -paths: - /pets: - get: - responses: - '200': - description: A list of pets. - content: - application/json: - schema: - $ref: 'path/to/swagger.yaml#/components/schemas/Pet' -``` - -Swagger 2.0 uses #/definitions for internal schemas, while OpenAPI 3.0 utilizes #/components/schemas. However, Mokapi automatically resolves these differences for you. - -With Mokapi's support for Swagger 2.0, you can modernize your API testing and mocking processes without abandoning your existing specifications. Whether you're transitioning to OpenAPI 3.0 or maintaining legacy systems, Mokapi makes the process seamless and efficient. \ No newline at end of file diff --git a/docs/guides/http/dashboard.md b/docs/http/dashboard.md similarity index 100% rename from docs/guides/http/dashboard.md rename to docs/http/dashboard.md diff --git a/docs/http/overview.md b/docs/http/overview.md new file mode 100644 index 000000000..8cf1c48ae --- /dev/null +++ b/docs/http/overview.md @@ -0,0 +1,372 @@ +--- +title: How to mock HTTP APIs with Mokapi +description: Mock any HTTP API with OpenAPI specification +--- + +# Mocking HTTP APIs + +## Quick Start: Mock an API in seconds + +Run this single command to start mocking Swagger's PetStore API: + +```bash +mokapi https://petstore3.swagger.io/api/v3/openapi.json +``` + +Open your browser and navigate to `http://localhost/api/v3/pet/12`. You'll see a generated response like: + +```json +{ + "id": 12, + "name": "Bruiser", + "category": { + "id": 1, + "name": "Dogs" + }, + "photoUrls": ["https://example.com/photo1.jpg"], + "status": "available" +} +``` + +That's it! Mokapi automatically generates realistic data based on your OpenAPI specification. + +## What You'll Learn + +By the end of this guide, you'll know how to: +- Launch a mock HTTP API using an OpenAPI specification +- Customize responses with Mokapi Scripts +- Work with both OpenAPI 3.0 and Swagger 2.0 specifications +- Simulate different API scenarios for testing + +## Prerequisites + +Before you start, make sure you have: + +- Mokapi installed on your system ([installation guide](/docs/get-started/installation.md)) +- An OpenAPI 3.0 or Swagger 2.0 specification file (or URL) +- For custom scripts, basic TypeScript or JavaScript knowledge + +## Basic Usage + +### Using a Remote Specification + +Point Mokapi to any publicly accessible OpenAPI specification: + +```bash +mokapi https://petstore3.swagger.io/api/v3/openapi.json +``` +```text box=tip +Mokapi supports both simplified syntax (`mokapi <url>`) and +verbose flags (`mokapi --providers-http-url <url>`). This guide uses the +simplified syntax for clarity. +``` + +**What happens:** +1. Mokapi downloads the specification +2. Starts the HTTP server on hosts and ports defined in `servers` specification (default port: 80) +3. Creates HTTP endpoints for all defined paths +4. Generates responses matching your schema definitions +5. Starts a dashboard server on `http://localhost` (default port: 8080) + +The dashboard shows all available endpoints, recent requests, and response statistics. +The API server and dashboard run independently and can use different ports. + +**Try it:** +```bash +curl http://localhost/api/v3/pet/12 +``` + +### Using a Local Specification File + +```bash +mokapi /path/to/your/openapi.yaml +``` + +This works with both `.json` and `.yaml` files. + +### Behind a Proxy? + +If you need to fetch specifications through a proxy server: + +```bash +mokapi --providers-http-proxy http://proxy.server.com:8080 -- https://petstore3.swagger.io/api/v3/openapi.json +``` + +## Customizing Responses with Mokapi Scripts + +To control API behavior, you can create custom scripts that define specific responses, simulate errors, or add conditional logic. + +### Example: Custom Response for a Specific Pet + +Let's return a specific response when requesting pet ID 12. + +**Step 1:** Create a script file `petstore.ts` + +```typescript tab=petstore.ts +import { on } from 'mokapi' + +export default function() { + on('http', (request, response) => { + // Check if the request is for pet ID 12 + if (request.key === '/pet/{petId}' && request.path.petId === 12) { + response.data = { + id: 12, + name: 'Garfield', + category: { + id: 3, + name: 'Cats' + }, + photoUrls: [], + status: 'available' + } + } + // Other pet IDs will receive auto-generated data + }) +} +``` + +**Step 2:** Start Mokapi with both the spec and your script + +```bash +mokapi https://petstore3.swagger.io/api/v3/openapi.json /path/to/petstore.ts +``` + +**Step 3:** Test the result + +```bash +# Request pet 12 - returns your custom "Garfield" response +curl http://localhost/api/v3/pet/12 + +# Request pet 99 - returns auto-generated random data +curl http://localhost/api/v3/pet/99 +``` + +**Before (without script):** +```json +{"id": 12, "name": "RandomName", "category": {"id": 1, "name": "Dogs"}, ...} +``` + +**After (with script):** +```json +{"id": 12, "name": "Garfield", "category": {"id": 3, "name": "Cats"}, ...} +``` + + +### Testing Error Handling + +**Scenario:** Your application needs to handle `404 Not Found` responses gracefully. + +With Mokapi Scripts, you can customize responses for specific test scenarios. + +```javascript tab=script.js +import { on } from 'mokapi' + +export default function() { + on('http', (request, response) => { + if (request.path.petId === 999) { + response.statusCode = 404 + } + }) +} +``` + +To run the script, pass it to Mokapi when starting the server: + +```bash +mokapi https://petstore3.swagger.io/api/v3/openapi.json ./script.js +``` + +You can test the behavior of your mocked API with the request: + +```bash +curl http://localhost/api/v3/pet/999 +``` + +### Simulating Network Delays + +**Scenario:** Test how your app behaves with slow API responses. + +Use Mokapi Scripts to add latency: + +```javascript tab=script.js +import { on, sleep } from 'mokapi' + +export default function() { + on('http', (request, response) => { + if (request.path.petId === 999) { + // delay the response by 5 seconds + sleep('5s'); + } + }) +} +``` + +### Testing Different Data States + +**Scenario:** Verify your UI displays different pet statuses correctly. + +Mokapi generates varied data on each request - refresh to see different values for enums and optional fields. +Set specific responses for petId 1 and 2 and for all others Mokapi will respond with random data + +```javascript tab=script.js +import { on, sleep } from 'mokapi' + +export default function() { + on('http', (request, response) => { + switch (request.path.petId) { + case 1: // Note: path parameter petId is defined as integer in spec + response.data = { + id: 1, + name: 'Max', + photoUrls: [] + } + return + case 2: + response.data = { + id: 2, + name: 'Bella', + photoUrls: [] + } + return + } + }) +} +``` + +### Example: Stateful Interactions + +Simulate creating and retrieving a pet: + +```typescript +import { on } from 'mokapi' + +let createdPets = new Map() + +export default function() { + on('http', (request, response) => { + // Handle POST /pet (create) + if (request.key === '/pet/{petId}' && request.method === 'POST') { + const newPet = request.body + createdPets.set(newPet.id, newPet) + response.statusCode = 201 + response.data = newPet + } + + // Handle GET /pet/{petId} (retrieve) + if (request.key === '/pet/{petId}' && request.method === 'GET' && createdPets.has(request.path.petId)) { + response.data = createdPets.get(request.path.petId) + } + }) +} +``` + +## Understanding Request Matching + +When writing Mokapi Scripts, you'll often need to identify which API endpoint was called. + +### Using `request.key` + +The `request.key` property contains the path pattern from your OpenAPI specification: +```typescript +// OpenAPI spec defines: /pet/{petId} +// User requests: http://localhost/api/v3/pet/12 + +on('http', (request, response) => { + console.log(request.key) // "/pet/{petId}" + console.log(request.operationId) // "getPetById" defined in OpenAPI spec + console.log(request.url.path) // "/api/v3/pet/12" +}) +``` + +**Best Practice:** Always check `request.key` to match the correct endpoint: +```typescript +// ✅ Reliable - matches the OpenAPI path pattern +if (request.key === '/pet/{petId}') { + // Your logic here +} + +// ❌ Fragile - breaks if base path changes +if (request.url.path.startsWith('/api/v3/pet/')) { + // Your logic here +} +``` + +## Working with Swagger 2.0 + +Mokapi fully supports Swagger 2.0 specifications. When you provide a Swagger 2.0 file, +Mokapi automatically converts it to OpenAPI 3.0 internally. + +### Schema Reference Translation + +**Swagger 2.0:** +```yaml +definitions: + Pet: + type: object +``` + +**OpenAPI 3.0 (how Mokapi sees it):** +```yaml +components: + schemas: + Pet: + type: object +``` + +Mokapi handles this translation automatically - you don't need to modify your specs. + +### What This Means for You + +✅ Your Swagger 2.0 specs work immediately - no conversion needed +✅ Reference resolution handled automatically - Mokapi translates between formats +✅ Schema paths differ - Mokapi transforms the path + +## Best Practice: Adjust Only What Matters + +Mokapi automatically generates a valid HTTP response based on the OpenAPI +specification, including the status code, headers, and response body. + +Event handlers are intended to modify **only the parts of the response that are +relevant to a specific scenario**. + +Instead of constructing the entire response manually, developers can: +- Override selected fields in `response.data` +- Adjust headers as needed +- Explicitly set a different status code (if defined in the OpenAPI specification) + +All other parts of the response remain automatically generated and valid. + +This approach: +- Reduces boilerplate code +- Prevents accidental invalid responses +- Keeps event handlers focused and maintainable +- Improves stability when APIs evolve: as response schemas change, event handlers + that modify only specific fields continue to work without requiring updates + for unrelated fields. + +### Example: Modifying a generated response + +```javascript +import { on } from 'mokapi' + +export default function() { + on('http', (request, response) => { + if (request.key === '/pet/{petId}') { + + // Modify only the relevant part of the generated response + if (request.path.petId === 10) { + response.data.name = 'Garfield' + return + } + // Safely switch to a different OpenAPI response + if (request.path.petId === 11) { + response.rebuild(404); + response.data.message = 'Pet not found' + } + } + }) +} +``` + +When switching the response status code, use [response.rebuild()](/docs/javascript-api/mokapi/eventhandler/httpresponse.md) to regenerate +a valid response based on the OpenAPI specification before modifying specific fields. \ No newline at end of file diff --git a/docs/guides/http/quick-start.md b/docs/http/quick-start.md similarity index 100% rename from docs/guides/http/quick-start.md rename to docs/http/quick-start.md diff --git a/docs/guides/http/security.md b/docs/http/security.md similarity index 100% rename from docs/guides/http/security.md rename to docs/http/security.md diff --git a/docs/guides/http/tls.md b/docs/http/tls.md similarity index 100% rename from docs/guides/http/tls.md rename to docs/http/tls.md diff --git a/docs/javascript-api/mokapi-file/append-string.md b/docs/javascript-api/mokapi-file/append-string.md new file mode 100644 index 000000000..536332f14 --- /dev/null +++ b/docs/javascript-api/mokapi-file/append-string.md @@ -0,0 +1,28 @@ +--- +title: appendString( path, s ) +description: Appends a string to a file at the given path. +--- +# appendString( path, s ) + +Appends the string `s` to a file at the given path. + +| Parameter | Type | Description | +|-----------|--------|------------------------------| +| path | string | Path to the file to write | +| s | string | The string content to append | + +If the path is relative, Mokapi resolves it relative to the **entry script file**. + +If the file does not exist, it will be created. If it exists, the string will be appended. + +## Example Appending File + +```javascript +import { appendString, writeString, read } from 'mokapi/file' + +export default function() { + writeString('data.json', 'Hello World') + appendString('data.json', '!') + console.log(read('data.json')) +} +``` \ No newline at end of file diff --git a/docs/javascript-api/mokapi-file/read.md b/docs/javascript-api/mokapi-file/read.md new file mode 100644 index 000000000..aebb4076e --- /dev/null +++ b/docs/javascript-api/mokapi-file/read.md @@ -0,0 +1,30 @@ +--- +title: read( path ) +description: Reads the contents of a file and returns it as a string. +--- +# read( path ) + +Reads the file at the given path until EOF and returns its contents. + +| Parameter | Type | Description | +|-----------|--------|---------------------------| +| path | string | Path to the file to read | + +If the path is relative, Mokapi resolves it relative to the **entry script file**. + +## Returns + +| Type | Description | +|--------|-------------------------| +| string | The content of the file | + +## Example Reading File + +```javascript +import { read } from 'mokapi/file' + +export default function() { + const data = read('data.json') + console.log(data) +} +``` \ No newline at end of file diff --git a/docs/javascript-api/mokapi-file/write-string.md b/docs/javascript-api/mokapi-file/write-string.md new file mode 100644 index 000000000..a94118724 --- /dev/null +++ b/docs/javascript-api/mokapi-file/write-string.md @@ -0,0 +1,27 @@ +--- +title: writeString( path, s ) +description: Writes a string to a file at the given path. +--- +# writeString( path, s ) + +Writes the string `s` to a file at the given path. + +| Parameter | Type | Description | +|-----------|--------|-------------------------------| +| path | string | Path to the file to write | +| s | string | The string content to write | + +If the path is relative, Mokapi resolves it relative to the **entry script file**. + +If the file does not exist, it will be created. If it exists, it will be overwritten. + +## Example Writing File + +```javascript +import { writeString, read } from 'mokapi/file' + +export default function() { + writeString('data.json', 'Hello World') + console.log(read('data.json')) +} +``` \ No newline at end of file diff --git a/docs/javascript-api/mokapi/eventhandler/eventargs.md b/docs/javascript-api/mokapi/eventhandler/eventargs.md index d7f34d4b8..447b6c321 100644 --- a/docs/javascript-api/mokapi/eventhandler/eventargs.md +++ b/docs/javascript-api/mokapi/eventhandler/eventargs.md @@ -1,24 +1,58 @@ --- title: EventArgs -description: EventArgs is an object used by on function. +description: EventArgs is an object used to configure event handlers registered with the on function. --- # EventArgs -EventArgs is an object used by [on](/docs/javascript-api/mokapi/on.md) function. +`EventArgs` is an optional configuration object passed to the +[`on`](/docs/javascript-api/mokapi/on.md) function when registering an event handler. +It allows controlling how and when an event handler is executed. -| Name | Type | Description | -|-------------------------|---------|--------------------------------------------------------------| -| tags | object | Adds or overrides existing tags used in dashboard | +| Name | Type | Description | +|----------|---------|--------------------------------------------------------------------------------------------------------| +| tags | object | Adds or overrides existing tags that are used in dashboard | +| priority | integer | Defines the execution priority of the event handler. Handlers with a higher value are executed first. | -## Examples +If no priority is specified, the default priority is `0`. -Add additional tag +## Example: Adding custom tags + +The following example registers an event handler and adds a custom tag. ```javascript import { every } from 'mokapi' export default function() { on('1m', function(request, response) { + // handler logic }, { tags: { foo: 'bar' } }) } -``` \ No newline at end of file +``` + +## Example: Controlling execution order with priority + +When multiple handlers are registered for the same event, the priority +property controls the order in which they are executed. + +```javascript +import { on } from 'mokapi' + +export default function() { + on('http', (req, res) => { + res.data.stage = 'early' + }, { priority: 10 }) + + on('http', (req, res) => { + res.data.stage = 'default' + }) + + on('http', (req, res) => { + res.data.stage = 'late' + }, { priority: -10 }) +} +``` + +In this example: +- The handler with priority 10 is executed first +- The handler without a priority is executed next (priority 0) +- The handler with priority -10 is executed last \ No newline at end of file diff --git a/docs/javascript-api/mokapi/eventhandler/eventhandler.md b/docs/javascript-api/mokapi/eventhandler/eventhandler.md index 5a25fc2f3..da4901df7 100644 --- a/docs/javascript-api/mokapi/eventhandler/eventhandler.md +++ b/docs/javascript-api/mokapi/eventhandler/eventhandler.md @@ -4,13 +4,21 @@ description: EventHandler is a function that is executed when an event is trigge --- # EventHandler -EventHandler is a function that is executed when an event is triggered. EventHandler has event-specific parameters like HttpRequest that contains data about an HTTP request. +An `EventHandler` is a function that is executed whenever a registered event is triggered. +The parameters passed to the handler depend on the event type (for example, an HTTP event +provides an `HttpRequest` and `HttpResponse` object). -## Returns +Multiple handlers can be registered for the same event. -| Type | Description | -|---------|-------------------------------------------------------| -| boolean | Whether Mokapi should log execution of event handler. | +## Usage + +Event handlers are registered using the `on` function. +The first argument specifies the event type, the second argument is the handler function. + +## Example: Handling an HTTP event + +The following example registers an HTTP event handler that responds only to a specific +operation (operationId === 'time'). ```javascript import { on } from 'mokapi' diff --git a/docs/javascript-api/mokapi/eventhandler/httpeventhandler.md b/docs/javascript-api/mokapi/eventhandler/httpeventhandler.md index e574ccad0..77100fbeb 100644 --- a/docs/javascript-api/mokapi/eventhandler/httpeventhandler.md +++ b/docs/javascript-api/mokapi/eventhandler/httpeventhandler.md @@ -1,23 +1,25 @@ --- title: HttpEventHandler -description: HttpEventHandler is a function that is executed when an event is triggered. +description: HttpEventHandler is a function that is executed when an HTTP event is triggered. --- # HttpEventHandler -HttpEventHandler is a function that is executed when an HTTP event is triggered. +An `HttpEventHandler` is a function that is executed whenever an HTTP event is triggered. +It allows inspecting the incoming request and modifying the outgoing response. + +Multiple HTTP event handlers can be registered. +If more than one handler is registered, they are executed in order based on their `priority`. | Parameter | Type | Description | |-----------|--------|---------------------------------------------------------------------------------------------------------------------| | request | object | [HttpRequest](/docs/javascript-api/mokapi/eventhandler/httprequest.md) object contains data of a HTTP request | -| response | object | [HttpResponse](/docs/javascript-api/mokapi/eventhandler/httpresponse.md) object contains data for the HTTP response | +| response | object | [HttpResponse](/docs/javascript-api/mokapi/eventhandler/httpresponse.md) object used to construct the HTTP response | -## Returns -| Type | Description | -|---------|-------------------------------------------------------| -| boolean | Whether Mokapi should log execution of event handler. | +## Example: Handling a specific operation -## Example +The following example registers a handler for HTTP events and returns the current date +when the request’s `operationId` is `time`. ```javascript import { on, date } from 'mokapi' @@ -26,9 +28,41 @@ export default function() { on('http', function(request, response) { if (request.operationId === 'time') { response.body = date() - return true } - return false }) } +``` + +## Example: Controlling execution order with priority + +Multiple HTTP event handlers can be registered for the same event. +The order in which they are executed is controlled by the priority option. + +Handlers with a higher priority value are executed first. +If no priority is specified, the default priority is 0. + +```javascript +import { on } from 'mokapi' + +export default () => { + let counter = 0 + + // Executed last (priority: -1) + on('http', (req, res) => { + res.data.foo = 'handler1'; + res.data.handler1 = counter++ + }, { priority: -1 }) + + // Executed first (priority: 10) + on('http', (req, res) => { + res.data.foo = 'handler2'; + res.data.handler2 = counter++ + }, { priority: 10 }) + + // Executed second (priority: 0) + on('http', (req, res) => { + res.data.foo = 'handler3'; + res.data.handler3 = counter++ + }) +} ``` \ No newline at end of file diff --git a/docs/javascript-api/mokapi/eventhandler/httprequest.md b/docs/javascript-api/mokapi/eventhandler/httprequest.md index c754f43cd..c09ecfdc8 100644 --- a/docs/javascript-api/mokapi/eventhandler/httprequest.md +++ b/docs/javascript-api/mokapi/eventhandler/httprequest.md @@ -1,22 +1,46 @@ --- title: HttpRequest -description: HttpRequest is an object used by HttpEventHandler +description: HttpRequest is an object that provides access to request-specific data in an HTTP event handler. --- # HttpRequest -HttpRequest is an object used by [HttpEventHandler](/docs/javascript-api/mokapi/eventhandler/httpeventhandler.md) -that contains request-specific data such as HTTP headers. - -| Name | Type | Description | -|-------------|--------|--------------------------------------------------------------------------| -| method | string | Request method like `GET` | -| url | object | Url represents a parsed URL | -| key | string | Path value specified by the OpenAPI path | -| operationId | string | OperationId defined in OpenAPI | -| path | object | Object contains path parameters specified by OpenAPI path parameters | -| query | object | Object contains query parameters specified by OpenAPI query parameters | -| header | object | Object contains header parameters specified by OpenAPI header parameters | -| cookie | object | Object contains cookie parameters specified by OpenAPI cookie parameters | -| body | any | Body contains request body specified by OpenAPI request body | -| api | string | The name of the API, as defined in the OpenAPI info.title field | +`HttpRequest` is an object passed to an +[`HttpEventHandler`](/docs/javascript-api/mokapi/eventhandler/httpeventhandler.md). +It contains request-specific data extracted from the incoming HTTP request +and parsed according to the OpenAPI specification. +The available properties depend on the OpenAPI definition and the incoming request. + +| Name | Type | Description | +|-------------|--------|------------------------------------------------------------------| +| method | string | HTTP request method, such as `GET` or `POST` | +| url | object | Parsed URL of the request | +| key | string | Path value that matched the OpenAPI path template | +| operationId | string | `operationId` defined in the OpenAPI specification | +| path | object | Path parameters defined by the OpenAPI path parameters | +| query | object | Query parameters defined by the OpenAPI query parameters | +| header | object | Header parameters defined by the OpenAPI header parameters | +| cookie | object | Cookie parameters defined by the OpenAPI cookie parameters | +| body | any | Request body parsed according to the OpenAPI request body schema | +| api | string | Name of the API, as defined in the OpenAPI `info.title` field | + +## Example + +The following example demonstrates how to access request data inside an HTTP event handler. + +```javascript +import { on } from 'mokapi' + +export default function() { + on('http', (request, response) => { + if (request.method === 'GET' && request.operationId === 'getUser') { + const userId = request.path.id + const includeDetails = request.query.details + + response.body = { + id: userId, + details: includeDetails + } + } + }) +} \ No newline at end of file diff --git a/docs/javascript-api/mokapi/eventhandler/httpresponse.md b/docs/javascript-api/mokapi/eventhandler/httpresponse.md index 8d0f7d0ba..fed43e16b 100644 --- a/docs/javascript-api/mokapi/eventhandler/httpresponse.md +++ b/docs/javascript-api/mokapi/eventhandler/httpresponse.md @@ -1,17 +1,155 @@ --- title: HttpResponse -description: HttpResponse is an object used by HttpEventHandler +description: HttpResponse is an object that is used to construct the HTTP response in an HttpEventHandler. --- # HttpResponse -HttpResponse is an object used by [HttpEventHandler](/docs/javascript-api/mokapi/eventhandler/httpeventhandler.md) -that contains response-specific data such as HTTP headers. +`HttpResponse` is an object passed to an +[`HttpEventHandler`](/docs/javascript-api/mokapi/eventhandler/httpeventhandler.md). +It is used to define the outgoing HTTP response, including status code, headers, +and response body. -| Name | Type | Description | -|------------|--------|--------------------------------------------------------------------------| -| statusCode | number | Specifies the http status used to select the OpenAPI response definition | -| headers | object | Object contains header parameters specified by OpenAPI header parameters | -| body | string | Response body. It has a higher precedence than data | -| data | any | Data will be encoded with the OpenAPI response definition | +## Properties +| Name | Type | Description | +|------------|--------|------------------------------------------------------------------------------| +| statusCode | number | HTTP status code used to select the OpenAPI response definition | +| headers | object | Response headers defined by the OpenAPI response header parameters | +| body | string | Raw response body. Takes precedence over `data` | +| data | any | Response data that will be encoded according to the OpenAPI response schema | +## Methods + +| Name | Description | +|----------------------------------|------------------------------| +| rebuild(statusCode, contentType) | Rebuilds the HTTP response | + +## Example + +The following example demonstrates how to construct an HTTP response inside an +HTTP event handler. + +```javascript +import { on } from 'mokapi' + +export default function() { + on('http', (request, response) => { + response.statusCode = 200 + response.headers = { + 'Content-Type': 'application/json' + } + response.data = { + message: 'Hello World' + } + }) +} +``` + +## Default Response Generation + +Mokapi automatically generates a valid HTTP response based on the OpenAPI +specification. + +- The HTTP status code is automatically selected as the **first successful + response (`200–299`) defined in the OpenAPI specification**, in the order they appear +- The response data is generated with valid example data according to the schema + defined for the selected status code. +- Response headers defined in the OpenAPI specification are also generated with + valid values + +For example, if the OpenAPI specification defines a `201` response before a +`200` response, Mokapi selects `201` and generates the response body and headers +based on the schema defined for that status code. + +This behavior ensures that every response is valid according to the OpenAPI +definition, even if the event handler does not explicitly modify the response. + +## Body vs Data + +Use body to return a raw response body without OpenAPI encoding and validating. + +Use data to return structured data that should be validated and encoded +according to the OpenAPI response definition + +If both body and data are set, body takes precedence + +## Rebuilding a Response + +When changing the HTTP status code or content type, the existing response data +and headers may no longer match the OpenAPI specification. + +To address this, `HttpResponse` provides a helper function: + +```typescript tab=Definition +rebuild(statusCode?: number, contentType?: string): void +``` + +This function rebuilds the entire HTTP response using the OpenAPI response +definition for the given status code and content type. + +### What rebuild does + +Calling rebuild will: +- Select the matching response definition from the OpenAPI specification +- Set response.statusCode to the provided value +- Generate valid response data based on the response schema +- Generate valid response headers defined in the specification +- Replace previously generated response data and headers + +This ensures the response remains valid after changing the status code. + +### When to Use rebuild + +Use rebuild when: +- You change the response status code +- You want to switch to a different response definition +- You want Mokapi to regenerate valid example data and headers + +You do not need to call rebuild if you only modify fields within the +already generated response.data. + +### Example: Changing the Status Code Safely + +```javascript +import { on } from 'mokapi' + +export default function() { + on('http', (request, response) => { + if (request.path.petId === 10) { + // Switch to a different OpenAPI response + response.rebuild(404, 'application/json') + + // Modify only what matters + response.data.message = 'Pet not found' + } + }) +} +``` + +### Parameter Defaults + +- If `statusCode` is not provided, Mokapi selects the OpenAPI `default` response. +- If `contentType` is not provided, Mokapi selects the first content type + defined for the selected status code in the OpenAPI specification. + +### Error handling + +If `response.rebuild()` throws an error, and it is not caught, the current event +handler is skipped and no response modifications from that handler are applied. + +```javascript +import { on } from 'mokapi' +import { read, writeString } from 'mokapi' + +export default function() { + on('http', (request, response) => { + let data: { request: HttpRequest, response: HTTPResponse}[] = [] + try { + const s = read('./data.json'); + data = JSON.parse(s) + } catch {} + data.push({ request, response }) + writeString('./data.json', JSON.stringify(data)) + }, { prriority: -1 }) +} +``` \ No newline at end of file diff --git a/docs/javascript-api/mokapi/on.md b/docs/javascript-api/mokapi/on.md index 783eab176..465182cf2 100644 --- a/docs/javascript-api/mokapi/on.md +++ b/docs/javascript-api/mokapi/on.md @@ -6,11 +6,11 @@ description: Attaches an event handler for the given event. Attaches an event handler for the given event. -| Parameter | Type | Description | -|------------------|----------|----------------------------------------------------------------------------------------------------------------| -| event | string | Event type such as `http | -| handler | function | An [EventHandler](/docs/javascript-api/mokapi/eventhandler) to execute when the event is triggered | -| args (optional) | object | [EventArgs](/docs/javascript-api/mokapi/eventhandler/eventargs.md) object contains additional event arguments. | +| Parameter | Type | Description | +|------------------|----------|--------------------------------------------------------------------------------------------------------------------| +| event | string | Event type such as `http | +| handler | function | An [EventHandler](/docs/javascript-api/mokapi/eventhandler/eventhandler.md) to execute when the event is triggered | +| args (optional) | object | [EventArgs](/docs/javascript-api/mokapi/eventhandler/eventargs.md) object contains additional event arguments. | ## Example Echo Server diff --git a/docs/javascript-api/overview.md b/docs/javascript-api/overview.md index 4c2bd6e50..15178b680 100644 --- a/docs/javascript-api/overview.md +++ b/docs/javascript-api/overview.md @@ -81,6 +81,45 @@ Mokapi provides its own APIs for common tasks: import { env } from "mokapi" ``` +### Improve Startup Performance + +Mokapi scans all configured directories at startup to discover API specifications and JavaScript files. +Starting a JavaScript runtime is memory-intensive, especially in projects with many JavaScript files. + +To improve startup time and reduce memory usage, follow these best practices: + +#### Use a Single Entry File per Script + +Structure your JavaScript so that only entry files export a default function. + +``` +mocks/ +└─ api/ + └─ users/ + ├─ index.js # executable script (default export) + ├─ handlers.js # module + └─ utils.js # module +``` + +Only index.js should export a default function. All other files should be imported as modules. + +#### Configure an Include Filter for JavaScript Files + +If a directory contains many JavaScript files (for example helpers or shared modules), +configure an include filter so Mokapi only considers entry files: + +```yaml +providers: + file: + directory: + - path: ./mocks + include: + - "**/index.js" +``` + +This prevents Mokapi from inspecting every JavaScript file and significantly reduces +startup time and memory usage. + ## Modules JavaScript files without a **default export** are treated as **modules**. @@ -193,7 +232,15 @@ Functions for encoding and decoding data. | [base64.encode( input )](/docs/javascript-api/mokapi-encoding/base64-encode.md) | Encodes a string to Base64. | | [base64.decode( input )](/docs/javascript-api/mokapi-encoding/base64-decode.md) | Decodes a Base64 string. | +### mokapi/file + +Functions for working with files +| Functions | Description | +|------------------------------------------------------------------------------|------------------------------------------------| +| [read( path )](/docs/javascript-api/mokapi-file/read.md) | Reads the contents of a file. | +| [writeString( path, s )](/docs/javascript-api/mokapi-file/write-string.md) | Writes a string to a file at the given path. | +| [appendString( path, s )](/docs/javascript-api/mokapi-file/append-string.md) | Appends a string to a file at the given path. | diff --git a/docs/guides/kafka/config.md b/docs/kafka/config.md similarity index 100% rename from docs/guides/kafka/config.md rename to docs/kafka/config.md diff --git a/docs/guides/kafka/overview.md b/docs/kafka/overview.md similarity index 96% rename from docs/guides/kafka/overview.md rename to docs/kafka/overview.md index dbe572b76..31539773e 100644 --- a/docs/guides/kafka/overview.md +++ b/docs/kafka/overview.md @@ -46,7 +46,7 @@ channels: ``` ```box=tip -Ready to dive in? Head over to the Kafka [Quick Start Guide](/docs/guides/kafka/quick-start.md) and run your first +Ready to dive in? Head over to the Kafka [Quick Start Guide](/docs/kafka/quick-start.md) and run your first Kafka mock in seconds. ``` @@ -113,5 +113,5 @@ or use Mokapi’s [patching](/docs/configuration/patching.md) mechanism. ## Next Steps -- [Quick Start Guide](/docs/guides/kafka/quick-start.md): Learn how to run Mokapi and load your first AsyncAPI file. +- [Quick Start Guide](/docs/kafka/quick-start.md): Learn how to run Mokapi and load your first AsyncAPI file. - [Mokapi CLI:](/docs/configuration/static/cli-usage.md): Detailed command-line options and runtime configuration. \ No newline at end of file diff --git a/docs/guides/kafka/quick-start.md b/docs/kafka/quick-start.md similarity index 100% rename from docs/guides/kafka/quick-start.md rename to docs/kafka/quick-start.md diff --git a/docs/guides/ldap/intro.md b/docs/ldap/intro.md similarity index 93% rename from docs/guides/ldap/intro.md rename to docs/ldap/intro.md index ef5a54aa4..070725d50 100644 --- a/docs/guides/ldap/intro.md +++ b/docs/ldap/intro.md @@ -4,13 +4,13 @@ description: Learn how to mock and test LDAP authentication using Mokapi. Simula cards: items: - title: Run your first mocked LDAP - href: /docs/guides/ldap/quick-start + href: /docs/ldap/quick-start description: Learn how to quickly set up and run your first LDAP mock and use ldapsearch tool - title: Mock LDAP Authentication in Node.js - href: /docs/resources/tutorials/mock-ldap-authentication-in-node + href: /resources/tutorials/mock-ldap-authentication-in-node description: Learn how to mock LDAP authentication using Mokapi and a Node.js backend. Step-by-step guide with code examples for testing LDAP login without a real server! - title: Mocking LDAP using Group Permission - href: /docs/resources/tutorials/mock-ldap-group-permission-in-node + href: /resources/tutorials/mock-ldap-group-permission-in-node description: Learn how to mock LDAP authentication and group permission using a Node.js backend. --- diff --git a/docs/guides/ldap/quick-start.md b/docs/ldap/quick-start.md similarity index 99% rename from docs/guides/ldap/quick-start.md rename to docs/ldap/quick-start.md index b8d543bfd..471d47184 100644 --- a/docs/guides/ldap/quick-start.md +++ b/docs/ldap/quick-start.md @@ -9,7 +9,7 @@ Learn how to create your first LDAP mock with Mokapi and begin ensuring the reli ## Before you start There are various ways to run Mokapi depending on your needs. For detailed instructions on how to get Mokapi running on -your workstation, refer to the information provided [here](/docs/guides/get-started/running.md). +your workstation, refer to the information provided [here](/docs/get-started/installation.md). ## Basic structure of an LDAP server configuration diff --git a/docs/guides/mail/client.md b/docs/mail/client.md similarity index 100% rename from docs/guides/mail/client.md rename to docs/mail/client.md diff --git a/docs/guides/mail/overview.md b/docs/mail/overview.md similarity index 100% rename from docs/guides/mail/overview.md rename to docs/mail/overview.md diff --git a/docs/guides/mail/quick-start.md b/docs/mail/quick-start.md similarity index 90% rename from docs/guides/mail/quick-start.md rename to docs/mail/quick-start.md index b920a6ed4..a2307ae00 100644 --- a/docs/guides/mail/quick-start.md +++ b/docs/mail/quick-start.md @@ -64,8 +64,8 @@ You’ll see the received message under Mail → Messages, including its full co ## What's Next? -- [Test email workflows with Playwright and Mokapi](/docs/resources/blogs/testing-email-workflows-with-playwright-and-mokapi) -- [Add recipient rules](/docs/guides/mail/rules.md) to allow or deny specific domains +- [Test email workflows with Playwright and Mokapi](/resources/blogs/testing-email-workflows-with-playwright-and-mokapi) +- [Add recipient rules](/docs/mail/rules.md) to allow or deny specific domains - [Patch the config](/docs/configuration/patching.md) to test different scenarios > Mokapi gives you full control over your mail simulation environment — ideal for CI pipelines, diff --git a/docs/guides/mail/rules.md b/docs/mail/rules.md similarity index 100% rename from docs/guides/mail/rules.md rename to docs/mail/rules.md diff --git a/docs/resources/blogs/acceptance-testing.md b/docs/resources/blogs/acceptance-testing.md index b4a0869ed..c248b5d9c 100644 --- a/docs/resources/blogs/acceptance-testing.md +++ b/docs/resources/blogs/acceptance-testing.md @@ -127,7 +127,7 @@ Mokapi doesn’t just enable acceptance testing—it makes it **practical, maint Learn how to set up acceptance tests with Mokapi in your CI/CD pipeline: -👉 [Running Mokapi in a CI/CD Pipeline](/docs/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline) +👉 [Running Mokapi in a CI/CD Pipeline](/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline) --- diff --git a/docs/resources/blogs/debugging-mokapi-scripts.md b/docs/resources/blogs/debugging-mokapi-scripts.md index c466d3031..a66ae4185 100644 --- a/docs/resources/blogs/debugging-mokapi-scripts.md +++ b/docs/resources/blogs/debugging-mokapi-scripts.md @@ -1,88 +1,155 @@ --- title: Debugging Mokapi JavaScript description: Learn how to debug your JavaScript code inside Mokapi using console.log, console.error, and event handler tracing. +subtitle: Master console logging, error tracking, and event handler tracing to build bulletproof Mokapi scripts +tags: [Development] --- # Debugging Mokapi JavaScript -Mokapi makes it easy not only to mock APIs, LDAP servers, Kafka topics, and mail servers, but also to -customize behavior using JavaScript. To help you debug your JavaScript code inside Mokapi, it provides -simple but powerful tools: `console.log`, `console.error`, and advanced event handler tracing. +Mokapi makes it remarkably easy to mock HTTP APIs, LDAP servers, Kafka topics, and mail servers—all customizable through +JavaScript. But as your scripts grow more sophisticated, you need powerful debugging tools to understand what's +happening under the hood. -This article will walk you through how to effectively debug your JavaScript running in Mokapi. +This article will show you how to leverage Mokapi's built-in debugging capabilities: `console.log`, `console.error`, +and advanced event handler tracing. -## Console Output: `console.log` and `console.error` +> Whether you're troubleshooting a failing integration or optimizing complex request flows, these debugging +> techniques will give you complete visibility into your Mokapi scripts. -Mokapi provides the familiar `console.log` and `console.error` functions that you know from the browser or Node.js. -These functions write directly to the Mokapi console output. +## Console Output: Your First Line of Defense + +| Method | Purpose | When to Use | +|-----------------|---------------------|----------------------------------------------------------------| +| console.log() | General information | Tracing values, flow control, debug checkpoints | +| console.error() | Error reporting | Unexpected conditions, failures, validation errors | +| console.warn() | Warning messages | Deprecations, potential issues, non-critical problems | +| console.debug() | Verbose debugging | Detailed internal state, variable dumps, step-by-step tracing | + +### Basic Console Logging Use `console.log` to output general debug information: + ```javascript -console.log('Hello World'); +import { on } from 'mokapi' + +export default function() { + on('http', (request, response) => { + // Log simple messages + console.log('Request received') + + // Log with context + console.log('Handling request for:', request.url.path) + + // Log complex objects + console.log('Request headers:', request.header) + + // Log formatted data + console.log(`${request.method} ${request.url.path}`) + }) +} ``` -Use `console.error` to report errors or unexpected behavior: +### Error Logging Best Practices + +Use `console.error` to report errors and unexpected behavior: + ```javascript -console.error({ firstname:"John", lastname:"Doe" }); +import { on } from 'mokapi' +import { fetch } from 'mokapi/http' + +export default function() { + on('http', async (request, response) => { + try { + const res = await fetch('https://api.backend.com/data') + + if (res.statusCode !== 200) { + // Log unexpected status codes + console.error('Backend returned error:', res.statusCode) + console.error('Response body:', res.body) + } + + response.data = res.json() + + } catch (error) { + // Log exceptions with full context + console.error('Failed to fetch backend data:', error.toString()) + console.error('Request details:', { + method: request.method, + path: request.url.path, + timestamp: new Date().toISOString() + }) + + response.statusCode = 500 + response.data = { error: 'Internal server error' } + } + }) +} ``` -Everything you log will appear in the terminal or system where you run Mokapi, making -it easy to follow what your scripts are doing behind the scenes. +## Event Handler Tracing: Deep Request Visibility -``` box=tip -There is also a function console.warn and console.debug -``` +Console logging shows you what's happening inside your scripts, but sometimes you need to +understand the bigger picture: which handlers fired, in what order, and with what configuration. -## Event Handler Tracing +That's where Mokapi's event handler tracing comes in. -Sometimes you need even deeper insights into how Mokapi processes a request — especially when you -are using event handlers to customize behavior. +### How Tracing Works -If your event handler function returns `true`, Mokapi not only executes the event but also: +Mokapi can automatically track which event handlers process each request and display this +information in the dashboard. You control tracking behavior through the EventArgs configuration: -- Logs the event handler data such as file, event type, event parameters -- Displays it in the Mokapi Dashboard under the request details +- **Auto-Detect** + Default behavior: tracks handlers that modify the response +- **Always Track** + Set track: true to monitor all executions +- **Never Track** + Set track: false to hide noisy handlers -This makes it very easy to see which handlers were involved in a request and in what order. +### Enabling Handler Tracing -Here's an example: ```javascript -import { on } from 'mokapi'; - -export default () => { - on("http", (req, res) => { - console.log("Handling request for:", req.path); - - if (req.path.startsWith("/admin")) { - console.log("Admin path detected"); - return true; // Trace this handler - } - return false; - }); +import { on } from 'mokapi' + +export default function() { + // Always track required due no changes to response + on('http', (request, response) => { + console.log('Handling request for:', request.url.path) + }, { + track: true, // Always visible in dashboard + }) + + // Auto-track admin routes (only when they modify the response) + on('http', (request, response) => { + if (request.url.path.startsWith('/admin')) { + console.log('Admin path detected:', request.url.path) + + // Add admin-specific headers + response.headers = response.headers || {} + response.headers['X-Admin-Route'] = ['true'] + } + }) + + // Never track health checks + on('http', (request, response) => { + if (request.url.path === '/health') { + response.statusCode = 200 + response.data = { status: 'ok' } + } + }, { + track: false, // Never show in dashboard + }) } ``` -In this case, because the handler returns true, Mokapi will: - -- Log that the onRequest handler was triggered -- List the handler in the dashboard under the request details - -This visibility is incredibly helpful when debugging complex request flows, especially if you have -multiple handlers reacting to a single request. - -Mokapi dashboard displaying event handler logs for a specific HTTP request. - -## Best Practices for Debugging +### Dashboard Visualization -- Use console.log freely during development to trace values and decisions inside your scripts. -- Return true from important event handlers you want to trace in the dashboard. -- Use console.error for anything unexpected to separate normal logs from problems. -- Check the Dashboard for a clear overview of all event handlers triggered by a request. +When handlers are tracked, the Mokapi dashboard displays detailed information for each request: -## Conclusion +Mokapi dashboard displaying event handler execution details for a specific HTTP request -Debugging Mokapi JavaScript is straightforward with console.log, console.error, and event handler -tracing. Whether you're building complex mock logic or just getting started, these tools will help you -understand exactly what happens inside Mokapi when it processes your requests. +## Debug with Confidence -Give it a try — and happy debugging! \ No newline at end of file +With console logging, event handler tracing, and strategic use of the track parameter, you have complete visibility +into your Mokapi scripts. Whether you're building complex mock logic or troubleshooting production issues, these +debugging tools will help you understand exactly what's happening at every step. \ No newline at end of file diff --git a/docs/resources/blogs/dynamic-mocks-with-javascript.md b/docs/resources/blogs/dynamic-mocks-with-javascript.md index 1d4ecd9a3..03ca3e387 100644 --- a/docs/resources/blogs/dynamic-mocks-with-javascript.md +++ b/docs/resources/blogs/dynamic-mocks-with-javascript.md @@ -11,7 +11,7 @@ Mocking APIs is essential for fast development — but static mocks can quickly become a bottleneck. Wouldn’t it be better if your mocks could think — reacting to queries, headers, or even generating data on the fly? -That's exactly what [Mokapi Scripts](/docs/javascript-api) are designed for. +That's exactly what [Mokapi Scripts](/docs/javascript-api/overview.md) are designed for. With just a few lines of JavaScript, you can control how your mocks behave — making them dynamic, intelligent, and realistic. @@ -132,9 +132,9 @@ demos and get faster feedback. 2. Add a Script where you control the response with JavaScript 3. Run Mokapi — that’s it! -👉 Try the [OpenAPI Mocking Tutorial](/docs/resources/tutorials/get-started-with-rest-api) for a guided walkthrough. +👉 Try the [OpenAPI Mocking Tutorial](/resources/tutorials/get-started-with-rest-api) for a guided walkthrough. -👉 Check out the [Mokapi Installation Guide](/docs/guides/get-started/installation.md) to get set up in minutes. +👉 Check out the [Mokapi Installation Guide](/docs/get-started/installation.md) to get set up in minutes. ## Conclusion @@ -146,9 +146,9 @@ With Mokapi, your mocks behave exactly the way you need them to. ## Further Reading -- [Debugging Mokapi JavaScript](/docs/resources/blogs/debugging-mokapi-scripts)\ +- [Debugging Mokapi JavaScript](/resources/blogs/debugging-mokapi-scripts)\ Learn how to debug your JavaScript code inside Mokapi -- [End-to-End Testing with Mock APIs Using Mokapi](/docs/resources/blogs/end-to-end-testing-with-mocked-apis)\ +- [End-to-End Testing with Mock APIs Using Mokapi](/resources/blogs/end-to-end-testing-with-mocked-apis)\ Improve your end-to-end tests by mocking APIs with Mokapi. --- diff --git a/docs/resources/blogs/end-to-end-testing-mocked-apis.md b/docs/resources/blogs/end-to-end-testing-mocked-apis.md index c64100ce0..317a440bf 100644 --- a/docs/resources/blogs/end-to-end-testing-mocked-apis.md +++ b/docs/resources/blogs/end-to-end-testing-mocked-apis.md @@ -21,8 +21,8 @@ Mocking APIs allows you to simulate external systems under controlled conditions - 🛠️ Easier testing of error scenarios, timeouts, and edge cases - 🔗 No dependencies on the availability of third-party services -With [Mokapi](https://mokapi.io), you can easily define API mocks using [OpenAPI](/docs/guides/http) or -[AsyncAPI](/docs/guides/kafka) specifications, and serve them locally or in your CI environment. +With [Mokapi](https://mokapi.io), you can easily define API mocks using [OpenAPI](/docs/http/overview.md) or +[AsyncAPI](/docs/kafka/overview.md) specifications, and serve them locally or in your CI environment. Mokapi even supports dynamic behavior using simple [JavaScripts](/docs/javascript-api/overview.md), helping you create more realistic test scenarios. ## How Mokapi Fits into Your CI/CD Pipeline @@ -91,7 +91,7 @@ You can even modify mocks dynamically during a test run with simple [JavaScripts ## Local Development and Mokapi Mokapi isn't just for CI. You can also run it locally during development. -There are many ways to run Mokapi depending on your setup — learn more in the [Running Mokapi Guide](/docs/guides/get-started/running.md). +There are many ways to run Mokapi depending on your setup — learn more in the [Running Mokapi Guide](/docs/get-started/running.md). ## Conclusion @@ -99,10 +99,10 @@ Mocking APIs is a crucial part of building robust, scalable systems. With Mokapi development and CI pipelines, leading to faster feedback, fewer bugs, and better products. **Ready to ship faster, more reliable software?** -[Get started with Mokapi](/docs/guides/get-started) +[Get started with Mokapi](/docs/get-started/installation.md) For a detailed, step-by-step guide on how to use Mokapi in your GitHub Actions workflows, -see the [GitHub Actions and Mokapi](/docs/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline). +see the [GitHub Actions and Mokapi](/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline). --- diff --git a/docs/resources/blogs/ensuring-api-contract-compliance-with-mokapi.md b/docs/resources/blogs/ensuring-api-contract-compliance-with-mokapi.md index 27c6aad45..bf7e07aa3 100644 --- a/docs/resources/blogs/ensuring-api-contract-compliance-with-mokapi.md +++ b/docs/resources/blogs/ensuring-api-contract-compliance-with-mokapi.md @@ -4,6 +4,7 @@ description: Validate HTTP API requests and responses with Mokapi to catch break image: url: /mokapi-using-as-proxy.png alt: Flow diagram illustrating how Mokapi enforces OpenAPI contracts between clients, Playwright tests, and backend APIs. +tech: http --- # Ensuring Compliance with the HTTP API Contract Using Mokapi for Request Forwarding and Validation @@ -111,7 +112,7 @@ export default async function () { return `https://backend1.example.com${request.url.path}?${request.url.query}`; } case 'backend-2': { - return `https://backend1.example.com${request.url.path}?${request.url.query}`; + return `https://backend2.example.com${request.url.path}?${request.url.query}`; } default: return undefined; diff --git a/docs/resources/blogs/record-and-replay-api-interactions.md b/docs/resources/blogs/record-and-replay-api-interactions.md new file mode 100644 index 000000000..137efb217 --- /dev/null +++ b/docs/resources/blogs/record-and-replay-api-interactions.md @@ -0,0 +1,262 @@ +--- +title: "Record & Replay: API Interactions with Mokapi" +description: Capture real-world API traffic for testing, debugging, and offline development—all with a simple JavaScript script +subtitle: Capture real-world API traffic for testing, debugging, and offline development—all with a simple JavaScript script +tech: http +tags: ['HTTP'] +--- + +# Record & Replay: API Interactions with Mokapi + +In a [previous article](ensuring-api-contract-compliance-with-mokapi.md), we explored how Mokapi can act as an API +specification guard between services—validating requests and responses against your OpenAPI specs in real-time. +But Mokapi's capabilities go far beyond validation. In this article, we'll dive into another powerful use case: +recording and replaying API interactions. + +Imagine capturing real API traffic from your production or staging environment and replaying it later for testing, +demos, or offline development. With Mokapi Script, this becomes remarkably straightforward. + +## Why Record API Interactions? + +Before we jump into the code, let's understand the value of recording API traffic: + +- **Complex User Scenarios** + Capture multistep workflows with real data to reproduce edge cases and validate business logic against actual usage patterns. +- **Regression Testing** + Build a suite of real-world request/response pairs to ensure new changes don't break existing functionality. +- **Demos & Presentations** + Replay realistic API interactions without depending on live services—perfect for sales demos or conference presentations. + +## The Recording Workflow + +At a high level, the recording workflow looks like this: + +Mokapi Record & Replay Workflow + +## Building the Recording Script + +Let's build a script that records all HTTP traffic passing through Mokapi. The script will capture both requests +and responses, storing them in a JSON file for later replay. + +### The Complete Recording Script + +```javascript +import { on } from 'mokapi' +import { read, writeString } from 'mokapi' + +export default function() { + on('http', (request, response) => { + // Initialize an empty array to hold recorded interactions + let data = [] + + try { + // Attempt to read existing recordings from file + const s = read('./recordings.json') + data = JSON.parse(s) + } catch {} + + // Append the current request/response pair + data.push({ request, response }) + + // Write the updated recordings back to file + writeString('./recordings.json', JSON.stringify(data, null, 2)) + }, { + // Use priority -1 to ensure this runs AFTER response is populated + priority: -1 + }) +} +``` + +``` box=warning title="Understanding the Priority Setting" +The priority: -1 parameter is crucial here. It ensures this handler runs after the response has been +fully populated by other handlers (like your forwarding script or mock generators). +``` + +### How It Works + +Let's break down what's happening in this compact script: + +- **Event Listener:** The on('http', ...) function registers a handler that fires for every HTTP request passing through Mokapi. +- **Read Existing Data:** We attempt to read recordings.json to retrieve previously recorded interactions. If the file doesn't + exist (first run), the try-catch silently handles it. +- **Append New Data:** The current request/response pair is added to the array. +- **Persist to Disk:** The entire array is serialized to JSON and written back to the file. + +## Combining Recording with Forwarding + +The real power emerges when you combine recording with the API forwarding pattern from our previous article. +Here's how they work together: + +```javascript +import { on } from 'mokapi' +import { fetch } from 'mokapi/http' +import { read, writeString } from 'mokapi' + +export default async function() { + // FIRST: Forward requests to real backend (default priority: 0) + on('http', async (request, response) => { + const url = getForwardUrl(request) + + if (!url) { + response.statusCode = 500 + response.body = 'Unknown backend' + return + } + + try { + const res = await fetch(url, { + method: request.method, + body: request.body, + headers: request.header, + timeout: '30s' + }) + + response.statusCode = res.statusCode + response.headers = res.headers + + const contentType = res.headers['Content-Type']?.[0] || '' + if (contentType.includes('application/json')) { + response.data = res.json() + } else { + response.body = res.body + } + } catch (e) { + response.statusCode = 500 + response.body = e.toString() + } + }) + + // SECOND: Record the complete request/response (priority: -1) + on('http', (request, response) => { + let data = [] + try { + data = JSON.parse(read('./recordings.json')) + } catch {} + + data.push({ request, response }) + writeString('./recordings.json', JSON.stringify(data, null, 2)) + }, { priority: -1 }) + + // Helper function to determine backend URL + 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 + } + } +} +``` + +This setup gives you the best of both worlds: real backend responses validated against your OpenAPI specs, +plus a growing library of recorded interactions for later use. + +``` box=info +When Mokapi forwards and records traffic, it still validates requests and +responses against the OpenAPI specification whenever possible. This means you +only record interactions that conform to your API contract—making replays +reliable and spec-compliant. +``` + +## Building the Replay Script + +Now comes the fun part: replaying your recorded interactions! Here's a script that reads the recorded data +and matches incoming requests to previously captured responses: + +```javascript +import { on } from 'mokapi' +import { read } from 'mokapi' + +export default function() { + // Load all recorded interactions at startup + let recordings = [] + try { + const data = read('./recordings.json') + recordings = JSON.parse(data) + } catch (e) { + console.error('Failed to load recordings:', e) + return + } + + on('http', (request, response) => { + // Find matching recorded request + const match = recordings.find(r => + r.request.method === request.method && + r.request.url.path === request.url.path && + r.request.url.query === request.url.query + ) + + if (match) { + // Replay the recorded response + response.statusCode = match.response.statusCode + response.headers = match.response.headers + response.body = match.response.body + response.data = match.response.data + } else { + // No recording found + response.statusCode = 404 + response.body = 'No recording found for this request' + } + }) +} +``` + +### Enhancing the Replay Logic + +The basic matching logic above works for simple cases, but you might want to enhance it for production use: + +- **Ignore specific headers**: Filter out timestamp headers or request IDs that change between requests +- **Match on request body**: For POST/PUT requests, compare the request body to find the right response +- **Fuzzy matching:** Match path patterns instead of exact paths (e.g., /users/:id) +- **Sequential playback:** Replay requests in the order they were recorded for complex workflows + +## Practical Use Cases + +### 1. Building Regression Test Suites + +Record interactions from your staging environment, then replay them in CI/CD pipelines to ensure new +code doesn't break existing functionality: + +> Record once in staging → Replay thousands of times in CI/CD → Catch regressions early + +### 2. Offline Development Environments + +Developers can work without VPN access or network connectivity. Simply record a comprehensive set of +API interactions once, and your entire team can develop offline using the replay script. + +### 3. Demo Environments + +Create polished demos with predictable responses. No more crossed fingers hoping the backend behaves +during that critical sales pitch! + +### 4. Performance Testing + +Replay recorded traffic to load-test your services without hitting real backends. Modify the +replay script to simulate concurrent users. + +## Best Practices & Tips + +- **Sanitize sensitive data:** Before committing recordings to version control, strip out authentication tokens, personal information, and other sensitive data. +- **Organize by scenario:** Instead of one giant recordings.json, create separate recording files for different user journeys or test scenarios. +- **Version your recordings:** As your API evolves, maintain recordings for different API versions to support backward compatibility testing. +- **Combine with validation:** Use Mokapi's OpenAPI validation alongside recording to ensure you're capturing valid interactions. + +## Taking It Further + +The recording and replay pattern opens up even more possibilities: + +- **Record/Replay UI:** Build a simple web interface to browse, filter, and selectively replay specific recordings +- **Dynamic modification:** Modify responses on-the-fly during replay to test error handling or edge cases +- **Traffic analysis:** Analyze recorded traffic to identify patterns, optimize API design, or detect anomalies +- **Mock generation:** Use recorded interactions to automatically generate OpenAPI examples or mock data + +## Start Recording Today + +By combining OpenAPI validation, HTTP forwarding, and file-based recording, +Mokapi becomes more than a mock server—it becomes a control plane for API +interactions across the entire API lifecycle. + +> Record real user behavior once. Replay it endlessly. Evolve your APIs with confidence. \ No newline at end of file diff --git a/docs/resources/blogs/testing-email-workflows-playwright.md b/docs/resources/blogs/testing-email-workflows-playwright.md index 55b8bc2c2..3313e79b1 100644 --- a/docs/resources/blogs/testing-email-workflows-playwright.md +++ b/docs/resources/blogs/testing-email-workflows-playwright.md @@ -168,4 +168,4 @@ Don’t leave your email experience to chance. Mock it, test it, and ship it wit --- Try the example on GitHub: [mokapi-email-workflow](https://github.com/marle3003/mokapi-email-workflow)\ -Learn more about [Mokapi Mail](/docs/guides/mail) \ No newline at end of file +Learn more about [Mokapi Mail](/docs/mail/overview.md) \ No newline at end of file diff --git a/docs/resources/tutorials/mock-ldap-group-permission.md b/docs/resources/tutorials/mock-ldap-group-permission.md index 2ed55c532..0409aedb4 100644 --- a/docs/resources/tutorials/mock-ldap-group-permission.md +++ b/docs/resources/tutorials/mock-ldap-group-permission.md @@ -25,7 +25,7 @@ LDAP server and implement group-based authentication in a Node.js backend. Before starting, ensure you have the following: - Node.js installed -- Mokapi installed [Installation Guide](/docs/guides/get-started/installation.md) +- Mokapi installed [Installation Guide](/docs/get-started/installation.md) - Basic knowledge of LDAP authentication ## 1. Create an LDAP Mock Configuration diff --git a/docs/resources/tutorials/mokapi-in-locale-tests.md b/docs/resources/tutorials/mokapi-in-locale-tests.md index a70f0f656..abc58383c 100644 --- a/docs/resources/tutorials/mokapi-in-locale-tests.md +++ b/docs/resources/tutorials/mokapi-in-locale-tests.md @@ -26,7 +26,7 @@ brew install mokapi choco install mokapi ``` -- On Linux: Follow the installation instructions [here](/docs/guides/get-started/installation.md). +- On Linux: Follow the installation instructions [here](/docs/get-started/installation.md). Once Mokapi is installed, you can start it from the command line: diff --git a/docs/resources/tutorials/running-mokapi-github-action.md b/docs/resources/tutorials/running-mokapi-github-action.md index e64ba5a1a..7fb8264c8 100644 --- a/docs/resources/tutorials/running-mokapi-github-action.md +++ b/docs/resources/tutorials/running-mokapi-github-action.md @@ -7,7 +7,7 @@ icon: bi-gear-wide-connected Integrating Mokapi into a CI/CD pipeline ensures API contracts and Kafka topics interactions are validated before deployment. This helps catch issues early, making the development process more reliable and reducing the risk of breaking changes in production. -If you haven't installed Mokapi yet, follow the [Installation Guide](/docs/guides/get-started/installation.md) to get started. +If you haven't installed Mokapi yet, follow the [Installation Guide](/docs/get-started/installation.md) to get started. This guide explains how to: diff --git a/engine/common/host.go b/engine/common/host.go index e1df87c2e..b03e89a8e 100644 --- a/engine/common/host.go +++ b/engine/common/host.go @@ -25,6 +25,11 @@ type JobOptions struct { Tags map[string]string } +type EventArgs struct { + Tags map[string]string + Priority int +} + type Host interface { Logger SetEventLogger(func(level, message string)) @@ -35,7 +40,7 @@ type Host interface { OpenFile(file string, hint string) (*dynamic.Config, error) - On(event string, do EventHandler, tags map[string]string) + On(event string, do EventHandler, args EventArgs) KafkaClient() KafkaClient HttpClient(HttpClientOptions) HttpClient @@ -49,6 +54,8 @@ type Host interface { Unlock() Store() Store + + Cwd() string } type Logger interface { diff --git a/engine/common/http.go b/engine/common/http.go index 46db0412c..b9519e9d3 100644 --- a/engine/common/http.go +++ b/engine/common/http.go @@ -6,14 +6,16 @@ import ( "strings" ) -type EventResponse struct { +type HttpEventResponse struct { Headers map[string]any `json:"headers"` StatusCode int `json:"statusCode"` Body string `json:"body"` Data any `json:"data"` + + Rebuild func(statusCode int, contentType string) `json:"-"` } -type EventRequest struct { +type HttpEventRequest struct { Method string `json:"method"` Url Url `json:"url"` Body interface{} `json:"body"` @@ -36,7 +38,7 @@ type Url struct { Query string `json:"query"` } -func (r *EventRequest) String() string { +func (r *HttpEventRequest) String() string { s := r.Method + " " + r.Url.String() if r.Api != "" { s += fmt.Sprintf(" [API: %s]", r.Api) @@ -61,11 +63,11 @@ func (u Url) String() string { return sb.String() } -func (r *EventResponse) HasBody() bool { +func (r *HttpEventResponse) HasBody() bool { return len(r.Body) > 0 || r.Data != nil } -func HttpEventHandler(req *EventRequest, res *EventResponse, resources interface{}) (bool, error) { +func HttpEventHandler(req *HttpEventRequest, res *HttpEventResponse, resources interface{}) (bool, error) { resource := getResource(req.Url, resources) if resource == nil { return false, nil diff --git a/engine/common/http_test.go b/engine/common/http_test.go index cfebca231..89a1c5adf 100644 --- a/engine/common/http_test.go +++ b/engine/common/http_test.go @@ -7,7 +7,7 @@ import ( ) func TestEventRequest_String(t *testing.T) { - r := &EventRequest{ + r := &HttpEventRequest{ Method: "GET", Url: Url{ Scheme: "https", @@ -20,7 +20,7 @@ func TestEventRequest_String(t *testing.T) { } func TestEventResponse_HasBody(t *testing.T) { - r := &EventResponse{} + r := &HttpEventResponse{} require.False(t, r.HasBody()) r.Body = "foo" require.True(t, r.HasBody()) @@ -55,10 +55,10 @@ func TestHttpResource(t *testing.T) { tc := tc t.Run(tc.url.String(), func(t *testing.T) { t.Parallel() - req := &EventRequest{ + req := &HttpEventRequest{ Url: tc.url, } - res := &EventResponse{} + res := &HttpEventResponse{} b, err := HttpEventHandler(req, res, tc.resource) tc.test(t, b, res.Data, err) }) diff --git a/engine/engine.go b/engine/engine.go index 9c0512e2e..c3ca6ea86 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -103,16 +103,6 @@ func (e *Engine) AddScript(evt dynamic.ConfigEvent) error { return nil } -func (e *Engine) Run(event string, args ...interface{}) []*common.Action { - var result []*common.Action - for _, s := range e.scripts { - actions := s.RunEvent(event, args...) - result = append(result, actions...) - } - - return result -} - func (e *Engine) Emit(event string, args ...interface{}) []*common.Action { return e.Run(event, args...) } diff --git a/engine/enginetest/host.go b/engine/enginetest/host.go index 2a72cdf3f..06c584d77 100644 --- a/engine/enginetest/host.go +++ b/engine/enginetest/host.go @@ -8,6 +8,8 @@ import ( "mokapi/schema/json/generator" "net/http" "net/url" + "os" + "path/filepath" "sync" ) @@ -27,10 +29,11 @@ type Host struct { KafkaClientTest *KafkaClient EveryFunc func(every string, do func(), opt common.JobOptions) CronFunc func(every string, do func(), opt common.JobOptions) - OnFunc func(event string, do common.EventHandler, tags map[string]string) + OnFunc func(event string, do common.EventHandler, args common.EventArgs) FindFakerNodeFunc func(name string) *generator.Node m sync.Mutex StoreTest *engine.Store + CwdFunc func() string } type HttpClient struct { @@ -117,9 +120,9 @@ func (h *Host) Cron(expr string, do func(), opt common.JobOptions) (int, error) return 0, nil } -func (h *Host) On(event string, do common.EventHandler, tags map[string]string) { +func (h *Host) On(event string, do common.EventHandler, args common.EventArgs) { if h.OnFunc != nil { - h.OnFunc(event, do, tags) + h.OnFunc(event, do, args) } } @@ -183,6 +186,17 @@ func (h *Host) AddCleanupFunc(f func()) { h.CleanupFuncs = append(h.CleanupFuncs, f) } +func (h *Host) Cwd() string { + if h.CwdFunc != nil { + return h.CwdFunc() + } + ex, err := os.Executable() + if err != nil { + panic(err) + } + return filepath.Dir(ex) +} + func mustParse(s string) *url.URL { u, err := url.Parse(s) if err != nil { diff --git a/engine/event.go b/engine/event.go new file mode 100644 index 000000000..919221532 --- /dev/null +++ b/engine/event.go @@ -0,0 +1,54 @@ +package engine + +import ( + "cmp" + "mokapi/engine/common" + "slices" + "time" + + log "github.com/sirupsen/logrus" +) + +func (e *Engine) Run(event string, args ...interface{}) []*common.Action { + var ehs []*eventHandler + for _, h := range e.scripts { + ehs = append(ehs, h.events[event]...) + } + slices.SortStableFunc(ehs, func(a, b *eventHandler) int { return -1 * cmp.Compare(a.priority, b.priority) }) + + var result []*common.Action + + for _, eh := range ehs { + a := runEventHandler(eh, args...) + if a != nil { + result = append(result, a) + } + } + + return result +} + +func runEventHandler(eh *eventHandler, args ...interface{}) *common.Action { + action := &common.Action{ + Tags: eh.tags, + } + start := time.Now() + logs := len(action.Logs) + + ctx := &common.EventContext{ + EventLogger: action.AppendLog, + Args: args, + } + + if b, err := eh.handler(ctx); err != nil { + log.Errorf("unable to execute event handler: %v", err) + action.Error = &common.Error{Message: err.Error()} + } else if !b && logs == len(action.Logs) { + return nil + } + log.WithField("handler", action).Debug("processed event handler") + + action.Parameters = getDeepCopy(args) + action.Duration = time.Now().Sub(start).Milliseconds() + return action +} diff --git a/engine/host.go b/engine/host.go index 6b8d21d93..e1e38b208 100644 --- a/engine/host.go +++ b/engine/host.go @@ -18,8 +18,9 @@ import ( ) type eventHandler struct { - handler common.EventHandler - tags map[string]string + handler common.EventHandler + tags map[string]string + priority int } type scriptHost struct { @@ -66,40 +67,10 @@ func (sh *scriptHost) Run() (err error) { return } } - log.Infof("executing script %v", sh.file.Info.Url) + log.Infof("executing script %v", sh.name) return sh.script.Run() } -func (sh *scriptHost) RunEvent(event string, args ...interface{}) []*common.Action { - var result []*common.Action - for _, eh := range sh.events[event] { - action := &common.Action{ - Tags: eh.tags, - } - start := time.Now() - logs := len(action.Logs) - - ctx := &common.EventContext{ - EventLogger: action.AppendLog, - Args: args, - } - - if b, err := eh.handler(ctx); err != nil { - log.Errorf("unable to execute event handler: %v", err) - action.Error = &common.Error{Message: err.Error()} - } else if !b && logs == len(action.Logs) { - continue - } else { - log.WithField("handler", action).Debug("processed event handler") - } - - action.Parameters = getDeepCopy(args) - action.Duration = time.Now().Sub(start).Milliseconds() - result = append(result, action) - } - return result -} - func (sh *scriptHost) Every(every string, handler func(), opt common.JobOptions) (int, error) { id := len(sh.jobs) @@ -199,7 +170,7 @@ func (sh *scriptHost) Cancel(jobId int) error { } } -func (sh *scriptHost) On(event string, handler common.EventHandler, tags map[string]string) { +func (sh *scriptHost) On(event string, handler common.EventHandler, args common.EventArgs) { h := &eventHandler{ handler: handler, tags: map[string]string{ @@ -208,9 +179,10 @@ func (sh *scriptHost) On(event string, handler common.EventHandler, tags map[str "fileKey": sh.file.Info.Key(), "event": event, }, + priority: args.Priority, } - for k, v := range tags { + for k, v := range args.Tags { h.tags[k] = v } @@ -284,8 +256,8 @@ func (sh *scriptHost) OpenFile(path string, hint string) (*dynamic.Config, error if len(hint) > 0 { path = filepath.Join(hint, path) } else { - p := getScriptPath(sh.file.Info.Kernel().Url) - path = filepath.Join(filepath.Dir(p), path) + cwd := sh.Cwd() + path = filepath.Join(cwd, path) } } @@ -346,6 +318,14 @@ func (sh *scriptHost) Store() common.Store { return sh.engine.store } +func (sh *scriptHost) Cwd() string { + u := sh.file.Info.Kernel().Url + if len(u.Path) > 0 { + return filepath.Dir(u.Path) + } + return filepath.Dir(u.Opaque) +} + func getScriptPath(u *url.URL) string { if len(u.Path) > 0 { return u.Path diff --git a/engine/mokapi_on_test.go b/engine/mokapi_on_test.go index 5b353f576..39b6b724b 100644 --- a/engine/mokapi_on_test.go +++ b/engine/mokapi_on_test.go @@ -2,6 +2,7 @@ package engine_test import ( "encoding/json" + "fmt" "io" "mokapi/engine" "mokapi/engine/common" @@ -32,10 +33,10 @@ export default () => { } `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{ + res := &common.HttpEventResponse{ Headers: map[string]any{"Content-Type": "application/json"}, } - actions := evt.Emit("http", &common.EventRequest{}, res) + actions := evt.Emit("http", &common.HttpEventRequest{}, res) require.Equal(t, "text/plain", mokapi.Export(res.Headers["Content-Type"])) return actions }, @@ -43,7 +44,7 @@ export default () => { require.NoError(t, err) require.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) require.Len(t, res.Headers, 1) require.Equal(t, "text/plain", res.Headers["Content-Type"]) @@ -59,8 +60,8 @@ export default () => { } `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) require.Equal(t, &map[string]interface{}{"foo": "bar"}, res.Data) return actions }, @@ -68,7 +69,7 @@ export default () => { require.NoError(t, err) require.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) require.Equal(t, map[string]interface{}{"foo": "bar"}, res.Data) }, @@ -83,8 +84,8 @@ export default () => { } `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) require.Equal(t, 201, res.StatusCode) return actions }, @@ -92,7 +93,7 @@ export default () => { require.NoError(t, err) require.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) require.Equal(t, 201, res.StatusCode) }, @@ -107,14 +108,14 @@ export default () => { } `, run: func(evt common.EventEmitter) []*common.Action { - return evt.Emit("http", &common.EventRequest{}, &common.EventResponse{}) + return evt.Emit("http", &common.HttpEventRequest{}, &common.HttpEventResponse{}) }, test: func(t *testing.T, actions []*common.Action, hook *test.Hook, err error) { require.NoError(t, err) require.NotNil(t, actions[0].Error) require.Equal(t, "failed to set statusCode: expected Integer but got String at test.js:4:6(3)", actions[0].Error.Message) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) require.Equal(t, 0, res.StatusCode) require.Len(t, hook.Entries, 2) @@ -131,8 +132,8 @@ export default () => { } `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) require.Equal(t, "hello world", res.Body) return actions }, @@ -140,7 +141,7 @@ export default () => { require.NoError(t, err) require.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) require.Equal(t, "hello world", res.Body) }, @@ -155,14 +156,14 @@ export default () => { } `, run: func(evt common.EventEmitter) []*common.Action { - return evt.Emit("http", &common.EventRequest{}, &common.EventResponse{}) + return evt.Emit("http", &common.HttpEventRequest{}, &common.HttpEventResponse{}) }, test: func(t *testing.T, actions []*common.Action, hook *test.Hook, err error) { require.NoError(t, err) require.NotNil(t, actions[0].Error) require.Equal(t, "failed to set body: expected String but got Object at test.js:4:6(5)", actions[0].Error.Message) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) require.Equal(t, "", res.Body) require.Len(t, hook.Entries, 2) @@ -180,15 +181,15 @@ export default () => { } `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) require.Equal(t, &[]any{int64(1), int64(2), int64(3)}, res.Data) return actions }, test: func(t *testing.T, actions []*common.Action, hook *test.Hook, err error) { require.NoError(t, err) require.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) require.Equal(t, []any{float64(1), float64(2), float64(3)}, res.Data) }, @@ -204,8 +205,8 @@ export default () => { } `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) require.Nil(t, actions[0].Error) require.Equal(t, map[string]any{"foo": "yuh"}, mokapi.Export(res.Data)) return actions @@ -213,7 +214,7 @@ export default () => { test: func(t *testing.T, actions []*common.Action, hook *test.Hook, err error) { require.NoError(t, err) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) require.Equal(t, map[string]any{"foo": "yuh"}, mokapi.Export(res.Data)) }, @@ -228,8 +229,8 @@ export default () => { } `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{Data: map[string]any{"foo": "bar"}} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{Data: map[string]any{"foo": "bar"}} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) require.Nil(t, actions[0].Error) require.Equal(t, map[string]any{"foo": "yuh"}, mokapi.Export(res.Data)) return actions @@ -237,7 +238,7 @@ export default () => { test: func(t *testing.T, actions []*common.Action, hook *test.Hook, err error) { require.NoError(t, err) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) require.Equal(t, map[string]any{"foo": "yuh"}, mokapi.Export(res.Data)) }, @@ -266,3 +267,114 @@ export default () => { }) } } + +func TestEventHandler_Priority(t *testing.T) { + testcases := []struct { + name string + scripts []string + run func(evt common.EventEmitter) []*common.Action + test func(t *testing.T, actions []*common.Action) + }{ + { + name: "handlers in same script", + scripts: []string{`import { on } from 'mokapi' +export default () => { + let counter = 0 + on('http', (req, res) => { + res.data.foo = 'handler1'; + res.data.handler1 = counter++ + }, { priority: -1 }) + on('http', (req, res) => { + res.data.foo = 'handler2'; + res.data.handler2 = counter++ + }, { priority: 10 }) + on('http', (req, res) => { + res.data.foo = 'handler3'; + res.data.handler3 = counter++ + }) +} +`, + }, + run: func(evt common.EventEmitter) []*common.Action { + res := &common.HttpEventResponse{Data: map[string]any{"foo": "bar"}} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) + require.Nil(t, actions[0].Error) + require.Nil(t, actions[1].Error) + require.Nil(t, actions[2].Error) + require.Equal(t, map[string]any{"foo": "handler1", "handler1": int64(2), "handler2": int64(0), "handler3": int64(1)}, mokapi.Export(res.Data)) + return actions + }, + test: func(t *testing.T, actions []*common.Action) { + var res *common.HttpEventResponse + _ = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) + require.Equal(t, map[string]any{"foo": "handler2", "handler2": float64(0)}, mokapi.Export(res.Data)) + _ = json.Unmarshal([]byte(actions[1].Parameters[1].(string)), &res) + require.Equal(t, map[string]any{"foo": "handler3", "handler2": float64(0), "handler3": float64(1)}, mokapi.Export(res.Data)) + _ = json.Unmarshal([]byte(actions[2].Parameters[1].(string)), &res) + require.Equal(t, map[string]any{"foo": "handler1", "handler2": float64(0), "handler3": float64(1), "handler1": float64(2)}, mokapi.Export(res.Data)) + }, + }, + { + name: "handlers in different scripts", + scripts: []string{` +import { on } from 'mokapi' +export default () => { + on('http', (req, res) => { + res.data.foo = 'handler1'; + }, { priority: -1 }) +} +`, + ` +import { on } from 'mokapi' +export default () => { + on('http', (req, res) => { + res.data.foo = 'handler2'; + }, { priority: 10 }) +} +`, + ` +import { on } from 'mokapi' +export default () => { + on('http', (req, res) => { + res.data.foo = 'handler3'; + }) +} +`, + }, + run: func(evt common.EventEmitter) []*common.Action { + res := &common.HttpEventResponse{Data: map[string]any{"foo": "bar"}} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) + require.Nil(t, actions[0].Error) + require.Nil(t, actions[1].Error) + require.Nil(t, actions[2].Error) + require.Equal(t, map[string]any{"foo": "handler1"}, mokapi.Export(res.Data)) + return actions + }, + test: func(t *testing.T, actions []*common.Action) { + var res *common.HttpEventResponse + _ = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) + require.Equal(t, map[string]any{"foo": "handler2"}, mokapi.Export(res.Data)) + _ = json.Unmarshal([]byte(actions[1].Parameters[1].(string)), &res) + require.Equal(t, map[string]any{"foo": "handler3"}, mokapi.Export(res.Data)) + _ = json.Unmarshal([]byte(actions[2].Parameters[1].(string)), &res) + require.Equal(t, map[string]any{"foo": "handler1"}, mokapi.Export(res.Data)) + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + logrus.SetOutput(io.Discard) + + var opts []engine.Options + e := enginetest.NewEngine(opts...) + for i, s := range tc.scripts { + err := e.AddScript(newScript(fmt.Sprintf("test-%v.js", i), s)) + require.NoError(t, err) + } + + actions := tc.run(e) + tc.test(t, actions) + }) + } +} diff --git a/examples/mokapi/services_http.js b/examples/mokapi/services_http.js index 63b24e2e9..9c5bc401e 100644 --- a/examples/mokapi/services_http.js +++ b/examples/mokapi/services_http.js @@ -112,7 +112,8 @@ export const configs = { data: 'http://localhost:8090/api/services/http/Swagger%20Petstore', } ], - filename: 'petstore.json' + filename: 'petstore.json', + tags: ['Tag1', 'Tag2'], }, 'b6fea8ac-56c7-4e73-a9c0-4487640bdca8': { id: 'b6fea8ac-56c7-4e73-a9c0-4487640bdca8', diff --git a/go.mod b/go.mod index 1b03a8531..962b84143 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c - github.com/evanw/esbuild v0.27.2 + github.com/evanw/esbuild v0.27.3 github.com/fsnotify/fsnotify v1.9.0 github.com/go-co-op/gocron v1.37.0 github.com/go-git/go-git/v5 v5.16.5 @@ -20,8 +20,8 @@ require ( github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 github.com/yuin/gopher-lua v1.1.1 - golang.org/x/net v0.49.0 - golang.org/x/text v0.33.0 + golang.org/x/net v0.50.0 + golang.org/x/text v0.34.0 gopkg.in/go-asn1-ber/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d gopkg.in/yaml.v3 v3.0.1 layeh.com/gopher-luar v1.0.11 @@ -80,8 +80,8 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect go.etcd.io/bbolt v1.4.0 // indirect go.uber.org/atomic v1.9.0 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.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 f3d599b06..6a4a75f21 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/evanw/esbuild v0.27.2 h1:3xBEws9y/JosfewXMM2qIyHAi+xRo8hVx475hVkJfNg= -github.com/evanw/esbuild v0.27.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/evanw/esbuild v0.27.3 h1:dH/to9tBKybig6hl25hg4SKIWP7U8COdJKbGEwnUkmU= +github.com/evanw/esbuild v0.27.3/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -190,13 +190,13 @@ go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -207,14 +207,14 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= diff --git a/images/alpha.Dockerfile b/images/alpha.Dockerfile index 73ffde179..b2ce6bccd 100644 --- a/images/alpha.Dockerfile +++ b/images/alpha.Dockerfile @@ -17,6 +17,9 @@ ARG VERSION=dev ARG BUILD_TIME=dev +# Install git for GIT tests +RUN apk add --no-cache git + COPY . /go/src/github.com/mokapi WORKDIR /go/src/github.com/mokapi diff --git a/js/console/console.go b/js/console/console.go index 847c846c6..af5e346a6 100644 --- a/js/console/console.go +++ b/js/console/console.go @@ -3,11 +3,12 @@ package console import ( "encoding/json" "fmt" - "github.com/dop251/goja" - "github.com/sirupsen/logrus" "mokapi/engine/common" "regexp" "strings" + + "github.com/dop251/goja" + "github.com/sirupsen/logrus" ) type Module struct { @@ -23,11 +24,11 @@ func Enable(vm *goja.Runtime) { logger: host, } obj := vm.NewObject() - obj.Set("log", f.Log) - obj.Set("warn", f.Warn) - obj.Set("error", f.Error) - obj.Set("debug", f.Debug) - vm.Set("console", obj) + _ = obj.Set("log", f.Log) + _ = obj.Set("warn", f.Warn) + _ = obj.Set("error", f.Error) + _ = obj.Set("debug", f.Debug) + _ = vm.Set("console", obj) } func (c *Module) Log(args ...goja.Value) { @@ -47,37 +48,55 @@ func (c *Module) Debug(args ...goja.Value) { } func (c *Module) log(level logrus.Level, args ...goja.Value) { - format := "" - if len(args) > 0 { - if s, ok := args[0].Export().(string); ok && strings.Contains(s, "%") { - format = s + if len(args) == 0 { + return + } + + var out []string + + if format, ok := args[0].Export().(string); ok && strings.Contains(format, "%") { + specs := countFormatSpecifiers(format) + + var exported []any + for _, arg := range args[1:] { + exported = append(exported, export(arg)) + } + + if specs > 0 && len(exported) >= specs { + if s, ok := formatString(format, exported[:specs]...); ok { + out = append(out, s) + + // Append remaining args (browser behavior) + for _, arg := range exported[specs:] { + out = append(out, fmt.Sprintf("%v", arg)) + } + + goto LOG + } } } - var logArgs []any + // Fallback: no formatting or formatting failed for _, arg := range args { - logArgs = append(logArgs, c.format(arg)) + out = append(out, fmt.Sprintf("%v", export(arg))) } - if format != "" { - if s, ok := formatString(format, logArgs[1:]...); ok { - logArgs = []any{s} - } - } +LOG: + msg := strings.Join(out, " ") switch level { case logrus.WarnLevel: - c.logger.Warn(logArgs...) + c.logger.Warn(msg) case logrus.ErrorLevel: - c.logger.Error(logArgs...) + c.logger.Error(msg) case logrus.DebugLevel: - c.logger.Debug(logArgs...) + c.logger.Debug(msg) default: - c.logger.Info(logArgs...) + c.logger.Info(msg) } } -func (c *Module) format(v goja.Value) any { +func export(v goja.Value) any { m, ok := v.(json.Marshaler) if !ok { return v.Export() @@ -102,3 +121,23 @@ func formatString(format string, args ...any) (string, bool) { return out, true } + +func countFormatSpecifiers(format string) int { + count := 0 + escaped := false + + for i := 0; i < len(format); i++ { + if escaped { + escaped = false + continue + } + if format[i] == '%' { + if i+1 < len(format) && format[i+1] == '%' { + escaped = true + continue + } + count++ + } + } + return count +} diff --git a/js/console/console_test.go b/js/console/console_test.go index f42858f73..7c32af145 100644 --- a/js/console/console_test.go +++ b/js/console/console_test.go @@ -1,14 +1,15 @@ package console_test import ( - "github.com/dop251/goja" - r "github.com/stretchr/testify/require" "mokapi/config/dynamic" "mokapi/engine/enginetest" "mokapi/js" "mokapi/js/console" "mokapi/js/eventloop" "testing" + + "github.com/dop251/goja" + r "github.com/stretchr/testify/require" ) func TestConsole(t *testing.T) { @@ -17,6 +18,21 @@ func TestConsole(t *testing.T) { host *enginetest.Host test func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) }{ + { + name: "no parameters", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + var logs []any + host.InfoFunc = func(args ...interface{}) { + logs = args + } + + _, err := vm.RunString(` + console.log(); + `) + r.NoError(t, err) + r.Empty(t, logs) + }, + }, { name: "string", test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { @@ -44,8 +60,7 @@ func TestConsole(t *testing.T) { console.log('hello', 'world'); `) r.NoError(t, err) - r.Equal(t, "hello", logs[0]) - r.Equal(t, "world", logs[1]) + r.Equal(t, "hello world", logs[0]) }, }, { @@ -121,8 +136,7 @@ func TestConsole(t *testing.T) { console.log('hello %', 123); `) r.NoError(t, err) - r.Equal(t, `hello %`, logs[0]) - r.Equal(t, int64(123), logs[1]) + r.Equal(t, `hello % 123`, logs[0]) }, }, { @@ -170,6 +184,66 @@ func TestConsole(t *testing.T) { r.Equal(t, `hello %9.2f`, logs[0]) }, }, + { + name: "multiple parameters", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + var logs []any + host.InfoFunc = func(args ...interface{}) { + logs = args + } + + _, err := vm.RunString(` + console.log('log an object:', { foo: 'bar', message: 'hello world' }); + `) + r.NoError(t, err) + r.Equal(t, `log an object: {"foo":"bar","message":"hello world"}`, logs[0]) + }, + }, + { + name: "log error", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + var logs []any + host.ErrorFunc = func(args ...interface{}) { + logs = args + } + + _, err := vm.RunString(` + console.error('error'); + `) + r.NoError(t, err) + r.Equal(t, "error", logs[0]) + }, + }, + { + name: "log warn", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + var logs []any + host.WarnFunc = func(args ...interface{}) { + logs = args + } + + _, err := vm.RunString(` + console.warn('warn'); + `) + r.NoError(t, err) + r.Equal(t, "warn", logs[0]) + }, + }, + { + name: "log debug", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + var logs []any + host.DebugFunc = func(args ...interface{}) { + logs = args + } + + _, err := vm.RunString(` + console.debug('debug'); + `) + r.NoError(t, err) + r.Equal(t, "debug", logs[0]) + }, + }, } for _, tc := range testcases { diff --git a/js/file/file.go b/js/file/file.go index b52e06a64..6c4312d97 100644 --- a/js/file/file.go +++ b/js/file/file.go @@ -2,8 +2,14 @@ package file import ( "encoding/json" + "fmt" + "io" "mokapi/config/dynamic" + "mokapi/config/dynamic/provider/file" "mokapi/engine/common" + "net/url" + "os" + "path/filepath" "github.com/dop251/goja" ) @@ -23,17 +29,30 @@ func Enable(rt *goja.Runtime, host common.Host, parent *dynamic.Config) { _ = rt.Set("open", r.open) } -func (o *Module) open(file string, args map[string]interface{}) (any, error) { - f, err := o.host.OpenFile(file, "") +func Require(vm *goja.Runtime, module *goja.Object) { + o := vm.Get("mokapi/internal").(*goja.Object) + host := o.Get("host").Export().(common.Host) + m := &Module{ + rt: vm, + host: host, + } + obj := module.Get("exports").(*goja.Object) + _ = obj.Set("read", m.Read) + _ = obj.Set("writeString", m.WriteString) + _ = obj.Set("appendString", m.AppendString) +} + +func (m *Module) open(file string, args map[string]interface{}) (any, error) { + f, err := m.host.OpenFile(file, "") if err != nil { return "", err } - dynamic.AddRef(o.parent, f) + dynamic.AddRef(m.parent, f) switch args["as"] { case "binary": return f.Raw, nil case "resolved": - return o.resolve(f) + return m.resolve(f) case "string": fallthrough default: @@ -41,7 +60,7 @@ func (o *Module) open(file string, args map[string]interface{}) (any, error) { } } -func (o *Module) resolve(f *dynamic.Config) (any, error) { +func (m *Module) resolve(f *dynamic.Config) (any, error) { b, err := json.Marshal(f.Data) if err != nil { return nil, err @@ -50,3 +69,87 @@ func (o *Module) resolve(f *dynamic.Config) (any, error) { err = json.Unmarshal(b, &v) return v, err } + +func (m *Module) Read(path string) (string, error) { + p, err := m.resolvePath(path) + if err != nil { + return "", err + } + f, err := os.Open(p) + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + + b, err := io.ReadAll(f) + if err != nil { + return "", err + } + return string(b), nil +} + +func (m *Module) WriteString(path, s string) error { + p, err := m.resolvePath(path) + if err != nil { + panic(fmt.Sprintf("failed to write to file: %s", err)) + } + f, err := os.Create(p) + if err != nil { + return fmt.Errorf("failed to write to file: %s", err) + } + defer func() { + _ = f.Close() + }() + + _, err = f.WriteString(s) + if err != nil { + return fmt.Errorf("failed to write to file: %s", err) + } + return nil +} + +func (m *Module) AppendString(path, s string) error { + p, err := m.resolvePath(path) + if err != nil { + panic(fmt.Sprintf("failed to write to file: %s", err)) + } + f, err := os.OpenFile(p, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("failed to append to file: %s", err) + } + defer func() { + _ = f.Close() + }() + + _, err = f.WriteString(s) + if err != nil { + return fmt.Errorf("failed to append string: %v", err) + } + return nil +} + +func (m *Module) resolvePath(path string) (string, error) { + u, err := url.Parse(path) + if err != nil || len(u.Scheme) == 0 || len(u.Opaque) > 0 { + if !filepath.IsAbs(path) { + cwd := m.host.Cwd() + path = filepath.Join(cwd, path) + } + + u, err = file.ParseUrl(path) + if err != nil { + return "", err + } + } + + if u.Scheme != "file" { + return "", fmt.Errorf("file access only allowed from local scripts") + } + + p := u.Path + if len(u.Opaque) > 0 { + p = u.Opaque + } + return p, nil +} diff --git a/js/file/file_test.go b/js/file/file_test.go index 8f872b5fd..9be19d861 100644 --- a/js/file/file_test.go +++ b/js/file/file_test.go @@ -11,6 +11,8 @@ import ( "mokapi/providers/openapi/openapitest" "mokapi/providers/openapi/schema" "mokapi/providers/openapi/schema/schematest" + "os" + "path/filepath" "testing" "github.com/dop251/goja" @@ -77,6 +79,116 @@ func TestModule_Open(t *testing.T) { r.Equal(t, map[string]any{"description": "circular reference"}, v.Export()) }, }, + { + name: "open", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "foo.txt"), []byte("Hello World"), 0o644) + r.NoError(t, err) + + host.CwdFunc = func() string { + return dir + } + + v, err := vm.RunString(` + const m = require("mokapi/file") + m.read('foo.txt'); + `) + r.NoError(t, err) + r.Equal(t, "Hello World", v.Export(), dir) + }, + }, + { + name: "open file not exists", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + dir := t.TempDir() + host.CwdFunc = func() string { + return dir + } + + _, err := vm.RunString(` + const m = require("mokapi/file") + m.read('foo.txt'); + `) + r.ErrorContains(t, err, "foo.txt: no such file or directory") + }, + }, + { + name: "write file", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + dir := t.TempDir() + host.CwdFunc = func() string { + return dir + } + + _, err := vm.RunString(` + const m = require("mokapi/file") + m.writeString('foo.txt', 'Hello World'); + `) + r.NoError(t, err) + + b, err := os.ReadFile(filepath.Join(dir, "foo.txt")) + r.NoError(t, err) + r.Equal(t, "Hello World", string(b), dir) + }, + }, + { + name: "write file with error", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + dir := t.TempDir() + host.CwdFunc = func() string { + return dir + } + + _, err := vm.RunString(` + const m = require("mokapi/file") + m.writeString('.', 'Hello World'); + `) + r.ErrorContains(t, err, "failed to write to file: open") + }, + }, + { + name: "write file catch error", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + dir := t.TempDir() + host.CwdFunc = func() string { + return dir + } + f, err := os.OpenFile(filepath.Join(dir, "foo.txt"), os.O_RDWR|os.O_CREATE, os.ModeExclusive) + r.NoError(t, err) + defer f.Close() + + _, err = vm.RunString(` + const m = require("mokapi/file") + try { + m.writeString('foo.txt', 'Hello World'); + } catch {} + `) + r.NoError(t, err) + }, + }, + { + name: "append string to file", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "foo.txt"), []byte("Hello World"), 0o644) + r.NoError(t, err) + + host.CwdFunc = func() string { + return dir + } + + _, err = vm.RunString(` + const m = require("mokapi/file") + m.appendString('foo.txt', '!'); + `) + r.NoError(t, err) + + b, err := os.ReadFile(filepath.Join(dir, "foo.txt")) + r.NoError(t, err) + r.Equal(t, "Hello World!", string(b), dir) + }, + }, } t.Parallel() @@ -98,6 +210,7 @@ func TestModule_Open(t *testing.T) { js.EnableInternal(vm, host, loop, source) reg.Enable(vm) file.Enable(vm, host, source) + reg.RegisterNativeModule("mokapi/file", file.Require) tc.test(t, vm, host) }) diff --git a/js/mokapi/on.go b/js/mokapi/on.go index 5e234a806..2fb2407ce 100644 --- a/js/mokapi/on.go +++ b/js/mokapi/on.go @@ -17,6 +17,7 @@ type onArgs struct { tags map[string]string track bool isTrackSet bool + priority int } func (m *Module) On(event string, do goja.Value, vArgs goja.Value) { @@ -65,7 +66,7 @@ func (m *Module) On(event string, do goja.Value, vArgs goja.Value) { return haveChanges(origin, newHashes), nil } - m.host.On(event, f, eventArgs.tags) + m.host.On(event, f, common.EventArgs{Tags: eventArgs.tags, Priority: eventArgs.priority}) } func getOnArgs(vm *goja.Runtime, args goja.Value) (onArgs, error) { @@ -100,6 +101,15 @@ func getOnArgs(vm *goja.Runtime, args goja.Value) (onArgs, error) { } result.track = v.ToBoolean() result.isTrackSet = true + case "priority": + v := params.Get(k) + if goja.IsUndefined(v) || goja.IsNull(v) { + continue + } + if v.ExportType().Kind() != reflect.Int64 { + return onArgs{}, fmt.Errorf("unexpected type for priority: %v", util.JsType(v.Export())) + } + result.priority = int(v.ToInteger()) } } return result, nil @@ -112,7 +122,7 @@ func getHashes(args ...any) ([][]byte, error) { for _, arg := range args { b, err := json.Marshal(arg) if err != nil { - return nil, fmt.Errorf("unable to marshal arg") + return nil, fmt.Errorf("failed to marshal arg: %v", err) } result = append(result, b) } @@ -131,7 +141,7 @@ func haveChanges(origin [][]byte, new [][]byte) bool { func ArgToJs(arg any, vm *goja.Runtime) goja.Value { switch v := (arg).(type) { - case *common.EventResponse: + case *common.HttpEventResponse: return vm.NewDynamicObject(&Proxy{ target: reflect.ValueOf(v), vm: vm, @@ -141,6 +151,13 @@ func ArgToJs(arg any, vm *goja.Runtime) goja.Value { switch key { case "headers": p.KeyNormalizer = http.CanonicalHeaderKey + case "rebuild": + return rebuild(vm, v) + } + + switch val.(type) { + case string, int, bool: + return p.vm.ToValue(val) } return vm.NewDynamicObject(p) @@ -150,3 +167,28 @@ func ArgToJs(arg any, vm *goja.Runtime) goja.Value { return vm.ToValue(v) } } + +func rebuild(vm *goja.Runtime, res *common.HttpEventResponse) goja.Value { + if res.Rebuild == nil { + return vm.ToValue(func() {}) + } + return vm.ToValue(func(statusCode goja.Value, contentType goja.Value) { + s := int64(0) + c := "" + if statusCode != nil { + if statusCode.ExportType().Kind() != reflect.Int64 { + panic(fmt.Sprintf("response.rebuild failed: statusCode must be a number: got %v", util.JsType(statusCode.Export()))) + } else { + s = statusCode.ToInteger() + } + } + if contentType != nil { + if contentType.ExportType().Kind() != reflect.String { + panic(fmt.Sprintf("response.rebuild failed: contentType must be a string: got %v", util.JsType(contentType.Export()))) + } else { + c = contentType.String() + } + } + res.Rebuild(int(s), c) + }) +} diff --git a/js/mokapi/on_test.go b/js/mokapi/on_test.go index d9f0a9c5e..c61560a61 100644 --- a/js/mokapi/on_test.go +++ b/js/mokapi/on_test.go @@ -26,7 +26,7 @@ func TestModule_On(t *testing.T) { test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { var event string var handler common.EventHandler - host.OnFunc = func(evt string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { event = evt handler = do } @@ -49,7 +49,7 @@ func TestModule_On(t *testing.T) { name: "event handler with parameter", test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { var handler common.EventHandler - host.OnFunc = func(evt string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { handler = do } @@ -70,7 +70,7 @@ func TestModule_On(t *testing.T) { name: "event handler changes params", test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { var handler common.EventHandler - host.OnFunc = func(evt string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { handler = do } @@ -88,7 +88,7 @@ func TestModule_On(t *testing.T) { name: "event handler does not change params", test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { var handler common.EventHandler - host.OnFunc = func(evt string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { handler = do } @@ -106,7 +106,7 @@ func TestModule_On(t *testing.T) { name: "event handler does not change params but uses track argument", test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { var handler common.EventHandler - host.OnFunc = func(evt string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { handler = do } @@ -124,7 +124,7 @@ func TestModule_On(t *testing.T) { name: "event handler changes params but disables track", test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { var handler common.EventHandler - host.OnFunc = func(evt string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { handler = do } @@ -142,7 +142,7 @@ func TestModule_On(t *testing.T) { name: "event handler throws error", test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { var handler common.EventHandler - host.OnFunc = func(evt string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { handler = do } @@ -159,8 +159,8 @@ func TestModule_On(t *testing.T) { name: "event handler with tags", test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { var tags map[string]string - host.OnFunc = func(evt string, do common.EventHandler, t map[string]string) { - tags = t + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { + tags = args.Tags } _, err := vm.RunString(` @@ -191,11 +191,37 @@ func TestModule_On(t *testing.T) { r.EqualError(t, err, "unexpected type for args: String at mokapi/js/mokapi.(*Module).On-fm (native)") }, }, + { + name: "event handler with priority", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + priority := 0 + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { + priority = args.Priority + } + + _, err := vm.RunString(` + const m = require('mokapi') + m.on('http', () => true, { priority: 100 }) + `) + r.NoError(t, err) + r.Equal(t, 100, priority) + }, + }, + { + name: "event handler invalid type for priority", + test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { + _, err := vm.RunString(` + const m = require('mokapi') + m.on('http', () => true, { priority: 'foo' }) + `) + r.EqualError(t, err, "unexpected type for priority: String at mokapi/js/mokapi.(*Module).On-fm (native)") + }, + }, { name: "async event handler", test: func(t *testing.T, vm *goja.Runtime, host *enginetest.Host) { var handler common.EventHandler - host.OnFunc = func(evt string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(evt string, do common.EventHandler, args common.EventArgs) { handler = do } @@ -265,10 +291,10 @@ m.on('http', (req, res) => { }) `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{ + res := &common.HttpEventResponse{ Headers: map[string]any{"Content-Type": "application/json"}, } - actions := evt.Emit("http", &common.EventRequest{}, res) + actions := evt.Emit("http", &common.HttpEventRequest{}, res) ct := res.Headers["Content-Type"].(*string) r.Equal(t, "text/plain", *ct) return actions @@ -277,7 +303,7 @@ m.on('http', (req, res) => { r.NoError(t, err) r.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) r.Len(t, res.Headers, 1) r.Equal(t, "text/plain", res.Headers["Content-Type"]) @@ -292,8 +318,8 @@ m.on('http', (req, res) => { }) `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) r.Equal(t, map[string]interface{}{"foo": "bar"}, mokapi.Export(res.Data)) return actions }, @@ -301,7 +327,7 @@ m.on('http', (req, res) => { r.NoError(t, err) r.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) r.Equal(t, map[string]interface{}{"foo": "bar"}, res.Data) }, @@ -315,8 +341,8 @@ m.on('http', (req, res) => { }) `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) r.Equal(t, 201, res.StatusCode) return actions }, @@ -324,7 +350,7 @@ m.on('http', (req, res) => { r.NoError(t, err) r.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) r.Equal(t, 201, res.StatusCode) }, @@ -338,14 +364,14 @@ m.on('http', (req, res) => { }) `, run: func(evt common.EventEmitter) []*common.Action { - return evt.Emit("http", &common.EventRequest{}, &common.EventResponse{}) + return evt.Emit("http", &common.HttpEventRequest{}, &common.HttpEventResponse{}) }, test: func(t *testing.T, actions []*common.Action, err error) { r.NoError(t, err) r.NotNil(t, actions[0].Error) r.Equal(t, "failed to set statusCode: expected Integer but got String at :4:6(3)", actions[0].Error.Message) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) r.Equal(t, 0, res.StatusCode) }, @@ -359,8 +385,8 @@ m.on('http', (req, res) => { }) `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) r.Equal(t, "hello world", res.Body) return actions }, @@ -368,7 +394,7 @@ m.on('http', (req, res) => { r.NoError(t, err) r.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) r.Equal(t, "hello world", res.Body) }, @@ -382,14 +408,14 @@ m.on('http', (req, res) => { }) `, run: func(evt common.EventEmitter) []*common.Action { - return evt.Emit("http", &common.EventRequest{}, &common.EventResponse{}) + return evt.Emit("http", &common.HttpEventRequest{}, &common.HttpEventResponse{}) }, test: func(t *testing.T, actions []*common.Action, err error) { r.NoError(t, err) r.NotNil(t, actions[0].Error) r.Equal(t, "failed to set body: expected String but got Object at :4:6(5)", actions[0].Error.Message) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) r.Equal(t, "", res.Body) }, @@ -404,15 +430,15 @@ m.on('http', (req, res) => { }) `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) r.Equal(t, &[]any{int64(1), int64(2), int64(3)}, res.Data) return actions }, test: func(t *testing.T, actions []*common.Action, err error) { r.NoError(t, err) r.Nil(t, actions[0].Error) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) r.Equal(t, []any{float64(1), float64(2), float64(3)}, res.Data) }, @@ -427,8 +453,8 @@ m.on('http', (req, res) => { }) `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) r.Nil(t, actions[0].Error) r.Equal(t, map[string]any{"foo": "yuh"}, mokapi.Export(res.Data)) return actions @@ -436,7 +462,7 @@ m.on('http', (req, res) => { test: func(t *testing.T, actions []*common.Action, err error) { r.NoError(t, err) - var res *common.EventResponse + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) r.Equal(t, map[string]any{"foo": "yuh"}, mokapi.Export(res.Data)) }, @@ -450,8 +476,8 @@ m.on('http', (req, res) => { }) `, run: func(evt common.EventEmitter) []*common.Action { - res := &common.EventResponse{Data: map[string]any{"foo": "bar"}} - actions := evt.Emit("http", &common.EventRequest{}, res) + res := &common.HttpEventResponse{Data: map[string]any{"foo": "bar"}} + actions := evt.Emit("http", &common.HttpEventRequest{}, res) r.Nil(t, actions[0].Error) r.Equal(t, map[string]any{"foo": "yuh"}, mokapi.Export(res.Data)) return actions @@ -459,11 +485,123 @@ m.on('http', (req, res) => { test: func(t *testing.T, actions []*common.Action, err error) { r.NoError(t, err) - var res *common.EventResponse + var res *common.HttpEventResponse + err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) + r.Equal(t, map[string]any{"foo": "yuh"}, res.Data) + }, + }, + { + name: "rebuild function not defined", + script: ` +const m = require('mokapi') +m.on('http', (req, res) => { + res.rebuild(); +}) +`, + run: func(evt common.EventEmitter) []*common.Action { + res := &common.HttpEventResponse{Data: map[string]any{"foo": "bar"}} + return evt.Emit("http", &common.HttpEventRequest{}, res) + }, + test: func(t *testing.T, actions []*common.Action, err error) { + r.NoError(t, err) + + r.Nil(t, actions[0].Error) + + var res *common.HttpEventResponse + err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) + r.Equal(t, map[string]any{"foo": "bar"}, res.Data) + }, + }, + { + name: "rebuild function updates data", + script: ` +const m = require('mokapi') +m.on('http', (req, res) => { + res.rebuild(); +}) +`, + run: func(evt common.EventEmitter) []*common.Action { + res := &common.HttpEventResponse{Data: map[string]any{"foo": "bar"}} + res.Rebuild = func(statusCode int, contentType string) { + res.Data = map[string]any{"foo": "yuh"} + } + return evt.Emit("http", &common.HttpEventRequest{}, res) + }, + test: func(t *testing.T, actions []*common.Action, err error) { + r.NoError(t, err) + + r.Nil(t, actions[0].Error) + + var res *common.HttpEventResponse err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) r.Equal(t, map[string]any{"foo": "yuh"}, res.Data) }, }, + { + name: "rebuild function with parameters", + script: ` +const m = require('mokapi') +m.on('http', (req, res) => { + res.rebuild(200, 'application/json'); +}) +`, + run: func(evt common.EventEmitter) []*common.Action { + res := &common.HttpEventResponse{Data: map[string]any{"foo": "bar"}} + res.Rebuild = func(statusCode int, contentType string) { + res.Data = map[string]any{"statusCode": statusCode, "contentType": contentType} + } + return evt.Emit("http", &common.HttpEventRequest{}, res) + }, + test: func(t *testing.T, actions []*common.Action, err error) { + r.NoError(t, err) + + r.Nil(t, actions[0].Error) + + var res *common.HttpEventResponse + err = json.Unmarshal([]byte(actions[0].Parameters[1].(string)), &res) + r.Equal(t, map[string]any{"statusCode": float64(200), "contentType": "application/json"}, res.Data) + }, + }, + { + name: "rebuild function wrong type statusCode", + script: ` +const m = require('mokapi') +m.on('http', (req, res) => { + res.rebuild({ }, 'application/json'); +}) +`, + run: func(evt common.EventEmitter) []*common.Action { + res := &common.HttpEventResponse{Data: map[string]any{"foo": "bar"}} + res.Rebuild = func(statusCode int, contentType string) {} + return evt.Emit("http", &common.HttpEventRequest{}, res) + }, + test: func(t *testing.T, actions []*common.Action, err error) { + r.NoError(t, err) + + r.NotNil(t, actions[0].Error) + r.Equal(t, "response.rebuild failed: statusCode must be a number: got Object", actions[0].Error.Message) + }, + }, + { + name: "rebuild function wrong type contentType", + script: ` +const m = require('mokapi') +m.on('http', (req, res) => { + res.rebuild(100, 200); +}) +`, + run: func(evt common.EventEmitter) []*common.Action { + res := &common.HttpEventResponse{Data: map[string]any{"foo": "bar"}} + res.Rebuild = func(statusCode int, contentType string) {} + return evt.Emit("http", &common.HttpEventRequest{}, res) + }, + test: func(t *testing.T, actions []*common.Action, err error) { + r.NoError(t, err) + + r.NotNil(t, actions[0].Error) + r.Equal(t, "response.rebuild failed: contentType must be a string: got Integer", actions[0].Error.Message) + }, + }, } for _, tc := range testcases { @@ -482,7 +620,7 @@ m.on('http', (req, res) => { reg.Enable(vm) var runEvent common.EventHandler - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { runEvent = do } diff --git a/js/mokapi/proxy.go b/js/mokapi/proxy.go index 32d3678b8..374343020 100644 --- a/js/mokapi/proxy.go +++ b/js/mokapi/proxy.go @@ -54,8 +54,10 @@ func (p *Proxy) Get(key string) goja.Value { v := target.MapIndex(reflect.ValueOf(key)) return p.toJSValue(key, v) case reflect.Struct: - f := getFieldByTag(target, key, "json") - return p.toJSValue(key, f) + f := getField(target, key, "json") + if f.IsValid() { + return p.toJSValue(key, f) + } case reflect.Slice: switch key { case "length": @@ -116,9 +118,8 @@ func (p *Proxy) Get(key string) goja.Value { } return goja.Undefined() } - default: - return goja.Undefined() } + panic(fmt.Sprintf("%s is not defined", key)) } func (p *Proxy) Has(key string) bool { @@ -133,7 +134,7 @@ func (p *Proxy) Has(key string) bool { k := target.MapIndex(reflect.ValueOf(key)) return k.IsValid() case reflect.Struct: - f := getFieldByTag(target, key, "json") + f := getField(target, key, "json") return f.IsValid() default: return false @@ -155,7 +156,7 @@ func (p *Proxy) Set(key string, value goja.Value) bool { target.SetMapIndex(reflect.ValueOf(key), v) return true case reflect.Struct: - f := getFieldByTag(target, key, "json") + f := getField(target, key, "json") err := assignValue(f, value.Export(), key) if err != nil { panic(p.vm.ToValue(err)) @@ -216,6 +217,10 @@ func (p *Proxy) normalizeKey(key string) string { } func (p *Proxy) toJSValue(key string, v reflect.Value) goja.Value { + if !v.IsValid() { + return goja.Undefined() + } + if p.ToJSValue != nil { return p.ToJSValue(p.vm, key, v.Interface()) } @@ -242,10 +247,15 @@ func (p *Proxy) Export() any { return Export(v) } -func getFieldByTag(structValue reflect.Value, name, tag string) reflect.Value { +func getField(structValue reflect.Value, name, tag string) reflect.Value { + name = capitalize(name) for i := 0; i < structValue.NumField(); i++ { - v := structValue.Type().Field(i).Tag.Get(tag) - tagValues := strings.Split(v, ",") + f := structValue.Type().Field(i) + if f.Name == name { + return structValue.Field(i) + } + t := f.Tag.Get(tag) + tagValues := strings.Split(t, ",") for _, tagValue := range tagValues { if tagValue == name { return structValue.Field(i) @@ -362,3 +372,7 @@ func unwrap(v reflect.Value) reflect.Value { } } } + +func capitalize(s string) string { + return strings.ToUpper(s[0:1]) + s[1:] +} diff --git a/js/script.go b/js/script.go index 77be59fdd..ab02626ce 100644 --- a/js/script.go +++ b/js/script.go @@ -208,12 +208,12 @@ func (s *Script) addHttpEvent(i interface{}) { if len(ctx.Args) != 2 { return false, fmt.Errorf("expected args: request, response") } - req := ctx.Args[0].(*engine.EventRequest) - res := ctx.Args[1].(*engine.EventResponse) + req := ctx.Args[0].(*engine.HttpEventRequest) + res := ctx.Args[1].(*engine.HttpEventResponse) return engine.HttpEventHandler(req, res, i) } - s.host.On("http", f, nil) + s.host.On("http", f, engine.EventArgs{}) } // customFieldNameMapper default implementation filters out @@ -269,6 +269,7 @@ func RegisterNativeModules(registry *require.Registry) { registry.RegisterNativeModule("mokapi/smtp", mail.Require) registry.RegisterNativeModule("mokapi/ldap", ldap.Require) registry.RegisterNativeModule("mokapi/encoding", encoding.Require) + registry.RegisterNativeModule("mokapi/file", file.Require) } func isClosingError(err error) bool { diff --git a/js/script_console_test.go b/js/script_console_test.go index d7f73bc4c..f144da85d 100644 --- a/js/script_console_test.go +++ b/js/script_console_test.go @@ -1,11 +1,12 @@ package js_test import ( - r "github.com/stretchr/testify/require" "mokapi/engine/enginetest" "mokapi/js" "mokapi/js/jstest" "testing" + + r "github.com/stretchr/testify/require" ) func TestScript_Console(t *testing.T) { @@ -67,8 +68,7 @@ func TestScript_Console(t *testing.T) { r.NoError(t, err) err = s.Run() r.NoError(t, err) - r.Equal(t, `{"foo":123,"bar":"mokapi"}`, log[0]) - r.Equal(t, "foo", log[1]) + r.Equal(t, `{"foo":123,"bar":"mokapi"} foo`, log[0]) }, }, } diff --git a/js/script_data_test.go b/js/script_data_test.go index bef1f4927..3b8d19071 100644 --- a/js/script_data_test.go +++ b/js/script_data_test.go @@ -18,11 +18,11 @@ func TestScript_Data(t *testing.T) { { name: "resource array", test: func(t *testing.T, host *enginetest.Host) { - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { r.Equal(t, "http", event) - request := &common.EventRequest{} + request := &common.HttpEventRequest{} request.Url.Path = "/foo/bar" - response := &common.EventResponse{} + response := &common.HttpEventResponse{} b, err := do(&common.EventContext{Args: []any{request, response}}) r.NoError(t, err) r.True(t, b) @@ -39,11 +39,11 @@ export const mokapi = {http: {"bar": [1, 2, 3, 4]}}`), { name: "resource absolute precedence ", test: func(t *testing.T, host *enginetest.Host) { - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { r.Equal(t, "http", event) - request := &common.EventRequest{} + request := &common.HttpEventRequest{} request.Url.Path = "/foo/bar" - response := &common.EventResponse{} + response := &common.HttpEventResponse{} b, err := do(&common.EventContext{Args: []any{request, response}}) r.NoError(t, err) r.True(t, b) @@ -60,11 +60,11 @@ export const mokapi = {"http": {"bar": [5,6], "foo": {"bar": [1, 2, 3, 4]}}}`), { name: "using default function", test: func(t *testing.T, host *enginetest.Host) { - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { r.Equal(t, "http", event) - request := &common.EventRequest{} + request := &common.HttpEventRequest{} request.Url.Path = "/foo/bar" - response := &common.EventResponse{} + response := &common.HttpEventResponse{} b, err := do(&common.EventContext{Args: []any{request, response}}) r.NoError(t, err) r.True(t, b) diff --git a/js/script_mokapi_test.go b/js/script_mokapi_test.go index 9ffc1fc4d..948e19de4 100644 --- a/js/script_mokapi_test.go +++ b/js/script_mokapi_test.go @@ -299,7 +299,7 @@ func TestScript_Mokapi_On_Http(t *testing.T) { { name: "event", test: func(t *testing.T, host *enginetest.Host) { - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { r.Equal(t, "http", event) } s, err := jstest.New(jstest.WithSource( @@ -316,8 +316,8 @@ func TestScript_Mokapi_On_Http(t *testing.T) { { name: "tags", test: func(t *testing.T, host *enginetest.Host) { - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { - r.Equal(t, "bar", tags["foo"]) + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { + r.Equal(t, "bar", args.Tags["foo"]) } s, err := jstest.New(jstest.WithSource( `import { on } from 'mokapi' @@ -348,7 +348,7 @@ func TestScript_Mokapi_On_Http(t *testing.T) { name: "run function", test: func(t *testing.T, host *enginetest.Host) { var doFunc common.EventHandler - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { doFunc = do } s, err := jstest.New(jstest.WithSource( @@ -379,7 +379,7 @@ func TestScript_Mokapi_On_Http(t *testing.T) { name: "return value default is false", test: func(t *testing.T, host *enginetest.Host) { var doFunc common.EventHandler - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { doFunc = do } s, err := jstest.New(jstest.WithSource( @@ -401,7 +401,7 @@ func TestScript_Mokapi_On_Http(t *testing.T) { name: "on error", test: func(t *testing.T, host *enginetest.Host) { var doFunc common.EventHandler - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { doFunc = do } s, err := jstest.New(jstest.WithSource( @@ -429,7 +429,7 @@ func TestScript_Mokapi_On_Http(t *testing.T) { } var doFunc common.EventHandler - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { doFunc = do } s, err := jstest.New(jstest.WithSource( @@ -452,13 +452,13 @@ func TestScript_Mokapi_On_Http(t *testing.T) { name: "access kebab case property by bracket notation", test: func(t *testing.T, host *enginetest.Host) { data := &struct { - Ship_date string `json:"ship-date"` // can be accessed via obj['ship-date'] in javascript + Ship_date string `json:"ship-date"` // can be accessed via obj['ship-date'] in JavaScript }{ Ship_date: "2022-01-01", } var doFunc common.EventHandler - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { doFunc = do } s, err := jstest.New(jstest.WithSource( @@ -483,7 +483,7 @@ func TestScript_Mokapi_On_Http(t *testing.T) { data := map[string]string{"foo": "bar"} var doFunc common.EventHandler - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { doFunc = do } s, err := jstest.New(jstest.WithSource( @@ -506,7 +506,7 @@ func TestScript_Mokapi_On_Http(t *testing.T) { name: "logging and async", test: func(t *testing.T, host *enginetest.Host) { var doFunc common.EventHandler - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { doFunc = do } s, err := jstest.New(jstest.WithSource( @@ -557,7 +557,7 @@ func TestScript_Mokapi_On_Kafka(t *testing.T) { { name: "event", test: func(t *testing.T, host *enginetest.Host) { - host.OnFunc = func(event string, do common.EventHandler, tags map[string]string) { + host.OnFunc = func(event string, do common.EventHandler, args common.EventArgs) { r.Equal(t, "kafka", event) } s, err := jstest.New(jstest.WithSource( diff --git a/lua/convert/convert.go b/lua/convert/convert.go index d3af2b4f2..49a39f0a1 100644 --- a/lua/convert/convert.go +++ b/lua/convert/convert.go @@ -2,10 +2,11 @@ package convert import ( "fmt" - lua "github.com/yuin/gopher-lua" - luar "layeh.com/gopher-luar" "mokapi/sortedmap" "reflect" + + lua "github.com/yuin/gopher-lua" + luar "layeh.com/gopher-luar" ) func FromLua(lv lua.LValue, to interface{}) error { @@ -102,6 +103,10 @@ func ToLua(l *lua.LState, from interface{}) (lua.LValue, error) { if !f.IsExported() { continue } + tag := f.Tag.Get("json") + if tag == "-" { + continue + } fields = append(fields, reflect.StructField{ Name: f.Name, Type: reflect.TypeOf((*lua.LValue)(nil)).Elem(), diff --git a/lua/modules/mokapi.go b/lua/modules/mokapi.go index 6fb7e2d26..c851213ed 100644 --- a/lua/modules/mokapi.go +++ b/lua/modules/mokapi.go @@ -118,7 +118,7 @@ func (m *Mokapi) on(l *lua.LState) int { } } - m.host.On(evt, fn, args.Tags) + m.host.On(evt, fn, common.EventArgs{Tags: args.Tags}) return 0 } diff --git a/lua/modules/mokapi_test.go b/lua/modules/mokapi_test.go index e44ed9cc1..e133a8f17 100644 --- a/lua/modules/mokapi_test.go +++ b/lua/modules/mokapi_test.go @@ -99,7 +99,7 @@ func TestMokapi_On(t *testing.T) { t.Run("mokapi.on event", func(t *testing.T) { var event string host := &testHost{ - fnOn: func(evt string, do common.EventHandler, tags map[string]string) { + fnOn: func(evt string, do common.EventHandler, args common.EventArgs) { event = evt }, } @@ -119,7 +119,7 @@ mokapi.on("foo", function() end) t.Run("mokapi.on do returns true", func(t *testing.T) { var fn common.EventHandler host := &testHost{ - fnOn: func(evt string, do common.EventHandler, tags map[string]string) { + fnOn: func(evt string, do common.EventHandler, args common.EventArgs) { fn = do }, } @@ -141,7 +141,7 @@ mokapi.on("foo", function() return true end) t.Run("mokapi.on do got error", func(t *testing.T) { var fn common.EventHandler host := &testHost{ - fnOn: func(evt string, do common.EventHandler, tags map[string]string) { + fnOn: func(evt string, do common.EventHandler, args common.EventArgs) { fn = do }, } @@ -166,7 +166,7 @@ end) t.Run("mokapi.on with parameters", func(t *testing.T) { var fn common.EventHandler host := &testHost{ - fnOn: func(evt string, do common.EventHandler, tags map[string]string) { + fnOn: func(evt string, do common.EventHandler, args common.EventArgs) { fn = do }, } @@ -192,8 +192,8 @@ end) t.Run("mokapi.on tags", func(t *testing.T) { var m map[string]string host := &testHost{ - fnOn: func(evt string, do common.EventHandler, tags map[string]string) { - m = tags + fnOn: func(evt string, do common.EventHandler, args common.EventArgs) { + m = args.Tags }, } l := lua.NewState(lua.Options{IncludeGoStackTrace: true}) @@ -212,7 +212,7 @@ mokapi.on("foo", function() return true end, {tags = {tag1 = "foo", tag2 = "bar" t.Run("mokapi.on access variable", func(t *testing.T) { var fn common.EventHandler host := &testHost{ - fnOn: func(evt string, do common.EventHandler, tags map[string]string) { + fnOn: func(evt string, do common.EventHandler, args common.EventArgs) { fn = do }, } @@ -239,7 +239,7 @@ return true end) t.Run("mokapi.on two handlers", func(t *testing.T) { var fns []common.EventHandler host := &testHost{ - fnOn: func(evt string, do common.EventHandler, tags map[string]string) { + fnOn: func(evt string, do common.EventHandler, args common.EventArgs) { fns = append(fns, do) }, } @@ -276,7 +276,7 @@ return true end) type testHost struct { common.Host fnInfo func(s string) - fnOn func(event string, do common.EventHandler, tags map[string]string) + fnOn func(event string, do common.EventHandler, args common.EventArgs) fnEvery func(every string, do func(), opt common.JobOptions) (int, error) } @@ -286,9 +286,9 @@ func (th *testHost) Info(args ...interface{}) { } } -func (th *testHost) On(event string, do common.EventHandler, tags map[string]string) { +func (th *testHost) On(event string, do common.EventHandler, args common.EventArgs) { if th.fnOn != nil { - th.fnOn(event, do, tags) + th.fnOn(event, do, args) } } diff --git a/lua/script_test.go b/lua/script_test.go index ef32d15c2..727bdad83 100644 --- a/lua/script_test.go +++ b/lua/script_test.go @@ -53,7 +53,7 @@ func TestMokapi_On(t *testing.T) { fnInfo: func(s string) { log = s }, - fnOn: func(evt string, do common.EventHandler, tags map[string]string) { + fnOn: func(evt string, do common.EventHandler, args common.EventArgs) { called = true _, err := do(&common.EventContext{}) require.NoError(t, err) @@ -108,7 +108,7 @@ mustache.render("", {}) type testHost struct { common.Host fnInfo func(s string) - fnOn func(event string, do common.EventHandler, tags map[string]string) + fnOn func(event string, do common.EventHandler, args common.EventArgs) } func (th *testHost) Info(args ...interface{}) { @@ -117,9 +117,9 @@ func (th *testHost) Info(args ...interface{}) { } } -func (th *testHost) On(event string, do common.EventHandler, tags map[string]string) { +func (th *testHost) On(event string, do common.EventHandler, args common.EventArgs) { if th.fnOn != nil { - th.fnOn(event, do, tags) + th.fnOn(event, do, args) } } diff --git a/npm/README.md b/npm/README.md index 5f4cdbb36..20c41aea7 100644 --- a/npm/README.md +++ b/npm/README.md @@ -86,32 +86,32 @@ Mokapi’s dashboard lets you visualize your mock APIs. View requests and respon Explore tutorials that walk you through mocking different protocols and scenarios: -- 🌍 [Get started with REST API](https://mokapi.io/docs/resources/tutorials/get-started-with-rest-api)\ +- 🌍 [Get started with REST API](https://mokapi.io/resources/tutorials/get-started-with-rest-api)\ This tutorial will show you how to mock a REST API using an OpenAPI specification. -- ⚡ [Mocking Kafka with AsyncAPI](https://mokapi.io/docs/resources/tutorials/get-started-with-kafka)\ +- ⚡ [Mocking Kafka with AsyncAPI](https://mokapi.io/resources/tutorials/get-started-with-kafka)\ mocking a Kafka topic using Mokapi and verifying that a producer generates valid messages. -- 👨‍💻 [Mocking LDAP Authentication](https://mokapi.io/docs/resources/tutorials/mock-ldap-authentication-in-node)\ +- 👨‍💻 [Mocking LDAP Authentication](https://mokapi.io/resources/tutorials/mock-ldap-authentication-in-node)\ Simulate LDAP-based login flows, including group-based permissions. -- 📧 [Mocking SMTP Mail Servers](https://mokapi.io/docs/resources/tutorials/mock-smtp-server-send-mail-using-node)\ +- 📧 [Mocking SMTP Mail Servers](https://mokapi.io/resources/tutorials/mock-smtp-server-send-mail-using-node)\ Use Mokapi to simulate sending and receiving emails in Node.js apps. -- 🖥️ [End-to-End Testing with Jest and GitHub Actions](https://mokapi.io/docs/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline)\ +- 🖥️ [End-to-End Testing with Jest and GitHub Actions](https://mokapi.io/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline)\ Integrate Mokapi into your CI pipeline for full-stack E2E testing. -> More examples are available on [mokapi.io/docs/resources](https://mokapi.io/docs/resources) +> More examples are available on [mokapi.io/resources](https://mokapi.io/resources) # 📚 Documentation -- [Get Started](https://mokapi.io/docs/guides/welcome) -- [HTTP](https://mokapi.io/docs/guides/http) -- [Kafka](https://mokapi.io/docs/guides/kafka) -- [LDAP](https://mokapi.io/docs/guides/ldap) -- [SMTP](https://mokapi.io/docs/guides/mail) -- [Javascript API](https://mokapi.io/docs/javascript-api) -- [Resources](https://mokapi.io/docs/resources) +- [Get Started](https://mokapi.io/docs/welcome) +- [HTTP](https://mokapi.io/docs/http/overview) +- [Kafka](https://mokapi.io/docs/kafka/overview) +- [LDAP](https://mokapi.io/docs/ldap/overview) +- [SMTP](https://mokapi.io/docs/mail/overview) +- [Javascript API](https://mokapi.io/docs/javascript-api/overview) +- [Resources](https://mokapi.io/resources) # ☕ Support diff --git a/npm/types/file.d.ts b/npm/types/file.d.ts new file mode 100644 index 000000000..4862e4ce1 --- /dev/null +++ b/npm/types/file.d.ts @@ -0,0 +1,50 @@ +/** + * Reads the contents of a file and returns it as a string. + * + * If the path is relative, it is resolved relative to the entry script file. + * + * @param path - Path to the file to read. + * @returns The contents of the file. + * + * @example + * export default function() { + * const data = read('data.json') + * console.log(data) + * } + */ +export function read(path: string): string; + +/** + * Writes a string to a file at the given path. + * + * If the path is relative, it is resolved relative to the entry script file. + * If the file does not exist, it will be created. + * If the file exists, it will be overwritten. + * + * @param path - Path to the file to write. + * @param s - The string content to write. + * + * @example + * export default function() { + * writeString('data.json', 'Hello World') + * } + */ +export function writeString(path: string, s: string): void; + +/** + * Appends a string to a file at the given path. + * + * If the path is relative, it is resolved relative to the entry script file. + * If the file does not exist, it will be created. + * If the file exists, the string will be appended. + * + * @param path - Path to the file to append to. + * @param s - The string content to append. + * + * @example + * export default function() { + * writeString('data.json', 'Hello') + * appendString('data.json', ' World') + * } + */ +export function appendString(path: string, s: string): void; \ No newline at end of file diff --git a/npm/types/index.d.ts b/npm/types/index.d.ts index 4bb611536..bcbff1719 100644 --- a/npm/types/index.d.ts +++ b/npm/types/index.d.ts @@ -1,6 +1,6 @@ /** * Mokapi JavaScript API - * https://mokapi.io/docs/guides/welcome + * https://mokapi.io/docs/welcome */ import "./faker"; @@ -10,6 +10,7 @@ import "./mustache"; import "./yaml"; import "./encoding"; import "./mail"; +import "./file" /** * Attaches an event handler for the given event. @@ -157,6 +158,9 @@ export interface HttpRequest { /** Object contains querystring parameters specified by OpenAPI querystring parameters. */ readonly querystring: any; + /** Name of the API, as defined in the OpenAPI `info.title` field */ + readonly api: string; + /** Path value specified by the OpenAPI path */ readonly key: string; @@ -164,7 +168,7 @@ export interface HttpRequest { readonly operationId: string; /** Returns a string representing this HttpRequest object. */ - toString(): string + toString(): string; } /** @@ -183,6 +187,23 @@ export interface HttpResponse { /** Data will be encoded with the OpenAPI response definition. */ data: any; + + /** + * Rebuilds the entire HTTP response using the OpenAPI response definition for the given status code and content type + * @example + * import { on } from 'mokapi' + * + * export default function() { + * on('http', (request, response) => { + * if (request.path.petId === 10) { + * // Switch to a different OpenAPI response. + * response.rebuild(404, 'application/json') + * response.data.message = 'Pet not found' + * } + * }) + * } + * */ + rebuild: (statusCode?: number, contentType?: string) => void; } /** @@ -421,10 +442,11 @@ export type DateLayout = | "RFC3339Nano"; /** - * EventArgs object contains additional arguments for an event handler. + * EventArgs provides optional configuration for an event handler. * https://mokapi.io/docs/javascript-api/mokapi/on * - * Use this to customize how the event appears in the dashboard or to control tracking. + * Use this object to control how the event is tracked, labeled, + * and ordered in the execution pipeline. * * @example * export default function() { @@ -438,16 +460,32 @@ export type DateLayout = */ export interface EventArgs { /** - * Adds or overrides existing tags used to label the event in dashboard + * Adds or overrides tags used to label this event in the dashboard. + * Tags can be used for filtering, grouping, or ownership attribution. */ tags?: { [key: string]: string }; /** - * Set to `true` to enable tracking of this event handler in the dashboard. - * Set to `false` to disable tracking. If omitted, Mokapi checks the response - * object to determine if the handler changed it, and tracks it accordingly. + * Controls whether this event handler is tracked in the dashboard. + * + * - true: always track this handler + * - false: never track this handler + * - undefined: Mokapi determines tracking automatically based on + * whether the response object was modified by the handler */ track?: boolean; + + /** + * Defines the execution order of the event handler. + * + * Handlers with higher priority values run first. + * Handlers with lower priority values run later. + * + * Use negative priorities (e.g. -1) to run a handler after + * the response has been fully populated by other handlers, + * such as for logging or recording purposes. + */ + priority?: number; } /** diff --git a/pkg/cli/bind.go b/pkg/cli/bind.go index 95b322181..84ef0d8e4 100644 --- a/pkg/cli/bind.go +++ b/pkg/cli/bind.go @@ -14,6 +14,10 @@ import ( type flagConfigBinder struct{} +type ValueSetter interface { + Set(v any) error +} + type bindContext struct { path string paths []string @@ -376,6 +380,10 @@ func (f *flagConfigBinder) convert(value any, target reflect.Value) error { if s == "" { return nil } + err = useValueSetter(target, s) + if err == nil { + return nil + } pairs := strings.Split(s, ",") for _, pair := range pairs { kv := strings.Split(pair, "=") @@ -455,7 +463,7 @@ func (f *flagConfigBinder) setJson(element reflect.Value, i interface{}) error { element.Set(v.Convert(t)) return nil } - return fmt.Errorf("value %v can not be set", i) + return useValueSetter(element, v.Interface()) case []interface{}: // reset array element.Set(reflect.MakeSlice(element.Type(), 0, len(o))) @@ -565,3 +573,17 @@ func isJsonValue(s string) bool { return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) || (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) } + +func useValueSetter(element reflect.Value, v any) error { + var target any + if element.CanAddr() { + target = element.Addr().Interface() + } else { + target = element.Interface() + } + vs, ok := target.(ValueSetter) + if ok { + return vs.Set(v) + } + return fmt.Errorf("value %v can not be set", v) +} diff --git a/pkg/cli/command.go b/pkg/cli/command.go index 1f39c60d0..168c28106 100644 --- a/pkg/cli/command.go +++ b/pkg/cli/command.go @@ -104,10 +104,10 @@ func (c *Command) Flags() *FlagSet { }, } - c.Flags().Bool("help", false, FlagDoc{Short: "Show help information and exit"}) + c.Flags().BoolShort("help", "h", false, FlagDoc{Short: "Show help information and exit"}) if c.Version != "" { - c.Flags().Bool("version", false, FlagDoc{Short: "Show version information and exit"}) + c.Flags().BoolShort("version", "v", false, FlagDoc{Short: "Show version information and exit"}) } } return c.flags diff --git a/pkg/cli/docs_test.go b/pkg/cli/docs_test.go index b5c1a04c1..db63b027b 100644 --- a/pkg/cli/docs_test.go +++ b/pkg/cli/docs_test.go @@ -53,9 +53,9 @@ description: A complete list of all Foo flags, with descriptions, defaults, and Show help information and exit -| Flag | Env | Type | Default | -|------|------|:----:|:-------:| -| --help | HELP | bool | false | +| Flag | Shorthand | Env | Type | Default | +|------|:---------:|------|:----:|:-------:| +| --help | -h | HELP | bool | false | `, sb.String()) }, @@ -103,9 +103,9 @@ description: A complete list of all Foo flags, with descriptions, defaults, and Show help information and exit -| Flag | Env | Type | Default | -|------|------|:----:|:-------:| -| --help | HELP | bool | false | +| Flag | Shorthand | Env | Type | Default | +|------|:---------:|------|:----:|:-------:| +| --help | -h | HELP | bool | false | `, sb.String()) }, @@ -164,9 +164,9 @@ Some long description here Show help information and exit -| Flag | Env | Type | Default | -|------|------|:----:|:-------:| -| --help | HELP | bool | false | +| Flag | Shorthand | Env | Type | Default | +|------|:---------:|------|:----:|:-------:| +| --help | -h | HELP | bool | false | `, "```bash tab=CLI\n--foo bar\n```\n```bash tab=Env\nFOO=bar\n```\n```bash tab=File\nfoo: bar\n```"), sb.String()) }, diff --git a/pkg/cli/dynamic.go b/pkg/cli/dynamic.go index 4049bfe92..c0e584ce7 100644 --- a/pkg/cli/dynamic.go +++ b/pkg/cli/dynamic.go @@ -82,7 +82,7 @@ func (fs *FlagSet) DynamicStringSlice(name string, explode bool, doc FlagDoc) *F func convertToPattern(s string) *regexp.Regexp { // index is either [0] or _0_. The latter is the old version. - pattern := regexIndex.ReplaceAllString(s, "(\\[[0-9]+])|(-[0-9]+-?)") + pattern := regexIndex.ReplaceAllString(s, "((\\[[0-9]+])|(-[0-9]+-?))") pattern = regexKey.ReplaceAllString(pattern, "[a-zA-Z]+") regex, err := regexp.Compile(fmt.Sprintf("^%s$", pattern)) if err != nil { diff --git a/pkg/cli/dynamic_test.go b/pkg/cli/dynamic_test.go index b2ab7071f..24759c342 100644 --- a/pkg/cli/dynamic_test.go +++ b/pkg/cli/dynamic_test.go @@ -111,6 +111,18 @@ func TestDynamic(t *testing.T) { require.Equal(t, []int{12}, s.Foo) }, }, + { + name: "should not match", + test: func(t *testing.T) { + s := &struct { + Foo []int + }{} + c := newCmd([]string{"--foo[0]-bar", "12"}, &s) + c.Flags().DynamicInt("foo[]", cli.FlagDoc{}) + err := c.Execute() + require.EqualError(t, err, "unknown flag 'foo[0]-bar'") + }, + }, } for _, tc := range testcases { diff --git a/pkg/cli/file.go b/pkg/cli/file.go index 5f2be3e5b..59440b7a9 100644 --- a/pkg/cli/file.go +++ b/pkg/cli/file.go @@ -186,9 +186,8 @@ func mapValueToConfig(value interface{}, configElement reflect.Value, format str case reflect.Struct: m, ok := value.(map[string]any) if !ok { - i := configElement.Interface() - _ = i - return fmt.Errorf("expected object structure, got: %v", value) + err = useValueSetter(configElement, value) + return err } for k, v := range m { f := getFieldByTag(configElement, k, format) diff --git a/pkg/cli/help.go b/pkg/cli/help.go index 9dcbedc4a..441e03f34 100644 --- a/pkg/cli/help.go +++ b/pkg/cli/help.go @@ -26,7 +26,13 @@ func (c *Command) printHelp() { _, _ = fmt.Fprintf(w, "\nFlags:") maxNameLen, hasShort := flagsInfo(flags) + done := map[string]bool{} _ = flags.Visit(func(flag *Flag) error { + // skip aliases and short + if done[flag.Name] { + return nil + } + done[flag.Name] = true _, _ = fmt.Fprintln(w) if hasShort { diff --git a/pkg/cli/help_test.go b/pkg/cli/help_test.go index 0d7321250..8c101d11c 100644 --- a/pkg/cli/help_test.go +++ b/pkg/cli/help_test.go @@ -21,7 +21,7 @@ func TestHelp(t *testing.T) { return c }(), test: func(t *testing.T, out string) { - require.Equal(t, "\nFlags:\n --help Show help information and exit\n", out) + require.Equal(t, "\nFlags:\n -h, --help Show help information and exit\n", out) }, }, { @@ -31,7 +31,7 @@ func TestHelp(t *testing.T) { return c }(), test: func(t *testing.T, out string) { - require.Equal(t, "\n\nLong Description\n\nFlags:\n --help Show help information and exit\n", out) + require.Equal(t, "\n\nLong Description\n\nFlags:\n -h, --help Show help information and exit\n", out) }, }, } diff --git a/pkg/cmd/mokapi/flags/providers_file.go b/pkg/cmd/mokapi/flags/providers_file.go index c6c8b2150..77505a838 100644 --- a/pkg/cmd/mokapi/flags/providers_file.go +++ b/pkg/cmd/mokapi/flags/providers_file.go @@ -11,6 +11,14 @@ func RegisterFileProvider(cmd *cli.Command) { cmd.Flags().StringSlice("providers-file-skip-prefix", []string{"_"}, false, providerFileSkipPrefix) cmd.Flags().StringSlice("providers-file-include", []string{}, false, providerFileInclude) cmd.Flags().DynamicString("providers-file-include[]", providerFileIncludeIndex) + cmd.Flags().StringSlice("providers-file-exclude", []string{}, false, providerFileExclude) + cmd.Flags().DynamicString("providers-file-exclude[]", providerFileExcludeIndex) + + // directories + cmd.Flags().DynamicString("providers-file-directories[]", providerFileDirectoriesIndex) + cmd.Flags().DynamicString("providers-file-directories[]-path", providerFileDirectoriesPath) + cmd.Flags().DynamicString("providers-file-directories[]-include", providerFileDirectoriesInclude) + cmd.Flags().DynamicString("providers-file-directories[]-exclude", providerFileDirectoriesExclude) } var providerFile = cli.FlagDoc{ @@ -133,3 +141,91 @@ This option is mainly intended for advanced use cases or programmatic configurat }, }, } + +var providerFileExclude = cli.FlagDoc{ + Short: "Exclude files or directories matching patterns", + Long: `Defines patterns for files or directories that should be excluded when loading configuration. +Any file or directory matching one of the exclude patterns is ignored, even if it would otherwise be included. When empty, no files are explicitly excluded.`, + Examples: []cli.Example{ + { + Codes: []cli.Code{ + {Title: "CLI", Source: "--providers-file-exclude tmp/*"}, + {Title: "Env", Source: "MOKAPI_PROVIDERS_FILE_EXCLUDE=tmp/*"}, + {Title: "File", Source: "providers:\n file:\n exclude: ['tmp/*']", Language: "yaml"}, + }, + }, + }, +} + +var providerFileExcludeIndex = cli.FlagDoc{ + Short: "Set exclude rule at the specified index", + Long: `Sets or overrides an exclude rule at the specified index. +This option is useful when exclude rules are defined via environment variables or configuration files but need to be adjusted or overridden using CLI arguments.`, + Examples: []cli.Example{ + { + Codes: []cli.Code{ + {Title: "CLI", Source: "--providers-file-exclude[1] tmp/*"}, + {Title: "Env", Source: "MOKAPI_PROVIDERS_FILE_EXCLUDE[1]=tmp/*"}, + }, + }, + }, +} + +var providerFileDirectoriesIndex = cli.FlagDoc{ + Short: "Configure the directory at the specified index", + Long: `Configures a directory entry at the specified index in the directories list. +This option allows directory-specific configuration and is mainly intended for advanced or programmatic use cases where precise control over individual directories is required.`, + Examples: []cli.Example{ + { + Codes: []cli.Code{ + {Title: "CLI", Source: "--providers-file-directories[0]-path ./mocks"}, + {Title: "Env", Source: "MOKAPI_PROVIDERS_FILE_DIRECTORIES[0]_PATH=./mocks"}, + }, + }, + }, +} + +var providerFileDirectoriesPath = cli.FlagDoc{ + Short: "Set the directory path", + Long: `Specifies the filesystem path of the directory to load configuration files from. +All supported configuration files in this directory are processed, subject to include and exclude rules. +`, + Examples: []cli.Example{ + { + Codes: []cli.Code{ + {Title: "CLI", Source: "--providers-file-directories[1]-path ./mocks"}, + {Title: "Env", Source: "MOKAPI_PROVIDERS_FILE_DIRECTORIES[1]_PATH=./mocks"}, + }, + }, + }, +} + +var providerFileDirectoriesInclude = cli.FlagDoc{ + Short: "Include only matching files or patterns", + Long: `Defines include patterns that files in the directory must match to be loaded. +If at least one include pattern is specified, only files matching one of the patterns are processed. When empty, all supported files are included by default. +`, + Examples: []cli.Example{ + { + Codes: []cli.Code{ + {Title: "CLI", Source: "--providers-file-directories[1]-include *index.js"}, + {Title: "Env", Source: "MOKAPI_PROVIDERS_FILE_DIRECTORIES[1]_INCLUDE=*index.js"}, + }, + }, + }, +} + +var providerFileDirectoriesExclude = cli.FlagDoc{ + Short: "Exclude matching files or patterns", + Long: `Defines exclude patterns for files or directories within the specified directory. +Any file or directory matching one of the exclude patterns is ignored, even if it matches an include rule. +`, + Examples: []cli.Example{ + { + Codes: []cli.Code{ + {Title: "CLI", Source: "--providers-file-exclude[1] *.tmp"}, + {Title: "Env", Source: "MOKAPI_PROVIDERS_FILE_EXCLUDE[1]=*.tmp"}, + }, + }, + }, +} diff --git a/pkg/cmd/mokapi/flags/providers_file_test.go b/pkg/cmd/mokapi/flags/providers_file_test.go new file mode 100644 index 000000000..b88e92caf --- /dev/null +++ b/pkg/cmd/mokapi/flags/providers_file_test.go @@ -0,0 +1,90 @@ +package flags_test + +import ( + "mokapi/config/static" + "mokapi/pkg/cli" + "mokapi/pkg/cmd/mokapi" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRoot_Providers_File(t *testing.T) { + testcases := []struct { + name string + cmd func(t *testing.T) *cli.Command + test func(t *testing.T, cfg *static.Config, flags *cli.FlagSet) + }{ + { + name: "directories index path", + cmd: func(t *testing.T) *cli.Command { + cmd := mokapi.NewCmdMokapi() + cmd.SetArgs([]string{"--providers-file-directories[0]-path ./foo"}) + return cmd + }, + test: func(t *testing.T, cfg *static.Config, flags *cli.FlagSet) { + require.Equal(t, []static.FileConfig{ + {Path: "./foo"}, + }, cfg.Providers.File.Directories) + }, + }, + { + name: "directories index path", + cmd: func(t *testing.T) *cli.Command { + cmd := mokapi.NewCmdMokapi() + cmd.SetArgs([]string{"--providers-file-directories[0]-path /foo"}) + return cmd + }, + test: func(t *testing.T, cfg *static.Config, flags *cli.FlagSet) { + require.Equal(t, []static.FileConfig{ + {Path: "/foo"}, + }, cfg.Providers.File.Directories) + }, + }, + { + name: "directories index include", + cmd: func(t *testing.T) *cli.Command { + cmd := mokapi.NewCmdMokapi() + cmd.SetArgs([]string{"--providers-file-directories[0]-include *index.js"}) + return cmd + }, + test: func(t *testing.T, cfg *static.Config, flags *cli.FlagSet) { + require.Equal(t, []static.FileConfig{ + {Include: []string{"*index.js"}}, + }, cfg.Providers.File.Directories) + }, + }, + { + name: "directories index include two values", + cmd: func(t *testing.T) *cli.Command { + cmd := mokapi.NewCmdMokapi() + cmd.SetArgs([]string{"--providers-file-directories[0]-include *index.js *foo.ts"}) + return cmd + }, + test: func(t *testing.T, cfg *static.Config, flags *cli.FlagSet) { + require.Equal(t, []static.FileConfig{ + {Include: []string{"*index.js", "*foo.ts"}}, + }, cfg.Providers.File.Directories) + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + cli.SetFileReader(&cli.FileReader{}) + }() + + cmd := tc.cmd(t) + var cfg *static.Config + cmd.Run = func(cmd *cli.Command, args []string) error { + cfg = cmd.Config.(*static.Config) + return nil + } + err := cmd.Execute() + require.NoError(t, err) + + tc.test(t, cfg, cmd.Flags()) + }) + } +} diff --git a/pkg/cmd/mokapi/flags/providers_npm_test.go b/pkg/cmd/mokapi/flags/providers_npm_test.go index fc43bdb9a..751423ca6 100644 --- a/pkg/cmd/mokapi/flags/providers_npm_test.go +++ b/pkg/cmd/mokapi/flags/providers_npm_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestRoot_Providers_File(t *testing.T) { +func TestRoot_Providers_Npm(t *testing.T) { testcases := []struct { name string args func(t *testing.T) []string diff --git a/pkg/cmd/mokapi/help.go b/pkg/cmd/mokapi/help.go index a83e90abf..6671761e4 100644 --- a/pkg/cmd/mokapi/help.go +++ b/pkg/cmd/mokapi/help.go @@ -2,7 +2,6 @@ package mokapi import ( "fmt" - "mokapi/config/decoders" "mokapi/config/static" "reflect" "strings" @@ -14,7 +13,7 @@ import ( func writeSkeleton(section string) { var skeleton interface{} if section != "" { - paths := decoders.ParsePath(section) + paths := parsePath(section) current := reflect.ValueOf(static.NewConfig()) for _, path := range paths { if current.Kind() == reflect.Pointer { @@ -41,3 +40,20 @@ func writeSkeleton(section string) { } fmt.Print(string(b)) } + +func parsePath(key string) []string { + var paths []string + split := strings.FieldsFunc(key, func(r rune) bool { + return r == '.' || r == '-' + }) + + for _, v := range split { + if strings.HasSuffix(v, "]") { + index := strings.Index(v, "[") + paths = append(paths, v[:index], v[index:]) + } else { + paths = append(paths, v) + } + } + return paths +} diff --git a/pkg/cmd/mokapi/mokapi_test.go b/pkg/cmd/mokapi/mokapi_test.go index ebbc0da08..cd0a1496e 100644 --- a/pkg/cmd/mokapi/mokapi_test.go +++ b/pkg/cmd/mokapi/mokapi_test.go @@ -127,7 +127,7 @@ func TestStaticConfig(t *testing.T) { args: []string{`--providers-file={"filename":"foo.yaml","directory":"foo", "skipPrefix":["_"]}`}, test: func(t *testing.T, cfg *static.Config) { require.Equal(t, []string{"foo.yaml"}, cfg.Providers.File.Filenames) - require.Equal(t, []string{"foo"}, cfg.Providers.File.Directories) + require.Equal(t, []static.FileConfig{{Path: "foo"}}, cfg.Providers.File.Directories) require.Equal(t, []string{"_"}, cfg.Providers.File.SkipPrefix) }, }, @@ -595,7 +595,7 @@ providers: c, cfg := newCmd([]string{"--cli-input", path}) err := c.Execute() require.NoError(t, err) - require.Equal(t, []string{"foo"}, cfg.Providers.File.Directories) + require.Equal(t, []static.FileConfig{{Path: "foo"}}, cfg.Providers.File.Directories) }, }, { @@ -609,7 +609,7 @@ providers: c, cfg := newCmd([]string{"--cli-input", path}) err := c.Execute() require.NoError(t, err) - require.Equal(t, []string{"/foo", "/bar"}, cfg.Providers.File.Directories) + require.Equal(t, []static.FileConfig{{Path: "/foo"}, {Path: "/bar"}}, cfg.Providers.File.Directories) }, }, { @@ -619,7 +619,7 @@ providers: c, cfg := newCmd([]string{"--cli-input", path}) err := c.Execute() require.NoError(t, err) - require.Equal(t, []string{"foo"}, cfg.Providers.File.Directories) + require.Equal(t, []static.FileConfig{{Path: "foo"}}, cfg.Providers.File.Directories) }, }, } diff --git a/pkg/cmd/mokapi/providers_test.go b/pkg/cmd/mokapi/providers_test.go index 4169cb3f9..b8a6bcc24 100644 --- a/pkg/cmd/mokapi/providers_test.go +++ b/pkg/cmd/mokapi/providers_test.go @@ -119,7 +119,7 @@ func TestRoot_Providers_File(t *testing.T) { return []string{} }, test: func(t *testing.T, cfg *static.Config, flags *cli.FlagSet) { - require.Equal(t, []string{"/foo", "/bar"}, cfg.Providers.File.Directories) + require.Equal(t, []static.FileConfig{{Path: "/foo"}, {Path: "/bar"}}, cfg.Providers.File.Directories) }, }, { @@ -138,7 +138,7 @@ func TestRoot_Providers_File(t *testing.T) { return []string{"--config-file", f} }, test: func(t *testing.T, cfg *static.Config, flags *cli.FlagSet) { - require.Equal(t, []string{"/foo", "/bar"}, cfg.Providers.File.Directories) + require.Equal(t, []static.FileConfig{{Path: "/foo"}, {Path: "/bar"}}, cfg.Providers.File.Directories) }, }, { diff --git a/providers/openapi/event.go b/providers/openapi/event.go index cd638e9af..d58392c75 100644 --- a/providers/openapi/event.go +++ b/providers/openapi/event.go @@ -18,8 +18,8 @@ import ( const eventKey = "event" -func NewEventResponse(status int, ct media.ContentType) *common.EventResponse { - r := &common.EventResponse{ +func NewEventResponse(status int, ct media.ContentType) *common.HttpEventResponse { + r := &common.HttpEventResponse{ Headers: make(map[string]any), StatusCode: status, } @@ -31,17 +31,17 @@ func NewEventResponse(status int, ct media.ContentType) *common.EventResponse { return r } -func EventRequestFromContext(ctx context.Context) *common.EventRequest { - e := ctx.Value(eventKey).(*common.EventRequest) +func EventRequestFromContext(ctx context.Context) *common.HttpEventRequest { + e := ctx.Value(eventKey).(*common.HttpEventRequest) return e } -func NewEventRequest(r *http.Request, contentType media.ContentType, api string) (*common.EventRequest, context.Context) { +func NewEventRequest(r *http.Request, contentType media.ContentType, api string) (*common.HttpEventRequest, context.Context) { ctx := r.Context() endpointPath := ctx.Value("endpointPath").(string) op, _ := OperationFromContext(ctx) - req := &common.EventRequest{ + req := &common.HttpEventRequest{ Api: api, Key: endpointPath, OperationId: op.OperationId, @@ -88,7 +88,7 @@ func NewEventRequest(r *http.Request, contentType media.ContentType, api string) return req, context.WithValue(ctx, eventKey, req) } -func setResponseData(r *common.EventResponse, m *MediaType, request *common.EventRequest) error { +func setResponseData(r *common.HttpEventResponse, m *MediaType, request *common.HttpEventRequest) error { if m != nil { if len(m.Examples) > 0 { keys := reflect.ValueOf(m.Examples).MapKeys() @@ -125,7 +125,7 @@ func setResponseData(r *common.EventResponse, m *MediaType, request *common.Even return nil } -func setResponseHeader(r *common.EventResponse, headers Headers) error { +func setResponseHeader(r *common.HttpEventResponse, headers Headers) error { for k, v := range headers { if v.Value == nil { log.Warnf("header ref not resovled: %v", v.Ref) @@ -140,7 +140,7 @@ func setResponseHeader(r *common.EventResponse, headers Headers) error { return nil } -func getGeneratorContext(r *common.EventRequest) map[string]interface{} { +func getGeneratorContext(r *common.HttpEventRequest) map[string]interface{} { ctx := map[string]interface{}{} for k, v := range r.Cookie { if v != nil { diff --git a/providers/openapi/handler.go b/providers/openapi/handler.go index f573b5b52..2a4c70679 100644 --- a/providers/openapi/handler.go +++ b/providers/openapi/handler.go @@ -116,6 +116,7 @@ func (h *responseHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { } response := NewEventResponse(status, contentType) + setResponseRebuild(response, request, op) err = setResponseData(response, mediaType, request) if err != nil { @@ -464,3 +465,45 @@ func drainRequestBody(r *http.Request) { log.Warnf("timeout reading request body for %s %s", r.Method, lib.GetUrl(r)) } } + +func setResponseRebuild(response *common.HttpEventResponse, request *common.HttpEventRequest, op *Operation) { + response.Rebuild = func(statusCode int, contentType string) { + res := op.Responses.GetResponse(statusCode) + if res == nil { + res = op.getResponse(0) + if res == nil { + panic( + fmt.Sprintf( + "no configuration was found for HTTP status code %v, https://swagger.io/docs/specification/describing-responses", + statusCode, + ), + ) + } + } + var mediaType *MediaType + if contentType != "" { + mediaType = res.Content[contentType] + if mediaType == nil { + panic(fmt.Sprintf("content type '%s' is not specified for HTTP status code %v", contentType, statusCode)) + } + } else { + for name, mt := range res.Content { + contentType = name + mediaType = mt + } + } + + response.StatusCode = statusCode + response.Headers = map[string]any{} + + err := setResponseData(response, mediaType, request) + if err != nil { + panic(err) + } + + err = setResponseHeader(response, res.Headers) + if err != nil { + panic(err) + } + } +} diff --git a/providers/openapi/handler_requestbody_test.go b/providers/openapi/handler_requestbody_test.go index d4035d291..fab206c00 100644 --- a/providers/openapi/handler_requestbody_test.go +++ b/providers/openapi/handler_requestbody_test.go @@ -3,7 +3,7 @@ package openapi_test import ( "bytes" "io" - engine2 "mokapi/engine/common" + "mokapi/engine/common" "mokapi/engine/enginetest" "mokapi/providers/openapi" "mokapi/providers/openapi/openapitest" @@ -24,7 +24,7 @@ func TestResponseHandler_ServeHTTP_ResponseBody(t *testing.T) { name string config *openapi.Config fn func(t *testing.T, handler openapi.Handler) - check func(t *testing.T, r *engine2.EventRequest) + check func(t *testing.T, r *common.HttpEventRequest) }{ { name: "text/plain", @@ -50,7 +50,7 @@ func TestResponseHandler_ServeHTTP_ResponseBody(t *testing.T) { require.Equal(t, 200, rr.Code) }, - check: func(t *testing.T, r *engine2.EventRequest) { + check: func(t *testing.T, r *common.HttpEventRequest) { require.Equal(t, "foo", r.Body) }, }, @@ -78,7 +78,7 @@ func TestResponseHandler_ServeHTTP_ResponseBody(t *testing.T) { require.Equal(t, 200, rr.Code) }, - check: func(t *testing.T, r *engine2.EventRequest) { + check: func(t *testing.T, r *common.HttpEventRequest) { require.Equal(t, "foo", r.Body) }, }, @@ -108,7 +108,7 @@ func TestResponseHandler_ServeHTTP_ResponseBody(t *testing.T) { require.Equal(t, 200, rr.Code) }, - check: func(t *testing.T, r *engine2.EventRequest) { + check: func(t *testing.T, r *common.HttpEventRequest) { require.Equal(t, "foo", r.Body) }, }, @@ -138,7 +138,7 @@ func TestResponseHandler_ServeHTTP_ResponseBody(t *testing.T) { require.Equal(t, 200, rr.Code) }, - check: func(t *testing.T, r *engine2.EventRequest) { + check: func(t *testing.T, r *common.HttpEventRequest) { require.Equal(t, map[string]interface{}{"bar": float64(12), "foo": "abc"}, r.Body) }, }, @@ -168,7 +168,7 @@ func TestResponseHandler_ServeHTTP_ResponseBody(t *testing.T) { require.Equal(t, http.StatusOK, rr.Code) }, - check: func(t *testing.T, r *engine2.EventRequest) { + check: func(t *testing.T, r *common.HttpEventRequest) { }, }, { @@ -195,7 +195,7 @@ func TestResponseHandler_ServeHTTP_ResponseBody(t *testing.T) { require.Equal(t, http.StatusOK, rr.Code) require.True(t, spy.readCalled, "server needs to read body") }, - check: func(t *testing.T, r *engine2.EventRequest) { + check: func(t *testing.T, r *common.HttpEventRequest) { }, }, } @@ -205,9 +205,9 @@ func TestResponseHandler_ServeHTTP_ResponseBody(t *testing.T) { t.Run(tc.name, func(t *testing.T) { test.NewNullLogger() - var r *engine2.EventRequest - e := enginetest.NewEngineWithHandler(func(event string, args ...interface{}) []*engine2.Action { - r = args[0].(*engine2.EventRequest) + var r *common.HttpEventRequest + e := enginetest.NewEngineWithHandler(func(event string, args ...interface{}) []*common.Action { + r = args[0].(*common.HttpEventRequest) return nil }) diff --git a/providers/openapi/handler_response_test.go b/providers/openapi/handler_response_test.go index eb0ecd7b5..2a7ea6e6f 100644 --- a/providers/openapi/handler_response_test.go +++ b/providers/openapi/handler_response_test.go @@ -36,14 +36,14 @@ func TestHandler_Response(t *testing.T) { testcases := []struct { name string config *openapi.Config - handler func(event string, req *common.EventRequest, res *common.EventResponse) + handler func(event string, req *common.HttpEventRequest, res *common.HttpEventResponse) req func() *http.Request test func(t *testing.T, rr *httptest.ResponseRecorder, eh events.Handler) }{ { name: "string as response body", config: getConfig(schematest.New("string"), "application/json"), - handler: func(event string, req *common.EventRequest, res *common.EventResponse) { + handler: func(event string, req *common.HttpEventRequest, res *common.HttpEventResponse) { res.Body = "foo" }, req: func() *http.Request { @@ -57,7 +57,7 @@ func TestHandler_Response(t *testing.T) { { name: "invalid string body", config: getConfig(schematest.New("string", schematest.WithFormat("date")), "application/json"), - handler: func(event string, req *common.EventRequest, res *common.EventResponse) { + handler: func(event string, req *common.HttpEventRequest, res *common.HttpEventResponse) { res.Data = "foo" }, req: func() *http.Request { @@ -71,7 +71,7 @@ func TestHandler_Response(t *testing.T) { { name: "object with null property", config: getConfig(schematest.New("object", schematest.WithProperty("foo", schematest.New("string", schematest.IsNullable(true)))), "application/json"), - handler: func(event string, req *common.EventRequest, res *common.EventResponse) { + handler: func(event string, req *common.HttpEventRequest, res *common.HttpEventResponse) { res.Data = map[string]interface{}{"foo": nil} }, req: func() *http.Request { @@ -85,7 +85,7 @@ func TestHandler_Response(t *testing.T) { { name: "detect content type on byte array", config: getConfig(schematest.New("object", schematest.WithProperty("foo", schematest.New("string"))), "*/*"), - handler: func(event string, req *common.EventRequest, res *common.EventResponse) { + handler: func(event string, req *common.HttpEventRequest, res *common.HttpEventResponse) { res.Data = []byte(`{"foo":"bar"}`) }, req: func() *http.Request { @@ -100,7 +100,7 @@ func TestHandler_Response(t *testing.T) { { name: "application/octet-stream with string", config: getConfig(schematest.New("string"), "application/octet-stream"), - handler: func(event string, req *common.EventRequest, res *common.EventResponse) { + handler: func(event string, req *common.HttpEventRequest, res *common.HttpEventResponse) { res.Data = "foo" }, req: func() *http.Request { @@ -114,7 +114,7 @@ func TestHandler_Response(t *testing.T) { { name: "application/octet-stream with object", config: getConfig(schematest.New("object"), "application/octet-stream"), - handler: func(event string, req *common.EventRequest, res *common.EventResponse) { + handler: func(event string, req *common.HttpEventRequest, res *common.HttpEventResponse) { res.Data = map[string]interface{}{"foo": "bar"} }, req: func() *http.Request { @@ -128,7 +128,7 @@ func TestHandler_Response(t *testing.T) { { name: "no content defined should send empty response body", config: getConfig(nil, ""), - handler: func(event string, req *common.EventRequest, res *common.EventResponse) { + handler: func(event string, req *common.HttpEventRequest, res *common.HttpEventResponse) { res.Data = map[string]interface{}{"foo": "bar"} }, req: func() *http.Request { @@ -149,7 +149,7 @@ func TestHandler_Response(t *testing.T) { { name: "no content defined should send body, when res.Body is used", config: getConfig(nil, ""), - handler: func(event string, req *common.EventRequest, res *common.EventResponse) { + handler: func(event string, req *common.HttpEventRequest, res *common.HttpEventResponse) { res.Headers["Content-Type"] = "text/plain" res.Body = "foo" }, @@ -178,7 +178,7 @@ func TestHandler_Response(t *testing.T) { m.SetStore(10, events.NewTraits().WithNamespace("http")) e := &engine{emit: func(event string, args ...interface{}) []*common.Action { - tc.handler(event, args[0].(*common.EventRequest), args[1].(*common.EventResponse)) + tc.handler(event, args[0].(*common.HttpEventRequest), args[1].(*common.HttpEventResponse)) return []*common.Action{ { Duration: 16, diff --git a/providers/openapi/handler_security_test.go b/providers/openapi/handler_security_test.go index 5670e213c..5133a61f9 100644 --- a/providers/openapi/handler_security_test.go +++ b/providers/openapi/handler_security_test.go @@ -2,7 +2,6 @@ package openapi_test import ( "fmt" - "github.com/stretchr/testify/require" "mokapi/engine/common" "mokapi/providers/openapi" "mokapi/providers/openapi/openapitest" @@ -10,6 +9,8 @@ import ( "net/http" "net/http/httptest" "testing" + + "github.com/stretchr/testify/require" ) func TestHandler_Security(t *testing.T) { @@ -44,8 +45,8 @@ func TestHandler_Security(t *testing.T) { require.Equal(t, "Basic 123", *httpLog.Request.Parameters[0].Raw) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - r := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + r := args[1].(*common.HttpEventResponse) r.Data = req.Header["Authorization"] return nil }, @@ -102,8 +103,8 @@ func TestHandler_Security(t *testing.T) { require.Equal(t, "Bearer 123", *httpLog.Request.Parameters[0].Raw) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - r := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + r := args[1].(*common.HttpEventResponse) r.Data = req.Header["Authorization"] return nil }, @@ -136,8 +137,8 @@ func TestHandler_Security(t *testing.T) { require.Equal(t, "123", *httpLog.Request.Parameters[0].Raw) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - r := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + r := args[1].(*common.HttpEventResponse) r.Data = req.Header["X-API-KEY"] return nil }, @@ -170,8 +171,8 @@ func TestHandler_Security(t *testing.T) { require.Equal(t, "123", *httpLog.Request.Parameters[0].Raw) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - r := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + r := args[1].(*common.HttpEventResponse) r.Data = req.Query["apikey"] return nil }, @@ -204,8 +205,8 @@ func TestHandler_Security(t *testing.T) { require.Equal(t, "123", *httpLog.Request.Parameters[1].Raw) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - r := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + r := args[1].(*common.HttpEventResponse) r.Data = req.Cookie["apikey"] return nil }, @@ -256,8 +257,8 @@ func TestHandler_Security(t *testing.T) { require.Equal(t, "Bearer 123", *httpLog.Request.Parameters[0].Raw) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - r := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + r := args[1].(*common.HttpEventResponse) r.Data = req.Header["Authorization"] return nil }, @@ -293,8 +294,8 @@ func TestHandler_Security(t *testing.T) { require.Equal(t, `"Bearer 123 - API_KEY_123"`, rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - r := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + r := args[1].(*common.HttpEventResponse) r.Data = fmt.Sprintf("%s - %s", req.Header["Authorization"], req.Header["apikey"]) return nil }, @@ -325,8 +326,8 @@ func TestHandler_Security(t *testing.T) { require.Equal(t, `"API_KEY_123"`, rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - r := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + r := args[1].(*common.HttpEventResponse) r.Data = req.Header["apikey"] return nil }, diff --git a/providers/openapi/handler_test.go b/providers/openapi/handler_test.go index 52b82acf9..39c601995 100644 --- a/providers/openapi/handler_test.go +++ b/providers/openapi/handler_test.go @@ -3,6 +3,7 @@ package openapi_test import ( "context" "encoding/json" + "fmt" "io" "mokapi/config/dynamic" "mokapi/engine/common" @@ -785,7 +786,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, "no configuration was found for HTTP status code 415, https://swagger.io/docs/specification/describing-responses\n", rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - r := args[1].(*common.EventResponse) + r := args[1].(*common.HttpEventResponse) r.StatusCode = http.StatusUnsupportedMediaType return nil }, @@ -804,7 +805,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, "no configuration was found for HTTP status code 415, https://swagger.io/docs/specification/describing-responses\n", rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - r := args[1].(*common.EventResponse) + r := args[1].(*common.HttpEventResponse) r.StatusCode = http.StatusUnsupportedMediaType return nil }, @@ -825,7 +826,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, "text/plain", rr.Header().Get("Content-Type")) }, event: func(event string, args ...interface{}) []*common.Action { - r := args[1].(*common.EventResponse) + r := args[1].(*common.HttpEventResponse) r.Headers["Content-Type"] = "text/plain" r.Body = "Hello" return nil @@ -849,8 +850,8 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, `{"foo":"bar"}`, rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - res := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + res := args[1].(*common.HttpEventResponse) res.Data = req.Body return nil }, @@ -871,8 +872,8 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, "", rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - res := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + res := args[1].(*common.HttpEventResponse) res.Data = req.Body return nil }, @@ -896,8 +897,8 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, `"123"`, rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - res := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + res := args[1].(*common.HttpEventResponse) res.Data = req.Path["id"] return nil }, @@ -921,8 +922,8 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, `"123"`, rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - res := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + res := args[1].(*common.HttpEventResponse) res.Data = req.Path["id"] return nil }, @@ -946,7 +947,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, http.Header{"Content-Type": []string{"application/json"}, "Foo": []string{"12345"}}, rr.Header()) }, event: func(event string, args ...interface{}) []*common.Action { - res := args[1].(*common.EventResponse) + res := args[1].(*common.HttpEventResponse) res.Headers["foo"] = "12345" return nil }, @@ -976,7 +977,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, map[string]string{"Content-Type": "application/json", "Foobaryuh": "12345"}, log.Response.Headers) }, event: func(event string, args ...interface{}) []*common.Action { - res := args[1].(*common.EventResponse) + res := args[1].(*common.HttpEventResponse) res.Headers["FooBarYuh"] = "12345" return nil }, @@ -998,11 +999,11 @@ func TestHandler_Event(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "http://localhost/foo/123", nil) rr := httptest.NewRecorder() h(rr, r) - var er *common.EventRequest + var er *common.HttpEventRequest b := rr.Body.Bytes() err := json.Unmarshal(b, &er) require.NoError(t, err) - require.Equal(t, &common.EventRequest{ + require.Equal(t, &common.HttpEventRequest{ Method: http.MethodGet, Url: common.Url{ Scheme: "http", @@ -1023,8 +1024,8 @@ func TestHandler_Event(t *testing.T) { }, er) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) - res := args[1].(*common.EventResponse) + req := args[0].(*common.HttpEventRequest) + res := args[1].(*common.HttpEventResponse) res.Data = req return nil }, @@ -1047,7 +1048,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - res := args[1].(*common.EventResponse) + res := args[1].(*common.HttpEventResponse) res.Headers["Content-Type"] = "text/plain" res.Body = "hello world" return nil @@ -1071,7 +1072,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, "response has no definition for content type: text/plain\n", rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - res := args[1].(*common.EventResponse) + res := args[1].(*common.HttpEventResponse) res.Headers["Content-Type"] = "text/plain" return nil }, @@ -1094,7 +1095,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, "invalid header 'Content-Type': expected a string or array of strings, but received Integer\n", rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - res := args[1].(*common.EventResponse) + res := args[1].(*common.HttpEventResponse) res.Headers["Content-Type"] = 123 return nil }, @@ -1118,7 +1119,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, []string{"1", "2"}, rr.Header()["Foo"]) }, event: func(event string, args ...interface{}) []*common.Action { - res := args[1].(*common.EventResponse) + res := args[1].(*common.HttpEventResponse) res.Headers["foo"] = []any{"1", "2"} return nil }, @@ -1142,7 +1143,7 @@ func TestHandler_Event(t *testing.T) { require.Equal(t, "invalid header 'foo': error count 1:\n\t- expected array but got: bar\n", rr.Body.String()) }, event: func(event string, args ...interface{}) []*common.Action { - res := args[1].(*common.EventResponse) + res := args[1].(*common.HttpEventResponse) res.Headers["foo"] = "bar" return nil }, @@ -1249,7 +1250,7 @@ func TestHandler_Event_TypeScript(t *testing.T) { name: "async event handler", test: func(t *testing.T) { e := enginetest.NewEngine() - err := e.AddScript(newScript("test.ts", ` + err := e.AddScript(newScript(fmt.Sprintf("%s.ts", t.Name()), ` import {on, sleep} from 'mokapi' export default function() { on('http', async (request, response) => { @@ -1273,7 +1274,9 @@ func TestHandler_Event_TypeScript(t *testing.T) { } h := func(rw http.ResponseWriter, r *http.Request) { - h := openapi.NewHandler(config, e, &events.StoreManager{}) + sm := &events.StoreManager{} + sm.SetStore(10, events.NewTraits().WithNamespace("http")) + h := openapi.NewHandler(config, e, sm) err = h.ServeHTTP(rw, r) require.Nil(t, err) } @@ -1289,14 +1292,178 @@ func TestHandler_Event_TypeScript(t *testing.T) { require.Equal(t, `"foo"`, rr.Body.String()) }, }, + { + name: "rebuild different status code", + test: func(t *testing.T) { + e := enginetest.NewEngine() + err := e.AddScript(newScript(fmt.Sprintf("%s.ts", t.Name()), ` + import {on, sleep} from 'mokapi' + export default function() { + on('http', async (request, response) => { + response.rebuild(404); + }); + } + `)) + require.NoError(t, err) + + config := &openapi.Config{ + Info: openapi.Info{Name: "Testing"}, + Servers: []*openapi.Server{{Url: "http://localhost"}}, + } + + h := func(rw http.ResponseWriter, r *http.Request) { + sm := &events.StoreManager{} + sm.SetStore(10, events.NewTraits().WithNamespace("http")) + h := openapi.NewHandler(config, e, sm) + err = h.ServeHTTP(rw, r) + require.Nil(t, err) + } + + op := openapitest.NewOperation( + openapitest.WithResponse(http.StatusOK, openapitest.WithContent("application/json", + openapitest.NewContent(openapitest.WithSchema( + schematest.New( + "object", + schematest.WithProperty("foo", schematest.New("string")), + schematest.WithRequired("foo"), + ), + )), + )), + openapitest.WithResponse(http.StatusNotFound, openapitest.WithContent("application/json", + openapitest.NewContent(openapitest.WithSchema( + schematest.New( + "object", + schematest.WithProperty("message", schematest.New("string")), + schematest.WithRequired("message"), + ), + )), + )), + ) + openapitest.AppendPath("/foo", config, openapitest.WithOperation("get", op)) + r := httptest.NewRequest("get", "http://localhost/foo", nil) + r.Header.Set("accept", "application/json") + rr := httptest.NewRecorder() + h(rr, r) + require.Equal(t, http.StatusNotFound, rr.Code) + require.Contains(t, rr.Body.String(), `{"message":`) + }, + }, + { + name: "rebuild status code not in specification", + test: func(t *testing.T) { + e := enginetest.NewEngine() + err := e.AddScript(newScript(fmt.Sprintf("%s.ts", t.Name()), ` + import {on, sleep} from 'mokapi' + export default function() { + on('http', async (request, response) => { + response.rebuild(404); + }); + } + `)) + require.NoError(t, err) + + config := &openapi.Config{ + Info: openapi.Info{Name: "Testing"}, + Servers: []*openapi.Server{{Url: "http://localhost"}}, + } + + sm := &events.StoreManager{} + sm.SetStore(10, events.NewTraits().WithNamespace("http")) + h := func(rw http.ResponseWriter, r *http.Request) { + h := openapi.NewHandler(config, e, sm) + err = h.ServeHTTP(rw, r) + require.Nil(t, err) + } + + op := openapitest.NewOperation( + openapitest.WithResponse(http.StatusOK, openapitest.WithContent("application/json", + openapitest.NewContent(openapitest.WithSchema( + schematest.New( + "object", + schematest.WithProperty("foo", schematest.New("string")), + schematest.WithRequired("foo"), + ), + )), + )), + ) + openapitest.AppendPath("/foo", config, openapitest.WithOperation("get", op)) + r := httptest.NewRequest("get", "http://localhost/foo", nil) + r.Header.Set("accept", "application/json") + rr := httptest.NewRecorder() + h(rr, r) + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), `{"foo":`) + + result := sm.GetEvents(events.NewTraits().WithNamespace("http")) + log := result[0].Data.(*openapi.HttpLog) + require.Equal(t, "no configuration was found for HTTP status code 404, https://swagger.io/docs/specification/describing-responses", log.Actions[0].Error.Message) + }, + }, + { + name: "rebuild content type not in specification", + test: func(t *testing.T) { + e := enginetest.NewEngine() + err := e.AddScript(newScript(fmt.Sprintf("%s.ts", t.Name()), ` + import {on, sleep} from 'mokapi' + export default function() { + on('http', async (request, response) => { + response.rebuild(404, 'text/plain'); + }); + } + `)) + require.NoError(t, err) + + config := &openapi.Config{ + Info: openapi.Info{Name: "Testing"}, + Servers: []*openapi.Server{{Url: "http://localhost"}}, + } + + sm := &events.StoreManager{} + sm.SetStore(10, events.NewTraits().WithNamespace("http")) + h := func(rw http.ResponseWriter, r *http.Request) { + h := openapi.NewHandler(config, e, sm) + err = h.ServeHTTP(rw, r) + require.Nil(t, err) + } + + op := openapitest.NewOperation( + openapitest.WithResponse(http.StatusOK, openapitest.WithContent("application/json", + openapitest.NewContent(openapitest.WithSchema( + schematest.New( + "object", + schematest.WithProperty("foo", schematest.New("string")), + schematest.WithRequired("foo"), + ), + )), + )), + openapitest.WithResponse(http.StatusNotFound, openapitest.WithContent("application/json", + openapitest.NewContent(openapitest.WithSchema( + schematest.New( + "object", + schematest.WithProperty("message", schematest.New("string")), + schematest.WithRequired("message"), + ), + )), + )), + ) + openapitest.AppendPath("/foo", config, openapitest.WithOperation("get", op)) + r := httptest.NewRequest("get", "http://localhost/foo", nil) + r.Header.Set("accept", "application/json") + rr := httptest.NewRecorder() + h(rr, r) + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), `{"foo":`) + + result := sm.GetEvents(events.NewTraits().WithNamespace("http")) + log := result[0].Data.(*openapi.HttpLog) + require.Equal(t, "content type 'text/plain' is not specified for HTTP status code 404", log.Actions[0].Error.Message) + }, + }, } - t.Parallel() for _, tc := range testcases { tc := tc t.Run(tc.name, func(t *testing.T) { - t.Parallel() - tc.test(t) }) } @@ -1330,7 +1497,7 @@ func TestHandler_Parameter(t *testing.T) { require.Equal(t, "missing parameter definition for route /foo/{id}: invalid path parameter 'id'", hook.Entries[0].Message) }, event: func(event string, args ...interface{}) []*common.Action { - req := args[0].(*common.EventRequest) + req := args[0].(*common.HttpEventRequest) require.NotContains(t, req.Path, "id") require.Len(t, req.Path, 0) return nil diff --git a/providers/openapi/request_body.go b/providers/openapi/request_body.go index 180672eb5..45e51c9ea 100644 --- a/providers/openapi/request_body.go +++ b/providers/openapi/request_body.go @@ -238,11 +238,16 @@ func (d urlValueDecoder) decode(propName string, val interface{}) (interface{}, values := val.([]string) prop := d.mt.Schema.Properties.Get(propName) + if prop == nil { + return val, nil + } switch { case prop.Type.IsOneOf("integer", "number", "string"): return values[0], nil case prop.Type.IsArray(): return d.decodeArray(propName, values) + case prop.Type.IsObject(): + return d.decodeObject(propName, values) default: return nil, fmt.Errorf("unsupported type %v", prop.Type) } @@ -263,6 +268,19 @@ func (d urlValueDecoder) decodeArray(propName string, values []string) (interfac } } +func (d urlValueDecoder) decodeObject(propName string, values []string) (interface{}, error) { + enc, ok := d.mt.Encoding[propName] + if !ok { + return nil, fmt.Errorf("behavior not specified for complex serialization without content-type: see https://swagger.io/docs/specification/v3_0/describing-request-body/describing-request-body/") + } + p := &parser.Parser{Schema: schema.ConvertToJsonSchema(d.mt.Schema.Properties.Get(propName)), ValidateAdditionalProperties: true} + opts := []encoding.DecodeOptions{ + encoding.WithContentType(media.ParseContentType(enc.ContentType)), + encoding.WithParser(p), + } + return encoding.Decode([]byte(strings.Join(values, "&")), opts...) +} + type multipartForm struct { mt *MediaType p parser.Parser diff --git a/providers/openapi/request_body_test.go b/providers/openapi/request_body_test.go index dcd75a765..62dfe7360 100644 --- a/providers/openapi/request_body_test.go +++ b/providers/openapi/request_body_test.go @@ -585,6 +585,51 @@ func TestBodyFromRequest_FormUrlEncoded(t *testing.T) { require.Equal(t, map[string]interface{}{"array_name": []interface{}{"value1", "value2"}}, result.Value) }, }, + { + name: "property not defined", + mt: openapitest.NewContent( + openapitest.WithSchema( + schematest.New("object", + schematest.WithProperty("name", schematest.New("string")), + ), + ), + ), + body: "name2=value1", + test: func(t *testing.T, result *openapi.Body, err error) { + require.NoError(t, err) + require.Equal(t, map[string]any{"name2": []string{"value1"}}, result.Value) + }, + }, + { + name: "encoding application/json", + mt: openapitest.NewContent( + openapitest.WithSchema( + schematest.New( + "object", + schematest.WithProperty( + "payload", + schematest.New( + "object", + schematest.WithProperty("text", schematest.New("string")), + ), + ), + ), + ), + openapitest.WithEncoding( + "payload", + &openapi.Encoding{ + ContentType: "application/json", + }, + ), + ), + body: "payload={\"text\":\"Swagger is awesome\"}", + test: func(t *testing.T, result *openapi.Body, err error) { + require.NoError(t, err) + require.Equal(t, + map[string]any{"payload": map[string]any{"text": "Swagger is awesome"}}, + result.Value) + }, + }, } for _, tc := range testcases { diff --git a/schema/encoding/decoder_http_test.go b/schema/encoding/decoder_http_test.go index e079206db..2fb54c093 100644 --- a/schema/encoding/decoder_http_test.go +++ b/schema/encoding/decoder_http_test.go @@ -2,10 +2,11 @@ package encoding_test import ( "fmt" - "github.com/stretchr/testify/require" "mokapi/media" "mokapi/schema/encoding" "testing" + + "github.com/stretchr/testify/require" ) func TestFormUrlEncodeDecoder(t *testing.T) { diff --git a/schema/json/generator/text.go b/schema/json/generator/text.go index fa243d96d..4b17c09b7 100644 --- a/schema/json/generator/text.go +++ b/schema/json/generator/text.go @@ -1,8 +1,9 @@ package generator import ( - "github.com/brianvoe/gofakeit/v6" "math" + + "github.com/brianvoe/gofakeit/v6" ) func textNodes() []*Node { @@ -15,6 +16,10 @@ func textNodes() []*Node { Name: "category", Fake: fakeCategory, }, + { + Name: "message", + Fake: fakeDescription, + }, } } diff --git a/schema/json/generator/text_test.go b/schema/json/generator/text_test.go index 1cdc4dbcd..4e1efafef 100644 --- a/schema/json/generator/text_test.go +++ b/schema/json/generator/text_test.go @@ -1,10 +1,11 @@ package generator import ( - "github.com/brianvoe/gofakeit/v6" - "github.com/stretchr/testify/require" "mokapi/schema/json/schema/schematest" "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/require" ) func TestStringDescription(t *testing.T) { @@ -37,6 +38,17 @@ func TestStringDescription(t *testing.T) { require.Equal(t, "Say just these run whose foot this least.", v) }, }, + { + name: "message", + req: &Request{ + Path: []string{"message"}, + Schema: schematest.New("string"), + }, + test: func(t *testing.T, v interface{}, err error) { + require.NoError(t, err) + require.Equal(t, "Ourselves whomever wade regularly you how theirs these tomorrow staff gloves wow then opposite conclude those abroad she stop mob a rubbish mob as.", v) + }, + }, } for _, tc := range testcases { diff --git a/schema/json/parser/parser_string.go b/schema/json/parser/parser_string.go index 19a8a4824..dc2493d2e 100644 --- a/schema/json/parser/parser_string.go +++ b/schema/json/parser/parser_string.go @@ -26,6 +26,8 @@ func (p *Parser) ParseString(data interface{}, schema *schema.Schema) (interface s = v.String() case []byte: s = string(v) + case *[]byte: + s = string(*v) default: if v == nil { if schema.IsNullable() { diff --git a/schema/json/parser/time.go b/schema/json/parser/time.go index bd5ea01c8..a6ed65e2d 100644 --- a/schema/json/parser/time.go +++ b/schema/json/parser/time.go @@ -25,6 +25,10 @@ func ParseDuration(s string) error { return fmt.Errorf("invalid duration format: %s", s) } + if len(s) == 1 { // only P + return fmt.Errorf("invalid duration format: %s", s) + } + for _, char := range s { switch char { case 'P': @@ -78,7 +82,7 @@ func ParseDuration(s string) error { } num = "" case 'H': - if state != parsingUnit { + if state != parsingValue { return fmt.Errorf("invalid duration format: %s", s) } @@ -88,7 +92,7 @@ func ParseDuration(s string) error { } num = "" case 'S': - if state != parsingUnit { + if state != parsingValue { return fmt.Errorf("invalid duration format: %s", s) } @@ -107,5 +111,10 @@ func ParseDuration(s string) error { } } + // control that all values are analyzed so duration ends with a unit + if num != "" { + return fmt.Errorf("invalid duration format: %s", s) + } + return nil } diff --git a/schema/json/parser/time_test.go b/schema/json/parser/time_test.go new file mode 100644 index 000000000..01fa8bc98 --- /dev/null +++ b/schema/json/parser/time_test.go @@ -0,0 +1,44 @@ +package parser_test + +import ( + "mokapi/schema/json/parser" + "testing" +) + +func TestParseDuration(t *testing.T) { + tests := []struct { + name string + args string + wantErr bool + }{ + {name: "invalid-duration-1", args: "T0S", wantErr: true}, + {name: "invalid-duration-2", args: "P-T0S", wantErr: true}, + {name: "invalid-duration-3", args: "PT1SP0D", wantErr: true}, + {name: "invalid-duration-4", args: "AT1SP0D", wantErr: true}, + {name: "invalid-duration-5", args: "P", wantErr: true}, + {name: "invalid-duration-6", args: "T", wantErr: true}, + {name: "invalid-duration-7", args: "-P", wantErr: true}, + {name: "invalid-unit-miss1", args: "P7Y4", wantErr: true}, + {name: "invalid-unit-miss2", args: "P6", wantErr: true}, + {name: "valid-period-only", args: "P2Y", wantErr: false}, + {name: "valid-time-decimal", args: "PT4.5S", wantErr: false}, + {name: "valid-full-iso8601", args: "P4Y3M2DT12H30M5.5S", wantErr: false}, + {name: "valid-seconds-1", args: "PT10S", wantErr: false}, + {name: "valid-minutes-2", args: "PT25M", wantErr: false}, + {name: "valid-hours-3", args: "PT2H", wantErr: false}, + {name: "valid-negative", args: "-PT5M", wantErr: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := parser.ParseDuration(tt.args) + if tt.wantErr == true && err == nil { + t.Errorf("ParseDuration() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr == false && err != nil { + t.Errorf("ParseDuration() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} diff --git a/webui/.env b/webui/.env index 9cb0340c3..17127bea5 100644 --- a/webui/.env +++ b/webui/.env @@ -1,2 +1,3 @@ VITE_USE_DEMO=true -VITE_DASHBOARD=true \ No newline at end of file +VITE_DASHBOARD=true +VITE_WEBSITE=true \ No newline at end of file diff --git a/webui/build-sitemap.js b/webui/build-sitemap.js index 686bdbea2..9febef308 100644 --- a/webui/build-sitemap.js +++ b/webui/build-sitemap.js @@ -39,34 +39,40 @@ const urlTemplate = ` %s ` +function writeItem(item) { + let xml = ''; + + if (item.path && item.source) { + stats = fs.statSync(path.join(docsPath, item.source)); + const url = `https://mokapi.io${item.path}`; + const node = util.format(urlTemplate, url, '0.7', stats.mtime.toISOString()) + xml += node + } + + if (item.items) { + for (const child of item.items) { + xml += writeItem(child) + } + } + return xml +} + function writeObject(obj, base) { let xml = '' for (let key in obj) { + const item = obj[key]; - let segment = key.split(' ').join('-').split('/').join('-').replace('&', '%26') - const urlPath = base + '/' + segment.toLowerCase() - - if (key === 'index') { - const url = 'https://mokapi.io/docs' + urlPath.replaceAll('/items', '').replace(/\/index$/, "/") - let stats; - if (typeof obj['index'] === "string") { - stats = fs.statSync(path.join(docsPath, obj["index"])) - } - else { - stats = fs.statSync(docsPath, key.toLowerCase()) - } + if (item.path && item.source) { + stats = fs.statSync(path.join(docsPath, item.source)); + const url = `https://mokapi.io${item.path}`; const node = util.format(urlTemplate, url, '0.7', stats.mtime.toISOString()) xml += node - } else { - if (typeof obj[key] !== "string") { - xml += writeObject(obj[key], urlPath) - } - else { - const stats = fs.statSync(path.join(docsPath, obj[key])) - const url = 'https://mokapi.io/docs' + urlPath.replaceAll('/items', '') - const node = util.format(urlTemplate, url, '0.7', stats.mtime.toISOString()) - xml += node + } + + if (item.items) { + for (const child of item.items) { + xml += writeItem(child) } } } diff --git a/webui/e2e/Dashboard/kafka/cluster.spec.ts b/webui/e2e/Dashboard/kafka/cluster.spec.ts index eede01fec..e746fd550 100644 --- a/webui/e2e/Dashboard/kafka/cluster.spec.ts +++ b/webui/e2e/Dashboard/kafka/cluster.spec.ts @@ -77,16 +77,18 @@ test('Visit Kafka cluster config file', async ({ page, context }) => { await open() await tabs.kafka.click() - await page.getByRole('table', { name: 'Kafka Clusters' }).getByText(cluster.name).click() + const dashboard = page.getByRole('region', { name: 'Dashboard' }); - await page.getByRole('tab', { name: 'Configs' }).click(); - await page.getByRole('table', { name: 'Configs' }).getByText('https://www.example.com/foo/bar/communication/service/asyncapi.json').click() + await dashboard.getByRole('table', { name: 'Kafka Clusters' }).getByText(cluster.name).click() - await expect(page.getByLabel('URL')).toHaveText('https://www.example.com/foo/bar/communication/service/asyncapi.json') - await expect(page.getByLabel('Provider')).toHaveText('HTTP') - await expect(page.getByLabel('Last Modified')).toHaveText(formatDateTime('2023-02-15T08:49:25.482366+01:00')) + await dashboard.getByRole('tab', { name: 'Configs' }).click(); + await dashboard.getByRole('table', { name: 'Configs' }).getByText('https://www.example.com/foo/bar/communication/service/asyncapi.json').click() - const { test: testSourceView } = useSourceView(page.getByRole('region', { name: 'Content' })) + await expect(dashboard.getByLabel('URL')).toHaveText('https://www.example.com/foo/bar/communication/service/asyncapi.json') + await expect(dashboard.getByLabel('Provider')).toHaveText('HTTP') + await expect(dashboard.getByLabel('Last Modified')).toHaveText(formatDateTime('2023-02-15T08:49:25.482366+01:00')) + + const { test: testSourceView } = useSourceView(dashboard.getByRole('region', { name: 'Content' })) await testSourceView({ lines: '342 lines', size: '8.94 kB', diff --git a/webui/e2e/Dashboard/kafka/topic.order.spec.ts b/webui/e2e/Dashboard/kafka/topic.order.spec.ts index f72caddcd..f027ff07f 100644 --- a/webui/e2e/Dashboard/kafka/topic.order.spec.ts +++ b/webui/e2e/Dashboard/kafka/topic.order.spec.ts @@ -142,7 +142,8 @@ test('Visit Kafka topic mokapi.shop.products', async ({ page, context }) => { await test.step('Go back to cluster view', async () => { await page.getByRole('link', { name: 'cluster' }).click() - await expect(page.getByLabel('name')).toHaveText(cluster.name) + const region = page.getByRole('region', { name: 'Info' }); + await expect(region.getByLabel('name')).toHaveText(cluster.name) }) }) }) diff --git a/webui/e2e/Dashboard/kafka/topic.userSignedUp.spec.ts b/webui/e2e/Dashboard/kafka/topic.userSignedUp.spec.ts index 911d9ba41..16d67f8c0 100644 --- a/webui/e2e/Dashboard/kafka/topic.userSignedUp.spec.ts +++ b/webui/e2e/Dashboard/kafka/topic.userSignedUp.spec.ts @@ -143,7 +143,8 @@ test('Visit Kafka topic mokapi.shop.userSignedUp', async ({ page, context }) => await test.step('Go back to cluster view', async () => { await page.getByRole('link', { name: 'cluster' }).click() - await expect(page.getByLabel('name')).toHaveText(cluster.name) + const region = page.getByRole('region', { name: 'Info' }); + await expect(region.getByLabel('name')).toHaveText(cluster.name) }) }) }) diff --git a/webui/e2e/components/dashboard.ts b/webui/e2e/components/dashboard.ts index e0c00105f..d2dd6a066 100644 --- a/webui/e2e/components/dashboard.ts +++ b/webui/e2e/components/dashboard.ts @@ -8,12 +8,13 @@ export function useDashboard(page: Page) { } export function useDashboardTabs(page: Page) { + const dashboard = page.getByRole('region', { name: 'Dashboard' }) return { - overview: page.getByRole('link', { name: 'Overview' }), - http: page.getByRole('link', { name: 'HTTP', exact: true }), - kafka: page.getByRole('link', { name: 'Kafka', exact: true }), - mail: page.getByRole('link', { name: 'Mail', exact: true }), - ldap: page.getByRole('link', { name: 'LDAP', exact: true }), - configs: page.getByRole('link', { name: 'Configs', exact: true }), + overview: dashboard.getByRole('link', { name: 'Overview' }), + http: dashboard.getByRole('link', { name: 'HTTP', exact: true }), + kafka: dashboard.getByRole('link', { name: 'Kafka', exact: true }), + mail: dashboard.getByRole('link', { name: 'Mail', exact: true }), + ldap: dashboard.getByRole('link', { name: 'LDAP', exact: true }), + configs: dashboard.getByRole('link', { name: 'Configs', exact: true }), } } \ No newline at end of file diff --git a/webui/e2e/dashboard-demo/kafka.spec.ts b/webui/e2e/dashboard-demo/kafka.spec.ts index ba3ea3b3a..f2e647b88 100644 --- a/webui/e2e/dashboard-demo/kafka.spec.ts +++ b/webui/e2e/dashboard-demo/kafka.spec.ts @@ -12,9 +12,10 @@ test('Visit Kafka Order Service', async ({ page }) => { await test.step('Verify service info', async () => { - await expect(page.getByLabel('Name')).toHaveText('Kafka Order Service API'); - await expect(page.getByLabel('Version')).toHaveText('1.0.0'); - await expect(page.getByLabel('Description')).toHaveText('An API to process customer orders and notify about order status updates using Kafka.') + const region = page.getByRole('region', { name: 'Info' }); + await expect(region.getByLabel('Name')).toHaveText('Kafka Order Service API'); + await expect(region.getByLabel('Version')).toHaveText('1.0.0'); + await expect(region.getByLabel('Description')).toHaveText('An API to process customer orders and notify about order status updates using Kafka.') }); @@ -66,10 +67,11 @@ test('Visit Kafka Order Service', async ({ page }) => { await expect(await getCellByColumnName(table, 'Members', rows.nth(0))).toHaveText('1'); await rows.nth(0).getByRole('cell').nth(0).click(); - await expect(page.getByLabel('Group Name')).toHaveText('order-status-group-100'); - await expect(page.getByLabel('State')).toHaveText('Stable'); - await expect(page.getByLabel('Protocol')).toHaveText('RoundRobinAssigner'); - await expect(page.getByLabel('Generation', { exact: true })).toHaveText('0'); + const region = page.getByRole('region', { name: 'Info' }); + await expect(region.getByLabel('Group Name')).toHaveText('order-status-group-100'); + await expect(region.getByLabel('State')).toHaveText('Stable'); + await expect(region.getByLabel('Protocol')).toHaveText('RoundRobinAssigner'); + await expect(region.getByLabel('Generation', { exact: true })).toHaveText('0'); await test.step('Verify Members', async () => { @@ -89,9 +91,10 @@ test('Visit Kafka Order Service', async ({ page }) => { await members.locator('tbody tr').click(); - await expect(page.getByLabel('Member Name')).toHaveText(/^consumer-1/); - await expect(page.getByLabel('Client')).toHaveText(/^consumer-1/); - await expect(page.getByLabel('Heartbeat')).not.toBeEmpty(); + const info = page.getByRole('region', { name: 'Info' }); + await expect(info.getByLabel('Member Name')).toHaveText(/^consumer-1/); + await expect(info.getByLabel('Client')).toHaveText(/^consumer-1/); + await expect(info.getByLabel('Heartbeat')).not.toBeEmpty(); const region = page.getByRole('region', { name: 'Partitions' }); await expect(region).toBeVisible(); @@ -140,24 +143,26 @@ test('Visit Kafka Order Service', async ({ page }) => { await page.getByRole('tab', { name: 'Topics' }).click(); await page.getByRole('table', { name: 'Topics' }).getByText('order-topic').click(); - await expect(page.getByLabel('Topic', { exact: true })).toHaveText('order-topic'); - await expect(page.getByLabel('Cluster')).toHaveText('Kafka Order Service API'); - await expect(page.getByLabel('Cluster')).toHaveAttribute('href'); - await expect(page.getByLabel('Description')).toHaveText('The Kafka topic for order events.'); + const info = page.getByRole('region', { name: 'Info' }); + await expect(info.getByLabel('Topic', { exact: true })).toHaveText('order-topic'); + await expect(info.getByLabel('Cluster')).toHaveText('Kafka Order Service API'); + await expect(info.getByLabel('Cluster')).toHaveAttribute('href'); + await expect(info.getByLabel('Description')).toHaveText('The Kafka topic for order events.'); - await expect(page.getByLabel('Type of API')).toHaveText('Kafka'); + await expect(info.getByLabel('Type of API')).toHaveText('Kafka'); await test.step('Verify Message 1', async () => { await page.getByRole('table', { name: 'Recent Messages' }).locator('tbody tr').getByRole('link', { name: 'a914817b-c5f0-433e-8280-1cd2fe44234e' }).click(); - await expect(page.getByLabel('Kafka Key')).toHaveText('a914817b-c5f0-433e-8280-1cd2fe44234e'); - await expect(page.getByLabel('Kafka Topic')).toHaveText('order-topic'); - await expect(page.getByLabel('Kafka Topic')).toHaveAttribute('href', '/dashboard-demo/kafka/service/Kafka%20Order%20Service%20API/topics/order-topic'); - await expect(page.getByLabel('Offset')).toHaveText('1'); - await expect(page.getByRole('region', { name: 'Meta' }).getByLabel('Content Type')).toHaveText('application/json'); - await expect(page.getByLabel('Key Type')).toHaveText('-'); - await expect(page.getByLabel('Key Type')).not.toBeEmpty(); - await expect(page.getByLabel('Client')).toHaveText('producer-1'); + const meta = page.getByRole('region', { name: 'Meta' }); + await expect(meta.getByLabel('Kafka Key')).toHaveText('a914817b-c5f0-433e-8280-1cd2fe44234e'); + await expect(meta.getByLabel('Kafka Topic')).toHaveText('order-topic'); + await expect(meta.getByLabel('Kafka Topic')).toHaveAttribute('href', '/dashboard-demo/kafka/service/Kafka%20Order%20Service%20API/topics/order-topic'); + await expect(meta.getByLabel('Offset')).toHaveText('1'); + await expect(meta.getByLabel('Content Type')).toHaveText('application/json'); + await expect(meta.getByLabel('Key Type')).toHaveText('-'); + await expect(meta.getByLabel('Key Type')).not.toBeEmpty(); + await expect(meta.getByLabel('Client')).toHaveText('producer-1'); const value = page.getByRole('region', { name: 'Value' }); await expect(value.getByLabel('Content Type')).toHaveText('application/json'); @@ -167,8 +172,9 @@ test('Visit Kafka Order Service', async ({ page }) => { await test.step('Verify Producer', async () => { await page.getByLabel('Client').getByRole('link').click(); - await expect(page.getByLabel('ClientId')).toHaveText('producer-1'); - await expect(page.getByLabel('Address')).not.toBeEmpty(); + const info = page.getByRole('region', { name: 'Info' }); + await expect(info.getByLabel('ClientId')).toHaveText('producer-1'); + await expect(info.getByLabel('Address')).not.toBeEmpty(); await page.goBack(); }) @@ -180,18 +186,20 @@ test('Visit Kafka Order Service', async ({ page }) => { await test.step('Verify Message 2', async () => { await page.getByRole('table', { name: 'Recent Messages' }).locator('tbody tr').getByRole('link', { name: 'random-message-1' }).click(); - await expect(page.getByLabel('Kafka Key')).toHaveText('random-message-1'); - await expect(page.getByLabel('Kafka Topic')).toHaveText('order-topic'); - await expect(page.getByLabel('Kafka Topic')).toHaveAttribute('href', '/dashboard-demo/kafka/service/Kafka%20Order%20Service%20API/topics/order-topic'); - await expect(page.getByLabel('Offset')).toHaveText('0'); - await expect(page.getByRole('region', { name: 'Meta' }).getByLabel('Content Type')).toHaveText('application/json'); - await expect(page.getByLabel('Key Type')).toHaveText('-'); - await expect(page.getByLabel('Key Type')).not.toBeEmpty(); - await expect(page.getByLabel('Client')).toHaveText('mokapi-script'); + const meta = page.getByRole('region', { name: 'Meta' }); + await expect(meta.getByLabel('Kafka Key')).toHaveText('random-message-1'); + await expect(meta.getByLabel('Kafka Topic')).toHaveText('order-topic'); + await expect(meta.getByLabel('Kafka Topic')).toHaveAttribute('href', '/dashboard-demo/kafka/service/Kafka%20Order%20Service%20API/topics/order-topic'); + await expect(meta.getByLabel('Offset')).toHaveText('0'); + await expect(meta.getByLabel('Content Type')).toHaveText('application/json'); + await expect(meta.getByLabel('Key Type')).toHaveText('-'); + await expect(meta.getByLabel('Key Type')).not.toBeEmpty(); + await expect(meta.getByLabel('Client')).toHaveText('mokapi-script'); await test.step('Verify Producer Script', async () => { await page.getByLabel('Client').getByRole('link').click(); - await expect(page.getByLabel('URL')).toHaveText(/kafka.ts$/); + const info = page.getByRole('region', { name: 'Info' }); + await expect(info.getByLabel('URL')).toHaveText(/kafka.ts$/); await page.goBack(); }) diff --git a/webui/e2e/dashboard-demo/ldap.spec.ts b/webui/e2e/dashboard-demo/ldap.spec.ts index 974d8178a..f04edbd1f 100644 --- a/webui/e2e/dashboard-demo/ldap.spec.ts +++ b/webui/e2e/dashboard-demo/ldap.spec.ts @@ -12,11 +12,12 @@ test('Visit LDAP Testserver', async ({ page }) => { await test.step('Verify service info', async () => { - await expect(page.getByLabel('Name')).toHaveText('HR Employee Directory'); - await expect(page.getByLabel('Version')).toHaveText('1.0.0'); - await expect(page.getByLabel('Contact')).not.toBeVisible(); - await expect(page.getByLabel('Type of API')).toHaveText('LDAP'); - await expect(page.getByLabel('Description')).toHaveText('LDAP server for internal employee contact information.'); + const info = page.getByRole('region', { name: 'Info' }) + await expect(info.getByLabel('Name')).toHaveText('HR Employee Directory'); + await expect(info.getByLabel('Version')).toHaveText('1.0.0'); + await expect(info.getByLabel('Contact')).not.toBeVisible(); + await expect(info.getByLabel('Type of API')).toHaveText('LDAP'); + await expect(info.getByLabel('Description')).toHaveText('LDAP server for internal employee contact information.'); }); diff --git a/webui/e2e/dashboard-demo/mail.spec.ts b/webui/e2e/dashboard-demo/mail.spec.ts index d12ce670b..8d9a7467c 100644 --- a/webui/e2e/dashboard-demo/mail.spec.ts +++ b/webui/e2e/dashboard-demo/mail.spec.ts @@ -85,20 +85,22 @@ test('Visit Mail Server', async ({ page }) => { await test.step('Verify Mail Reset Your Password', async () => { await mails.locator('tbody tr').click(); - await expect(page.getByLabel('Subject')).toHaveText('Reset Your Password'); - await expect(page.getByLabel('Service', { exact: true })).toHaveText('Mail Server'); - await expect(page.getByLabel('Service', { exact: true }).getByRole('link')).toHaveAttribute('href', '/dashboard-demo/mail/service/Mail%20Server'); - await expect(page.getByLabel('From')).not.toBeEmpty(); - await expect(page.getByLabel('From')).toHaveText('zzz@example.com'); - await expect(page.getByLabel('To', { exact: true })).toHaveText('Bob Miller '); + const info = page.getByRole('region', { name: 'Info' }) + await expect(info.getByLabel('Subject')).toHaveText('Reset Your Password'); + await expect(info.getByLabel('Service', { exact: true })).toHaveText('Mail Server'); + await expect(info.getByLabel('Service', { exact: true }).getByRole('link')).toHaveAttribute('href', '/dashboard-demo/mail/service/Mail%20Server'); + await expect(info.getByLabel('From')).not.toBeEmpty(); + await expect(info.getByLabel('From')).toHaveText('zzz@example.com'); + await expect(info.getByLabel('To', { exact: true })).toHaveText('Bob Miller '); const body = page.getByRole('region', { name: 'Body' }); await expect(body.getByRole('heading')).toHaveText('Reset Your Password'); await expect(body).toContainText('Hello John Doe,'); - await expect(page.getByLabel('Content-Type')).toHaveText('text/html; charset=utf-8'); - await expect(page.getByLabel('Encoding')).toHaveText('quoted-printable'); - await expect(page.getByLabel('Message-ID')).not.toBeEmpty(); + const footer = page.getByRole('region', { name: 'Footer' }); + await expect(footer.getByLabel('Content-Type')).toHaveText('text/html; charset=utf-8'); + await expect(footer.getByLabel('Encoding')).toHaveText('quoted-printable'); + await expect(footer.getByLabel('Message-ID')).not.toBeEmpty(); }); }); @@ -126,12 +128,13 @@ test('Visit Mail Server', async ({ page }) => { await test.step('Verify Mail Check Out Our New Arrivals!', async () => { await mails.locator('tbody tr').click(); - await expect(page.getByLabel('Subject')).toHaveText('Check Out Our New Arrivals!'); - await expect(page.getByLabel('Service', { exact: true })).toHaveText('Mail Server'); - await expect(page.getByLabel('Service', { exact: true }).getByRole('link')).toHaveAttribute('href', '/dashboard-demo/mail/service/Mail%20Server'); - await expect(page.getByLabel('From')).not.toBeEmpty(); - await expect(page.getByLabel('From')).toHaveText('Bob Miller '); - await expect(page.getByLabel('To', { exact: true })).toHaveText('Alice Johnson '); + const info = page.getByRole('region', { name: 'Info' }) + await expect(info.getByLabel('Subject')).toHaveText('Check Out Our New Arrivals!'); + await expect(info.getByLabel('Service', { exact: true })).toHaveText('Mail Server'); + await expect(info.getByLabel('Service', { exact: true }).getByRole('link')).toHaveAttribute('href', '/dashboard-demo/mail/service/Mail%20Server'); + await expect(info.getByLabel('From')).not.toBeEmpty(); + await expect(info.getByLabel('From')).toHaveText('Bob Miller '); + await expect(info.getByLabel('To', { exact: true })).toHaveText('Alice Johnson '); const body = page.getByRole('region', { name: 'Body' }); await expect(body.getByRole('heading', { level: 1 })).toHaveText('New Arrivals Just Landed!'); diff --git a/webui/e2e/dashboard-demo/petstore.spec.ts b/webui/e2e/dashboard-demo/petstore.spec.ts index f6881c83c..23d7b8e0e 100644 --- a/webui/e2e/dashboard-demo/petstore.spec.ts +++ b/webui/e2e/dashboard-demo/petstore.spec.ts @@ -11,11 +11,13 @@ test('Visit Petstore Demo', async ({ page }) => { await page.getByText('Swagger Petstore').click(); await test.step('Verify service info', async () => { - await expect(page.getByLabel('Name')).toHaveText('Swagger Petstore'); - await expect(page.getByLabel('Version')).toHaveText('1.0.0'); - await expect(page.getByLabel('Version')).toHaveText('1.0.0'); - await expect(page.getByLabel('Contact').getByRole('link')).toHaveAttribute('href', 'mailto:apiteam@swagger.io'); - await expect(page.getByLabel('Type of API')).toHaveText('HTTP'); + + const info = page.getByRole('region', { name: 'Info' }); + await expect(info.getByLabel('Name')).toHaveText('Swagger Petstore'); + await expect(info.getByLabel('Version')).toHaveText('1.0.0'); + await expect(info.getByLabel('Version')).toHaveText('1.0.0'); + await expect(info.getByLabel('Contact').getByRole('link')).toHaveAttribute('href', 'mailto:apiteam@swagger.io'); + await expect(info.getByLabel('Type of API')).toHaveText('HTTP'); const description = page.getByLabel('Description'); await expect(description).toContainText('This is a sample server Petstore server.'); diff --git a/webui/e2e/documentation/configuration.spec.ts b/webui/e2e/documentation/configuration.spec.ts index 87ea1c34a..deb43f8fc 100644 --- a/webui/e2e/documentation/configuration.spec.ts +++ b/webui/e2e/documentation/configuration.spec.ts @@ -2,19 +2,22 @@ import { expect, test } from "../models/fixture-website"; test('Visit Configuration', async ({ page, home }) => { await home.open() - await page.getByRole('navigation').getByRole('link', { name: 'Configuration' }).click() + await page.getByRole('navigation').getByRole('link', { name: 'Docs' }).click() + await page.getByRole('navigation').getByRole('region', { name: 'Configuration' }).getByRole('link', { name: 'Overview' }).click() await test.step('meta information are available', async () => { - await expect(page).toHaveURL('/docs/configuration') + await expect(page).toHaveURL('/docs/configuration/overview') await expect(page).toHaveTitle('Introduction to Mokapi Configuration | Static & Dynamic Setup Explained') await expect(page.locator('meta[name="description"]')).toHaveAttribute( 'content', 'Discover how to configure Mokapi using static files or dynamic updates. Learn startup options, hot-reloading, and flexible setup for your mocked APIs.' ) - await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://mokapi.io/docs/configuration/introduction') + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://mokapi.io/docs/configuration/overview') }) await test.step('navigation section providers', async () => { + await page.getByRole('button', { name: 'Dynamic' }).click() + const region = page.getByRole('region', { name: 'Dynamic' }) const link = page.getByRole('link', { name: 'File' }) await expect(region).toBeVisible() @@ -30,7 +33,7 @@ test('Visit Configuration', async ({ page, home }) => { await test.step('meta information are available', async () => { await expect(page).toHaveURL('/docs/configuration/dynamic/file') - await expect(page).toHaveTitle('File Provider | Mokapi Configuration') + await expect(page).toHaveTitle('File Provider - Configuration | Mokapi Docs') await expect(page.locator('meta[name="description"]')).toHaveAttribute( 'content', 'The file provider reads dynamic configuration from a single file or multiple files.' diff --git a/webui/e2e/documentation/guides.spec.ts b/webui/e2e/documentation/guides.spec.ts index 978fd013a..7f12c3ab6 100644 --- a/webui/e2e/documentation/guides.spec.ts +++ b/webui/e2e/documentation/guides.spec.ts @@ -3,16 +3,16 @@ import { config } from "./config" test('Visit Guides', async ({ page, home }) => { await home.open() - await page.getByRole('navigation').getByRole('link', { name: 'Guides' }).click() + await page.getByRole('navigation').getByRole('link', { name: 'Docs' }).click() await test.step('meta information are available', async () => { - await expect(page).toHaveURL('/docs/guides') - await expect(page).toHaveTitle('Getting Started with Mokapi | Mokapi Guides') + await expect(page).toHaveURL('/docs/welcome') + await expect(page).toHaveTitle('Getting Started with Mokapi | Mokapi Docs') await expect(page.locator('meta[name="description"]')).toHaveAttribute( 'content', 'Learn how to set up Mokapi to mock APIs and validate requests using OpenAPI or AsyncAPI. No account needed—free, open-source, and easy to use.' ) - await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://mokapi.io/docs/guides/welcome') + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://mokapi.io/docs/welcome') }) await test.step('navigation is open', async () => { @@ -32,42 +32,43 @@ test('Visit Guides', async ({ page, home }) => { await test.step('click on Welcome change to canonical url', async () => { await page.getByRole('link', { name: 'Welcome' }).click() - await expect(page).toHaveURL('/docs/guides/welcome') + await expect(page).toHaveURL('/docs/welcome') }) await test.step('navigation collapse works', async () => { - await page.getByRole('link', { name: 'HTTP', exact: true }).click() - await expect(page.getByRole('link', { name: 'Quick Start', exact: true })).toBeVisible() + const nav = page.getByRole('navigation', { name: 'Sidebar' }); + await nav.getByRole('button', { name: 'HTTP', exact: true }).click() + await expect(nav.getByRole('link', { name: 'Quick Start', exact: true })).toBeVisible() - await page.getByRole('button', { name: 'Get Started', exact: true }).click() - const getStarted = page.getByRole('region', { name: 'Get Started' }) - await expect(getStarted.getByRole('link', { name: 'Installation' })).toBeVisible() - await expect(page.getByRole('region', { name: 'HTTP' }).getByRole('link', { name: 'Quick Start' })).toBeVisible() + await nav.getByRole('button', { name: 'Mail', exact: true }).click() + const mail = nav.getByRole('region', { name: 'Mail' }) + await expect(mail.getByRole('link', { name: 'Clients' })).toBeVisible() + await expect(nav.getByRole('region', { name: 'HTTP' }).getByRole('link', { name: 'Quick Start' })).toBeVisible() - await page.getByRole('button', { name: 'Get Started'}).click() - await expect(getStarted.getByRole('link', { name: 'Installation' })).not.toBeVisible() + await nav.getByRole('button', { name: 'Mail'}).click() + await expect(mail.getByRole('link', { name: 'Clients' })).not.toBeVisible() }) await test.step('visit HTTP Quick Start page', async () => { await page.getByRole('region', { name: 'HTTP' }).getByRole('link', { name: 'Quick Start' }).click() await test.step('meta information are available', async () => { - await expect(page).toHaveURL('/docs/guides/http/quick-start') + await expect(page).toHaveURL('/docs/http/quick-start') await expect(page).toHaveTitle('HTTP Quick Start - Mock an HTTP API that don\'t exists yet') await expect(page.locator('meta[name="description"]')).toHaveAttribute( 'content', 'A quick tutorial how to run Swagger\'s Petstore in Mokapi' ) - await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://mokapi.io/docs/guides/http/quick-start') + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://mokapi.io/docs/http/quick-start') }) await test.step('navigation is open', async () => { - await expect(page.getByRole('navigation', { name: 'sidebar' }).getByRole('link', { name: 'HTTP', exact: true })).not.toHaveCSS('color', config.colorLinkActive) + await expect(page.getByRole('navigation', { name: 'sidebar' }).getByRole('button', { name: 'HTTP', exact: true })).not.toHaveCSS('color', config.colorLinkActive) const link = page.getByRole('link', { name: 'Quick Start' }) await expect(link).toBeVisible() await expect(link).toHaveCSS('color', config.colorLinkActive) - await expect(page.getByRole('region', { name: 'Get Started'})).not.toBeVisible() + await expect(page.getByRole('region', { name: 'HTTP'})).toBeVisible() await expect(page.getByRole('region', { name: 'Kafka'})).not.toBeVisible() await expect(page.getByRole('region', { name: 'LDAP'})).not.toBeVisible() await expect(page.getByRole('region', { name: 'SMTP'})).not.toBeVisible() diff --git a/webui/e2e/documentation/resources.spec.ts b/webui/e2e/documentation/resources.spec.ts index 513522245..fb2491692 100644 --- a/webui/e2e/documentation/resources.spec.ts +++ b/webui/e2e/documentation/resources.spec.ts @@ -5,12 +5,12 @@ test('Visit Guides', async ({ page, home }) => { await page.getByRole('navigation').getByRole('link', { name: 'Resources' }).click() await test.step('meta information are available', async () => { - await expect(page).toHaveURL('/docs/resources') + await expect(page).toHaveURL('/resources') await expect(page).toHaveTitle('Explore Mokapi Resources: Tutorials, Examples, and Blog Articles') await expect(page.locator('meta[name="description"]')).toHaveAttribute( 'content', "Explore Mokapi's resources including tutorials, examples, and blog articles. Learn to mock APIs, validate schemas, and streamline your development." ) - await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://mokapi.io/docs/resources') + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://mokapi.io/resources') }) }) \ No newline at end of file diff --git a/webui/e2e/header.dashboard.spec.ts b/webui/e2e/header.dashboard.spec.ts index 39aaf7185..625f02fc3 100644 --- a/webui/e2e/header.dashboard.spec.ts +++ b/webui/e2e/header.dashboard.spec.ts @@ -7,18 +7,16 @@ test('header in dashboard', async ({ dashboard }) => { const links = dashboard.header.getNavLinks() await expect(links.nth(0)).toHaveText('Dashboard') if (process.env.CI) { - await expect(links.nth(1)).toHaveText('Guides') - await expect(links.nth(2)).toHaveText('Configuration') - await expect(links.nth(3)).toHaveText('JavaScript API') - await expect(links.nth(4)).toHaveText('Resources') - await expect(links.nth(5)).toHaveText('References') + await expect(links.nth(1)).toHaveText('Docs') + await expect(links.nth(2)).toHaveText('Resources') } else { - await expect(links.nth(1)).toHaveText('Dashboard') - await expect(links.nth(2)).toHaveText('Guides') - await expect(links.nth(3)).toHaveText('Configuration') - await expect(links.nth(4)).toHaveText('JavaScript API') - await expect(links.nth(5)).toHaveText('Resources') - await expect(links.nth(6)).toHaveText('References') + await expect(links.nth(1)).toHaveText('HTTP') + await expect(links.nth(2)).toHaveText('Kafka') + await expect(links.nth(3)).toHaveText('LDAP') + await expect(links.nth(4)).toHaveText('Email') + await expect(links.nth(5)).toHaveText('Dashboard') + await expect(links.nth(6)).toHaveText('Docs') + await expect(links.nth(7)).toHaveText('Resources') } }) diff --git a/webui/e2e/header.website.spec.ts b/webui/e2e/header.website.spec.ts index 9115af3c4..1cadf9f9a 100644 --- a/webui/e2e/header.website.spec.ts +++ b/webui/e2e/header.website.spec.ts @@ -1,16 +1,17 @@ -import { test, expect } from './models/fixture-dashboard' +import { test, expect } from './models/fixture-dashboard'; test('header in dashboard', async ({ page }) => { - await page.goto('/home') + await page.goto('/home'); await test.step("navigation links", async () => { const links = page.getByRole('banner').getByRole('navigation').getByRole('link'); - await expect(links.nth(0)).toHaveAccessibleDescription('Mokapi home') - await expect(links.nth(1)).toHaveText('Dashboard') - await expect(links.nth(2)).toHaveText('Guides') - await expect(links.nth(3)).toHaveText('Configuration') - await expect(links.nth(4)).toHaveText('JavaScript API') - await expect(links.nth(5)).toHaveText('Resources') - await expect(links.nth(6)).toHaveText('References') + await expect(links.nth(0)).toHaveAccessibleDescription('Mokapi home'); + await expect(links.nth(1)).toHaveText('HTTP'); + await expect(links.nth(2)).toHaveText('Kafka'); + await expect(links.nth(3)).toHaveText('LDAP'); + await expect(links.nth(4)).toHaveText('Email'); + await expect(links.nth(5)).toHaveText('Dashboard'); + await expect(links.nth(6)).toHaveText('Docs'); + await expect(links.nth(7)).toHaveText('Resources'); }) }) \ No newline at end of file diff --git a/webui/package-lock.json b/webui/package-lock.json index 55faab66d..aa7fdc2e8 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -25,12 +25,13 @@ "http-status-codes": "^2.3.0", "js-yaml": "^4.1.1", "kafkajs": "^2.2.4", - "ldapts": "^8.1.3", + "ldapts": "^8.1.6", + "markdown-it-container": "^4.0.0", "mime-types": "^3.0.2", "ncp": "^2.0.0", - "nodemailer": "^7.0.13", - "vue": "^3.5.27", - "vue-router": "^5.0.0", + "nodemailer": "^8.0.1", + "vue": "^3.5.28", + "vue-router": "^5.0.2", "vue3-ace-editor": "^2.2.4", "vue3-highlightjs": "^1.0.5", "vue3-markdown-it": "^1.0.10", @@ -38,11 +39,12 @@ "xml-formatter": "^3.6.7" }, "devDependencies": { - "@playwright/test": "^1.57.0", + "@playwright/test": "^1.58.2", "@rushstack/eslint-patch": "^1.15.0", "@types/js-yaml": "^4.0.9", - "@types/node": "^25.1.0", - "@vitejs/plugin-vue": "^6.0.3", + "@types/markdown-it-container": "^4.0.0", + "@types/node": "^25.2.3", + "@vitejs/plugin-vue": "^6.0.4", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.6.0", "@vue/tsconfig": "^0.8.1", @@ -91,12 +93,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -106,9 +108,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -885,13 +887,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", - "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.0" + "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -911,9 +913,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "dev": true, "license": "MIT" }, @@ -1337,26 +1339,33 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT", - "peer": 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==", "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, + "node_modules/@types/markdown-it-container": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it-container/-/markdown-it-container-4.0.0.tgz", + "integrity": "sha512-GmD8OECLfzPHv8VyvFRzslqdwXoDBJ2H40fxXFjrarbqvJZSB/BJKZXN5e3k7Mx7GQanSNzTYhzeS3H9o0gAOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/markdown-it": ">=14" + } + }, "node_modules/@types/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/mokapi": { "version": "0.29.1", @@ -1365,9 +1374,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", - "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1632,13 +1641,13 @@ } }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", - "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.53" + "@rolldown/pluginutils": "1.0.0-rc.2" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1705,39 +1714,39 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", - "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", + "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.27", - "entities": "^7.0.0", + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.28", + "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", - "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", + "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-core": "3.5.28", + "@vue/shared": "3.5.28" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", - "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", + "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.27", - "@vue/compiler-dom": "3.5.27", - "@vue/compiler-ssr": "3.5.27", - "@vue/shared": "3.5.27", + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.28", + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", @@ -1745,13 +1754,13 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", - "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", + "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-dom": "3.5.28", + "@vue/shared": "3.5.28" } }, "node_modules/@vue/devtools-api": { @@ -1858,53 +1867,53 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", - "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", + "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.27" + "@vue/shared": "3.5.28" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", - "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", + "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/reactivity": "3.5.28", + "@vue/shared": "3.5.28" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", - "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", + "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.27", - "@vue/runtime-core": "3.5.27", - "@vue/shared": "3.5.27", + "@vue/reactivity": "3.5.28", + "@vue/runtime-core": "3.5.28", + "@vue/shared": "3.5.28", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", - "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", + "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28" }, "peerDependencies": { - "vue": "3.5.27" + "vue": "3.5.28" } }, "node_modules/@vue/shared": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", - "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", + "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", "license": "MIT" }, "node_modules/@vue/tsconfig": { @@ -4817,13 +4826,12 @@ } }, "node_modules/ldapts": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.3.tgz", - "integrity": "sha512-kEU3GDh48ZymnyLGsFprai2v4r7Gyxe6niBlUUw3xnOGpq5O+XODmXJ8gBwbPIg35qt5cnYVC80NNSdAkb2dJg==", + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.6.tgz", + "integrity": "sha512-sofxzGEPRBvubSrdmly0mmUwjXHPfTbO51KLAUzuO4sHWwy+r0G6FwaLWWDwTPRpjJFkMdLId5BeRUHksUH4yA==", "license": "MIT", "dependencies": { - "strict-event-emitter-types": "2.0.0", - "whatwg-url": "15.1.0" + "strict-event-emitter-types": "2.0.0" }, "engines": { "node": ">=20" @@ -4970,6 +4978,12 @@ "markdown-it": "*" } }, + "node_modules/markdown-it-container": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/markdown-it-container/-/markdown-it-container-4.0.0.tgz", + "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", @@ -5270,9 +5284,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", - "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -5825,13 +5839,13 @@ } }, "node_modules/playwright": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", - "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -5844,9 +5858,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", - "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5976,6 +5990,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6920,18 +6935,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -7147,18 +7150,17 @@ } }, "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=18.12.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/unplugin-utils": { @@ -7367,16 +7369,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.5.27", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", - "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", + "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.27", - "@vue/compiler-sfc": "3.5.27", - "@vue/runtime-dom": "3.5.27", - "@vue/server-renderer": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-sfc": "3.5.28", + "@vue/runtime-dom": "3.5.28", + "@vue/server-renderer": "3.5.28", + "@vue/shared": "3.5.28" }, "peerDependencies": { "typescript": "*" @@ -7425,9 +7427,9 @@ } }, "node_modules/vue-router": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.0.tgz", - "integrity": "sha512-xWHlps4o1ScODWqvyapl0v1uGy0g7ozmsTSO/dguyGb/9RL6oSU2HfN/8oMXnoFOH1BuTaAkbiOz4OWdkfjcZg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz", + "integrity": "sha512-YFhwaE5c5JcJpNB1arpkl4/GnO32wiUWRB+OEj1T0DlDxEZoOfbltl2xEwktNU/9o1sGcGburIXSpbLpPFe/6w==", "license": "MIT", "dependencies": { "@babel/generator": "^7.28.6", @@ -7444,7 +7446,7 @@ "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.15", - "unplugin": "^2.3.11", + "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.8.2" }, @@ -7452,7 +7454,7 @@ "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "@pinia/colada": "^0.18.1", + "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.17", "pinia": "^3.0.4", "vue": "^3.5.0" @@ -7544,15 +7546,6 @@ "markdown-it-toc-done-right": "^4.2.0" } }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -7568,19 +7561,6 @@ "node": ">=20" } }, - "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", - "license": "MIT", - "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/webui/package.json b/webui/package.json index 9b188f55c..bcc620d7c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -34,12 +34,13 @@ "http-status-codes": "^2.3.0", "js-yaml": "^4.1.1", "kafkajs": "^2.2.4", - "ldapts": "^8.1.3", + "ldapts": "^8.1.6", + "markdown-it-container": "^4.0.0", "mime-types": "^3.0.2", "ncp": "^2.0.0", - "nodemailer": "^7.0.13", - "vue": "^3.5.27", - "vue-router": "^5.0.0", + "nodemailer": "^8.0.1", + "vue": "^3.5.28", + "vue-router": "^5.0.2", "vue3-ace-editor": "^2.2.4", "vue3-highlightjs": "^1.0.5", "vue3-markdown-it": "^1.0.10", @@ -47,11 +48,12 @@ "xml-formatter": "^3.6.7" }, "devDependencies": { - "@playwright/test": "^1.57.0", + "@playwright/test": "^1.58.2", "@rushstack/eslint-patch": "^1.15.0", "@types/js-yaml": "^4.0.9", - "@types/node": "^25.1.0", - "@vitejs/plugin-vue": "^6.0.3", + "@types/markdown-it-container": "^4.0.0", + "@types/node": "^25.2.3", + "@vitejs/plugin-vue": "^6.0.4", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.6.0", "@vue/tsconfig": "^0.8.1", diff --git a/webui/public/.htaccess b/webui/public/.htaccess index 42118ce05..06b37a4c7 100644 --- a/webui/public/.htaccess +++ b/webui/public/.htaccess @@ -14,23 +14,29 @@ RewriteCond %{SERVER_PORT} !^443$ RewriteRule (.+) https://%{HTTP_HOST}/$1 [L,R] - # Redirect exactly /docs/examples to /docs/resources - RewriteRule ^docs/examples/?$ /docs/resources [R=301,L] + # Redirect exactly /docs/examples to /resources + RewriteRule ^docs/examples/?$ /resources [R=301,L] - # Redirect anything under /docs/examples/ to /docs/resources/ - RewriteRule ^docs/examples/(.*)$ /docs/resources/$1 [R=301,L] + # Redirect anything under /docs/examples/ to /resources/ + RewriteRule ^docs/examples/(.*)$ /resources/$1 [R=301,L] - # Redirect exactly /docs/blogs to /docs/resources - RewriteRule ^docs/blogs/?$ /docs/resources/blogs [R=301,L] + # Redirect exactly /docs/blogs to /resources + RewriteRule ^docs/blogs/?$ /resources/blogs [R=301,L] - # Redirect anything under /docs/blogs/ to /docs/resources/blogs/ - RewriteRule ^docs/blogs/(.*)$ /docs/resources/blogs/$1 [R=301,L] + # Redirect anything under /docs/blogs/ to /resources/blogs/ + RewriteRule ^docs/blogs/(.*)$ /resources/blogs/$1 [R=301,L] + + # Redirect anything under /docs/resources/ to /resources/ + RewriteRule ^docs/resources/(.*)$ /resources/$1 [R=301,L] + + # Redirect anything under /docs/guides/ to /docs/ + RewriteRule ^docs/guides/(.*)$ /docs/$1 [R=301,L] # Redirect exactly /smtp to /mail RewriteRule ^smtp/?$ /mail [R=301,L] - # Redirect anything under /docs/guides/smtp to /docs/guides/mail/ - RewriteRule ^docs/guides/smtp/?(.*)$ /docs/guides/mail/$1 [R=301,L] + # Redirect anything under /docs/guides/smtp to /docs/mail/ + RewriteRule ^docs/guides/smtp/?(.*)$ /docs/mail/$1 [R=301,L] # rewrite for bots RewriteCond %{HTTP_USER_AGENT} googlebot|bingbot|Seobility|yandex|baiduspider|facebookexternalhit|twitterbot|rogerbot|linkedinbot|embedly|quora\ link\ preview|showyoubot|outbrain|pinterest\/0\.|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp|redditbot|applebot|flipboard|tumblr|bitlybot|skypeuripreview|nuzzel|discordbot|google\ page\ speed|qwantify|bitrix\ link\ preview|xing-contenttabreceiver|google-inspectiontool|chrome-lighthouse|telegrambot|amazonbot [NC] diff --git a/webui/public/recording.png b/webui/public/recording.png new file mode 100644 index 000000000..ec9bbb654 Binary files /dev/null and b/webui/public/recording.png differ diff --git a/webui/src/assets/home.css b/webui/src/assets/home.css index 37993b962..9a7216219 100644 --- a/webui/src/assets/home.css +++ b/webui/src/assets/home.css @@ -15,7 +15,7 @@ } .home h3 { line-height: 1.6; - font-size: 1.5rem; + font-size: 1.3rem; } .home .hero-title { margin-top: 5rem; @@ -184,29 +184,7 @@ section.feature button { margin-top: 1rem; margin-bottom: 1rem; } -footer { - padding-top: 3rem; - padding-bottom: 2rem; - background-color: var(--footer-background); - opacity: 0.8; - font-size: 0.9rem; -} -footer h3 { - font-size: 0.9rem; - margin-bottom: 1.2rem; - font-weight: 600; - text-transform: uppercase; -} -footer ul { - list-style-type: none; - padding-left: 0; -} -footer ul li { - margin-bottom: 0.5rem; -} -footer a { - text-decoration: none; -} + .home code { font-size: 14px; font-family: Menlo,Monaco,Consolas,"Courier New",monospace !important; @@ -230,4 +208,9 @@ footer a { .home img:not(.no-dialog) { cursor: pointer; +} + +.home .text { + max-width: 750px; + margin-inline: auto; } \ No newline at end of file diff --git a/webui/src/assets/main.css b/webui/src/assets/main.css index 26db94c7c..d2f013553 100644 --- a/webui/src/assets/main.css +++ b/webui/src/assets/main.css @@ -27,17 +27,25 @@ body { } #app { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: auto; + grid-template-areas: + "hd" + "main" + "ft"; min-height: 100%; } -#app:has(header .promo-banner) { - --header-height: calc(4rem + 22px); +header { + grid-area: hd; } main { - flex: 1; + grid-area: main; +} + +#app:has(header .promo-banner) { + --header-height: calc(4rem + 22px); } h1 { diff --git a/webui/src/assets/nav.css b/webui/src/assets/nav.css index a598079b1..3692f585c 100644 --- a/webui/src/assets/nav.css +++ b/webui/src/assets/nav.css @@ -1,13 +1,18 @@ .nav-link { color: var(--nav-color); } + .nav-link.disabled { color: var(--nav-color); } -.nav-link:not(.disabled):hover, .nav-link:not(.disabled):focus { + +.nav-link:not(.disabled):hover, +.nav-link:not(.disabled):focus { color: var(--nav-color-active); } -.nav-pills .nav-link.active, .nav-pills .show > .nav-link { + +.nav-pills .nav-link.active, +.nav-pills .show>.nav-link { color: var(--nav-color-active); background-color: var(--color-background); } @@ -21,7 +26,99 @@ header .navbar-collapse.show .nav-item .nav-link { padding-top: 7px; padding-bottom: 7px; } + header .navbar-collapse ul.dropdown-menu { padding-top: 0; padding-bottom: 0; +} + +nav.nav-tree { + line-height: 1.5; + font-size: 0.95rem; +} + +.nav-tree { + + .chapter > section > ul { + position: relative; + margin-left: 0.6rem; + } + + .chapter > section > ul:before { + content: ''; + background-color: var(--color-background-mute); + width: 1px; + inset-block: 0.25rem; + position: absolute; + } + + .nav, + .nav .btn { + font-size: var(--bs-nav-link-font-size) + } + + .nav-title { + font-size: var(--bs-nav-link-font-size) + } + + .chapter > section > ul > .nav-item { + padding-left: 12px; + } + + .nav-item a, + .nav-item .chapter>div { + padding-top: 6px; + padding-bottom: 6px; + } + + .nav-item .chapter>div a, .btn-link { + padding-top: 0; + padding-bottom: 0; + } + + .nav .nav-link { + padding-left: 0; + } + + @media only screen and (max-width: 768px) { + .nav a { + padding-top: 10px; + padding-bottom: 10px; + } + } + + .nav .active { + color: var(--nav-color-active); + } + + .nav button { + color: var(--color-text); + padding-inline: 0; + text-decoration: none; + border: 0; + } + + .nav button:hover { + color: var(--nav-color-active); + } + + button[aria-expanded=false] .bi-chevron-down { + display: none; + } + + button[aria-expanded=true] .bi-chevron-right { + display: none; + } + + .image.shadow { + border-radius: 8px; + box-shadow: + 0px 10px 0.5rem rgba(0, 0, 0, 0.3), + 10px 0px 0.5rem rgba(0, 0, 0, 0.3); + } + + .headline>div { + padding: 13px 0; + font-weight: 600; + } } \ No newline at end of file diff --git a/webui/src/assets/tabs.css b/webui/src/assets/tabs.css index 0cdf5b51e..e2a914c38 100644 --- a/webui/src/assets/tabs.css +++ b/webui/src/assets/tabs.css @@ -69,7 +69,45 @@ .tab-pane { padding: 0.8rem; padding-top: 1.6rem; - } +} + +.tabs { + border: none; + padding: 16px 0; + + .nav-tabs { + border: none; + } + + > .nav-tabs button { + border: none; + background-color: transparent; + padding: 0 8px 4px; + } + + > .nav-tabs button[role="tab"].active, > .nav-tabs button[role="tab"]:hover { + border-color: var(--code-tabs-border-color-active); + border-bottom-width: 3px; + border-bottom-style: solid; + margin-bottom: -3px; + } + + > .nav-tabs .tabs-border { + width: 100%; + height: 2px; + background-color: var(--tabs-border-color); + margin: 0 auto; + } + + .tab-content { + margin-top: 20px; + } + + .tab-pane { + padding: 0; + } +} + .code { background-color: var(--code-background); color: var(--code-color); @@ -123,6 +161,6 @@ .tabs-border { width: 100%; height: 3px; - background-color: var(--code-tab-border-color); + background-color: var(--code-tabs-border-color); margin: 0 auto; } \ No newline at end of file diff --git a/webui/src/assets/vars.css b/webui/src/assets/vars.css index 5af3b12ad..55b16509f 100644 --- a/webui/src/assets/vars.css +++ b/webui/src/assets/vars.css @@ -98,14 +98,19 @@ --badge-background: #eabaabff; + --tabs-border-color: rgba(255, 255, 255, 0.1); + --code-background: #0d1117; --code-color: #fff; --code-tabs-color: #d3d4d5; --code-tabs-color-active: #eabaabff; --code-tabs-border-color-active: #eabaabff; - --code-tab-border-color: #3c424b; + --code-tabs-border-color: #3c424b; --code-control-color-active: #eabaabff; + --blockquote-border-color: #eabaabff; + --blockquote-background-color: var(--color-background-soft); + --footer-background: #282b33; } @@ -180,13 +185,18 @@ --badge-background: rgb(8, 109, 215); + --tabs-border-color: rgba(0, 0, 0, 0.1); + --code-background: #0d1117; --code-color: rgb(255, 255, 255);; --code-tabs-color: rgb(255,255,255); --code-tabs-color-active: rgb(255, 255, 255); --code-tabs-border-color-active: rgb(8, 109, 215); - --code-tab-border-color: #3c424b; + --code-tabs-border-color: #3c424b; --code-control-color-active: rgb(8, 109, 215); + --blockquote-border-color: rgb(8, 109, 215); + --blockquote-background-color: #f8f9fa; + --footer-background: rgb(244 244 246); } \ No newline at end of file diff --git a/webui/src/components/Footer.vue b/webui/src/components/Footer.vue index 3d8390bc9..58ff29a48 100644 --- a/webui/src/components/Footer.vue +++ b/webui/src/components/Footer.vue @@ -40,22 +40,22 @@

Guides

  • - + Installation
  • - + Running
  • - + Test Data
  • - + Dashboard
  • @@ -69,22 +69,22 @@

    Resources & Blogs

    • - + Mock and Test REST APIs Using OpenAPI with Mokapi
    • - + Simulate Kafka Topics with AsyncAPI and Mokapi for Reliable Event Testing
    • - + Test Email Workflows with a Mock SMTP Server Using Mokapi and Playwright
    • - + Mock LDAP Authentication in Node.js for Seamless Testing with Mokapi
    • @@ -127,4 +127,31 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/webui/src/components/Header.vue b/webui/src/components/Header.vue index e661ee594..46c345101 100644 --- a/webui/src/components/Header.vue +++ b/webui/src/components/Header.vue @@ -9,9 +9,11 @@ import { Modal } from 'bootstrap'; import type { AppInfoResponse } from '@/types/dashboard'; import { useDashboard } from '@/composables/dashboard'; import { usePromo } from '@/composables/promo'; +import NavDocItem from './NavDocItem.vue'; const isDashboard = import.meta.env.VITE_DASHBOARD === 'true' const useDemo = import.meta.env.VITE_USE_DEMO === 'true' +const isWebsite = import.meta.env.VITE_WEBSITE === 'true' const promo = usePromo() let appInfo: AppInfoResponse | null = null const query = ref('') @@ -29,10 +31,12 @@ if (isDashboard) { const isDark = document.documentElement.getAttribute('data-theme') == 'dark'; const nav = inject('nav')! const route = useRoute() -const { resolve } = useFileResolver() -const levels = computed(() => { - const { levels } = resolve(nav, route) - return levels + const { getBreadcrumb, getEntryBySource } = useFileResolver(); +const navHeaders = computed(() => { + return Object.keys(nav).map(x => Object.assign({ label: x }, nav[x])) as DocEntry[] +}) +const breadcrumb = computed(() => { + return getBreadcrumb(nav, route) }) const visible = ref(false) @@ -55,41 +59,30 @@ router.beforeEach(() => { } }) -function showInHeader(item: any): boolean{ - return typeof item !== 'string' -} -function formatParam(label: any): string { - return label.toString().toLowerCase().split(' ').join('-').split('/').join('-') -} -function hasChildren(item: DocEntry | string) { - if (typeof item === 'string') { - return false - } - const entry = item - if ('file' in entry || 'component' in entry) { - return false - } - return true -} -function isActive(...levels: any[]) { - for (let i = 0; i < levels.length; i++) { - if (!matchLevel(levels[i], i + 1)) { - return false - } - } - return true +function hasChildren(item: DocEntry) { + if (item.hideInNavigation) { + return false + } + if (!item.items) { + return false + } + return item.items.length > 0 } -function matchLevel(label: any, level: number) { - if (!levels.value) { - return false - } - if (level > levels.value.length){ - return false - } - return label.toString().toLowerCase() == levels.value[level - 1]!.toLowerCase() +function isActive(entry: DocEntry) { + if (!breadcrumb.value) { + return false + } + if (entry.type === 'root') { + return breadcrumb.value[0]?.label === entry.label + } + return breadcrumb.value.find(x => x === entry) !== undefined; } -function getId(name: any) { - return name.toString().replaceAll("/", "-").replaceAll(" ", "-") +let docIndex = 0; +function getId(entry: DocEntry) { + if (!entry || !entry.label) { + return `doc-${docIndex++}` + } + return entry.label.toString().replaceAll("/", "-").replaceAll(" ", "-") } function isExpanded(item: DocEntry | string) { if (typeof item === 'string') { @@ -97,18 +90,8 @@ function isExpanded(item: DocEntry | string) { } return item.expanded || false } -function showItem(name: string | number, item: DocConfig | DocEntry | string) { - if (!levels.value) { - return false - } - if (typeof item == 'string' && levels.value[0] != name) { - return true - } - const entry = item - if ('hideInNavigation' in entry) { - return !entry.hideInNavigation - } - return true +function showItem(entry: DocEntry) { + return !entry.hideInNavigation } const files = inject>('files')! @@ -116,11 +99,10 @@ const files = inject>('files')! // Transform files into an array of { name, content } const documents = Object.entries(files).map(([path, content]) => { const doc = parseMarkdown(content) - const url = getUrlPath(path.replace('/src/assets/docs/', '')) return { name: doc.meta.title, description: doc.meta.description, - path: url, + path: getEntryBySource(nav, path.replace('/src/assets/docs/', ''))?.path, content: doc.content } }).filter(doc => doc.path) @@ -136,10 +118,7 @@ const filtered = computed(() => { result = fuse.search(query.value).map(({ item }) => { return { name: item.name, - params: item.path!.reduce((obj, value, index) => { - obj[`level${index+1}`] = formatParam(value) - return obj - }, {} as Record), + path: item.path, description: item.description } }) @@ -150,35 +129,6 @@ onUnmounted(() => { window.removeEventListener('keydown', shortcutHandler) }) -function getUrlPath(filePath: string, cfg?: DocEntry): string[] | undefined { - if (cfg) { - if (!cfg.items) { - return undefined - } - for (const [key, item] of Object.entries(cfg.items)) { - if (item === filePath) { - return [key] - } - if (typeof item !== 'string') { - const path = getUrlPath(filePath, item) - if (path) { - path.unshift(key) - return path - } - } - } - } else { - for (const name in nav) { - const path = getUrlPath(filePath, nav[name]) - if (path) { - path.unshift(name) - return path - } - } - } - return undefined -} - const SEQ_TIMEOUT = 1000 let lastKeyTime = 0 let awaitingSecondKey = false @@ -241,7 +191,7 @@ onMounted(() => { }) -function navigateAndClose(params: Record) { +function navigateAndClose(path: string) { if (document.activeElement instanceof HTMLElement) { // remove focus document.activeElement.blur(); @@ -252,7 +202,7 @@ function navigateAndClose(params: Record) { modalInstance?.hide(); modalEl.addEventListener('hidden.bs.modal', () => { - router.push({ name: 'docs', params: params }); + router.push({ path: path }); }, { once: true }); } function isDashboardDisplayed() { @@ -271,7 +221,7 @@ function isDashboardDisplayed() { Visit shop → -
    +
    v{{appInfo.data.version}} - @@ -406,7 +315,7 @@ function isDashboardDisplayed() {
    - +

    {{ item.name }}

    {{ item.description }}

    @@ -524,15 +433,10 @@ header .container-fluid { .navbar .nav .nav-link { padding-left: 0; } -.nav-item a, .subchapter .btn-link, .nav-item .chapter > div { +.nav-item a, .nav-item .btn-link { padding-top: 7px; padding-bottom: 7px; } - -header .navbar-collapse .nav-item .chapter > div a { - padding-top: 0; - padding-bottom: 0; -} .nav-item:has(.btn-link) { line-height: 1.5; } @@ -637,4 +541,8 @@ header .navbar-collapse .nav-item .chapter > div a { font-size: 16px; } } +.headline > div { + padding: 13px 0; + font-weight: 600; +} \ No newline at end of file diff --git a/webui/src/components/NavDocItem.vue b/webui/src/components/NavDocItem.vue new file mode 100644 index 000000000..a3cfc74f3 --- /dev/null +++ b/webui/src/components/NavDocItem.vue @@ -0,0 +1,80 @@ + + + \ No newline at end of file diff --git a/webui/src/components/dashboard/Config.vue b/webui/src/components/dashboard/Config.vue index 9aa184094..7df0068c7 100644 --- a/webui/src/components/dashboard/Config.vue +++ b/webui/src/components/dashboard/Config.vue @@ -60,17 +60,20 @@ const provider = computed(() => {
    - +
    diff --git a/webui/src/components/dashboard/Search.vue b/webui/src/components/dashboard/Search.vue index 4bdb77cff..a9d5496de 100644 --- a/webui/src/components/dashboard/Search.vue +++ b/webui/src/components/dashboard/Search.vue @@ -123,7 +123,10 @@ onMounted(async () => { if (queryText.value !== '') { await search() } else { - document.getElementById('search-input')?.focus(); + const btn = document.getElementById('search-input'); + if (btn && getComputedStyle(btn).visibility !== 'hidden') { + btn.focus(); + } } }) @@ -264,7 +267,7 @@ function facetTitle(s: string) {
  • (get OR post) AND pets – Combine multiple terms logically
- Learn more about Mokapi's search here + Learn more about Mokapi's search here
diff --git a/webui/src/components/dashboard/SourceView.vue b/webui/src/components/dashboard/SourceView.vue index a53b893d0..ead96826f 100644 --- a/webui/src/components/dashboard/SourceView.vue +++ b/webui/src/components/dashboard/SourceView.vue @@ -5,6 +5,13 @@ import { usePrettyBytes } from '@/composables/usePrettyBytes' import { VAceEditor } from 'vue3-ace-editor' import '@/ace-editor/ace-config' import HexEditor from '../HexEditor.vue' +import { Range } from 'ace-builds' +import { useRoute, useRouter } from '@/router' + +interface AceMouseEvent { + domEvent: MouseEvent + getDocumentPosition(): { row: number } +} const props = withDefaults(defineProps<{ source: Source @@ -26,6 +33,8 @@ const emit = defineEmits<{ (e: 'switch', value: 'preview' | 'binary'): void }>() +const route = useRoute(); +const router = useRouter(); const { getLanguage } = usePrettyLanguage() const { format } = usePrettyBytes() @@ -117,11 +126,105 @@ function switchCode() { preview.value?.classList.remove('active') emit('switch', 'binary') } -const onEditorInit = (editor: typeof VAceEditor) => { - editor.setOptions({ +const editor = ref(null) +let lineSelectionMarker: number | null = null // persistent marker id + +function updateSelectionDirect(startRow: number, endRow: number) { + if (!editor.value) return + const session = editor.value.session + + if (lineSelectionMarker !== null) { + session.removeMarker(lineSelectionMarker) + } + + lineSelectionMarker = session.addMarker( + new Range(startRow, 0, endRow, Infinity), + 'marker', + 'fullLine', + false // back layer + ) + + updateGutterMarker(startRow, endRow); +} + +function updateGutterMarker(startRow: number, endRow: number) { + if (!editor.value) return + + const session = editor.value.session + + // remove old annotation for simplicity + session.clearAnnotations() + + // prepare annotations array + const annotations = [] + for (let row = startRow; row <= startRow; row++) { + annotations.push({ + row, + column: 0, + text: `Line ${row+1}`, + type: 'info' // "error" | "warning" | "info" + }) + } + + session.setAnnotations(annotations) +} + +const startRow = ref() + +const onEditorInit = (e: typeof VAceEditor) => { + editor.value = e + e.setOptions({ readOnly: props.readonly }); + + applyHashSelection(); + + // Gutter drag logic + editor.value.on('guttermousedown', (e: AceMouseEvent) => { + let row = e.getDocumentPosition().row + let from: number + let to: number + if (e.domEvent.shiftKey && startRow.value) { + from = Math.min(startRow.value, row) + to = Math.max(startRow.value, row) + } else { + from = row + to = row + } + + // Update URL hash without triggering remount issues + let hash = from === to ? `#L${from + 1}` : `#L${from + 1}:L${to + 1}` + if (route.hash === hash) { + hash = '' + } + router.replace({ + name: route.name, + params: route.params, + hash + }) + updateSelectionDirect(from, to) + }) }; + +// Parse route.hash and update marker +function applyHashSelection() { + if (!editor.value) return + const m = route.hash.match(/L([0-9]+)(:L([0-9]+))?/) + if (!m) { + return + } + + startRow.value = Number(m[1]) - 1 + const endRow = Number(m[3] || m[1]) - 1 + updateSelectionDirect(startRow.value, endRow) +} + +watch( + () => route.hash, + () => { + applyHashSelection() + } +)