From 1e4ebdf1b07a14b407fe7849c1d3fc1bf0692460 Mon Sep 17 00:00:00 2001 From: maesi Date: Mon, 16 Feb 2026 23:10:39 +0100 Subject: [PATCH 01/11] fix: remove upload release notes --- .github/actions/build-release-notes/action.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/actions/build-release-notes/action.yml b/.github/actions/build-release-notes/action.yml index e50f4a591..2c522f1a7 100644 --- a/.github/actions/build-release-notes/action.yml +++ b/.github/actions/build-release-notes/action.yml @@ -13,8 +13,3 @@ runs: echo echo "$BODY" } > docs/release.md - - name: Upload UI artifact - uses: actions/upload-artifact@v4 - with: - name: release-notes - path: docs/release.md From 23fad135ce08222fbc70b25d4d5b7a250499613a Mon Sep 17 00:00:00 2001 From: marle3003 Date: Tue, 17 Feb 2026 13:01:33 +0100 Subject: [PATCH 02/11] fix(webui): decode email subject and addresses also in events --- api/handler_mail.go | 25 ++++---- providers/mail/smtp_handler_test.go | 94 +++++++++++++++++++---------- smtp/message.go | 14 ++++- smtp/message_test.go | 43 +++++++++++++ 4 files changed, 130 insertions(+), 46 deletions(-) create mode 100644 smtp/message_test.go diff --git a/api/handler_mail.go b/api/handler_mail.go index 50e9d4136..b80fa0a46 100644 --- a/api/handler_mail.go +++ b/api/handler_mail.go @@ -2,7 +2,6 @@ package api import ( "fmt" - "mime" "mokapi/media" "mokapi/providers/mail" "mokapi/runtime" @@ -413,6 +412,11 @@ func getRejectResponse(r *mail.Rule) *rejectResponse { } func toMessage(m *smtp.Message) *messageData { + subject, err := smtp.DecodeHeaderValue(m.Subject) + if err != nil { + log.Printf("failed to decode subject '%s': %v", m.Subject, err) + subject = m.Subject + } r := &messageData{ Server: m.Server, From: toAddress(m.From), @@ -423,7 +427,7 @@ func toMessage(m *smtp.Message) *messageData { MessageId: m.MessageId, InReplyTo: m.InReplyTo, Date: m.Date, - Subject: decodeSmtpValue(m.Subject), + Subject: subject, ContentType: m.ContentType, ContentTransferEncoding: m.ContentTransferEncoding, Body: m.Body, @@ -455,20 +459,15 @@ func toMessage(m *smtp.Message) *messageData { func toAddress(list []smtp.Address) []address { var r []address for _, a := range list { + name, err := smtp.DecodeHeaderValue(a.Name) + if err != nil { + log.Printf("failed to decode address '%v': %v", a.Name, err) + name = a.Name + } r = append(r, address{ - Name: decodeSmtpValue(a.Name), + Name: 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/providers/mail/smtp_handler_test.go b/providers/mail/smtp_handler_test.go index bfa0f14a0..c412e781f 100644 --- a/providers/mail/smtp_handler_test.go +++ b/providers/mail/smtp_handler_test.go @@ -5,7 +5,7 @@ import ( "encoding/base64" "mokapi/engine/enginetest" "mokapi/providers/mail" - "mokapi/runtime/events/eventstest" + "mokapi/runtime/events" "mokapi/smtp" "mokapi/smtp/smtptest" "testing" @@ -18,12 +18,12 @@ func TestHandler_ServeSMTP(t *testing.T) { testcases := []struct { name string config *mail.Config - test func(t *testing.T, h *mail.Handler, s *mail.Store, eh *eventstest.Handler) + test func(t *testing.T, h *mail.Handler, s *mail.Store, eh events.Handler) }{ { name: "no auth required", config: &mail.Config{}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMail(t, h, ctx) require.Equal(t, smtp.Ok, r.Result) @@ -39,7 +39,7 @@ func TestHandler_ServeSMTP(t *testing.T) { }, }, }, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMail(t, h, ctx) require.Equal(t, &smtp.AuthRequired, r.Result) @@ -52,7 +52,7 @@ func TestHandler_ServeSMTP(t *testing.T) { "alice@foo.bar": {Username: "alice", Password: "foo"}, }, }, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendLogin(t, h, ctx, "foo", "foo") require.Equal(t, &smtp.InvalidAuthCredentials, r.Result) @@ -65,7 +65,7 @@ func TestHandler_ServeSMTP(t *testing.T) { "alice@foo.bar": {Username: "alice", Password: "foo"}, }, }, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendLogin(t, h, ctx, "alice", "foo") require.Equal(t, &smtp.InvalidAuthCredentials, r.Result) @@ -79,7 +79,7 @@ func TestHandler_ServeSMTP(t *testing.T) { }, Settings: &mail.Settings{AllowUnknownSenders: false}, }, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMail(t, h, ctx) exp := smtp.AddressRejected @@ -95,7 +95,7 @@ func TestHandler_ServeSMTP(t *testing.T) { }, Settings: &mail.Settings{AllowUnknownSenders: true}, }, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMail(t, h, ctx) exp := smtp.Ok @@ -110,7 +110,7 @@ func TestHandler_ServeSMTP(t *testing.T) { "alice@foo.bar": {}, }, }, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMail(t, h, ctx) require.Equal(t, smtp.Ok, r.Result) @@ -119,7 +119,7 @@ func TestHandler_ServeSMTP(t *testing.T) { { name: "mail any is valid", config: &mail.Config{}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMail(t, h, ctx) require.Equal(t, smtp.Ok, r.Result) @@ -133,7 +133,7 @@ func TestHandler_ServeSMTP(t *testing.T) { "alice@foo.bar": {}, }, }, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendRcpt(t, h, ctx) exp := smtp.AddressRejected @@ -149,7 +149,7 @@ func TestHandler_ServeSMTP(t *testing.T) { "bob@foo.bar": {}, }, }, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendRcpt(t, h, ctx) require.Equal(t, smtp.Ok, r.Result) @@ -158,7 +158,7 @@ func TestHandler_ServeSMTP(t *testing.T) { { name: "rcpt any is valid", config: &mail.Config{Settings: &mail.Settings{AutoCreateMailbox: true}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendRcpt(t, h, ctx) require.Equal(t, smtp.Ok, r.Result) @@ -167,7 +167,7 @@ func TestHandler_ServeSMTP(t *testing.T) { { name: "no rcpt is valid", config: &mail.Config{Settings: &mail.Settings{AutoCreateMailbox: false}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendRcpt(t, h, ctx) require.Equal(t, smtp.AddressRejected.StatusCode, r.Result.StatusCode) @@ -183,7 +183,7 @@ func TestHandler_ServeSMTP(t *testing.T) { "rcpt": {Recipient: mail.NewRuleExpr("^support"), Action: mail.Allow}, }, }, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendRcpt(t, h, ctx) require.Equal(t, smtp.AddressRejected.StatusCode, r.Result.StatusCode) @@ -194,7 +194,7 @@ func TestHandler_ServeSMTP(t *testing.T) { { name: "max recipients valid", config: &mail.Config{Settings: &mail.Settings{AutoCreateMailbox: true, MaxRecipients: 5}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendRcpt(t, h, ctx) require.Equal(t, smtp.Ok, r.Result) @@ -207,7 +207,7 @@ func TestHandler_ServeSMTP(t *testing.T) { { name: "max recipients not valid", config: &mail.Config{Settings: &mail.Settings{AutoCreateMailbox: true, MaxRecipients: 2}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendRcpt(t, h, ctx) require.Equal(t, smtp.Ok, r.Result) @@ -222,18 +222,19 @@ func TestHandler_ServeSMTP(t *testing.T) { { name: "data", config: &mail.Config{Info: mail.Info{Name: "Testing Mail Server"}, Settings: &mail.Settings{AutoCreateMailbox: true}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, eh *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, eh events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendData(t, h, ctx) require.Equal(t, smtp.Ok, r.Result) - require.Len(t, eh.Events, 1) - require.Equal(t, "namespace=mail, name=Testing Mail Server", eh.Events[0].Traits.String()) + logs := eh.GetEvents(events.NewTraits().WithNamespace("mail")) + require.Len(t, logs, 1) + require.Equal(t, "namespace=mail, name=Testing Mail Server", logs[0].Traits.String()) }, }, { name: "server should add message into mailbox", config: &mail.Config{Settings: &mail.Settings{AutoCreateMailbox: true}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendData(t, h, ctx) require.Equal(t, smtp.Ok, r.Result) @@ -249,7 +250,7 @@ func TestHandler_ServeSMTP(t *testing.T) { Sender: mail.NewRuleExpr(".*@mokapi.io"), Action: mail.Allow, }}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMail(t, h, ctx) require.Equal(t, "Sender alice@foo.bar does not match allow rule: .*@mokapi.io", r.Result.Message) @@ -262,7 +263,7 @@ func TestHandler_ServeSMTP(t *testing.T) { Sender: mail.NewRuleExpr("@foo.bar"), Action: mail.Deny, }}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMail(t, h, ctx) require.Equal(t, "Sender alice@foo.bar does match deny rule: @foo.bar", r.Result.Message) @@ -282,7 +283,7 @@ func TestHandler_ServeSMTP(t *testing.T) { Message: "custom error message", }, }}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMail(t, h, ctx) require.Equal(t, "custom error message", r.Result.Message) @@ -297,7 +298,7 @@ func TestHandler_ServeSMTP(t *testing.T) { Subject: mail.NewRuleExpr("^Hello"), Action: mail.Deny, }}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMailWithSubjectAndBody(t, h, "Hello World", "", ctx) require.Equal(t, "Subject Hello World does match deny rule: ^Hello", r.Result.Message) @@ -312,7 +313,7 @@ func TestHandler_ServeSMTP(t *testing.T) { Subject: mail.NewRuleExpr("^Hello"), Action: mail.Allow, }}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMailWithSubjectAndBody(t, h, "foo", "", ctx) require.Equal(t, "Subject foo does not match allow rule: ^Hello", r.Result.Message) @@ -327,7 +328,7 @@ func TestHandler_ServeSMTP(t *testing.T) { Body: mail.NewRuleExpr("^Hello"), Action: mail.Deny, }}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMailWithSubjectAndBody(t, h, "Hello World", "Hello", ctx) require.Equal(t, "Body Hello does match deny rule: ^Hello", r.Result.Message) @@ -342,7 +343,7 @@ func TestHandler_ServeSMTP(t *testing.T) { Body: mail.NewRuleExpr("^Hello"), Action: mail.Allow, }}}, - test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ *eventstest.Handler) { + test: func(t *testing.T, h *mail.Handler, s *mail.Store, _ events.Handler) { ctx := smtp.NewClientContext(context.Background(), "") r := sendMailWithSubjectAndBody(t, h, "Hello World", "foo", ctx) require.Equal(t, "Body foo does not match allow rule: ^Hello", r.Result.Message) @@ -350,15 +351,46 @@ func TestHandler_ServeSMTP(t *testing.T) { require.Equal(t, smtp.EnhancedStatusCode{5, 7, 1}, r.Result.EnhancedStatusCode) }, }, + { + name: "log has subject", + config: &mail.Config{ + Mailboxes: map[string]*mail.MailboxConfig{ + "alice@foo.bar": {}, + }, + }, + test: func(t *testing.T, h *mail.Handler, s *mail.Store, eh events.Handler) { + ctx := smtp.NewClientContext(context.Background(), "") + sendMailWithSubjectAndBody(t, h, "foo", "", ctx) + logs := eh.GetEvents(events.NewTraits().WithNamespace("mail")) + require.Len(t, logs, 1) + require.Equal(t, "foo", logs[0].Data.(*mail.Log).Subject) + }, + }, + { + name: "encoded subject", + config: &mail.Config{ + Mailboxes: map[string]*mail.MailboxConfig{ + "alice@foo.bar": {}, + }, + }, + test: func(t *testing.T, h *mail.Handler, s *mail.Store, eh events.Handler) { + ctx := smtp.NewClientContext(context.Background(), "") + sendMailWithSubjectAndBody(t, h, "=?UTF-8?Q?=C2=A1Buenos_d=C3=ADas!?=", "", ctx) + logs := eh.GetEvents(events.NewTraits().WithNamespace("mail")) + require.Len(t, logs, 1) + require.Equal(t, "¡Buenos días!", logs[0].Data.(*mail.Log).Subject) + }, + }, } for _, tc := range testcases { tc := tc t.Run(tc.name, func(t *testing.T) { s := mail.NewStore(tc.config) - eh := &eventstest.Handler{} - h := mail.NewHandler(tc.config, s, enginetest.NewEngine(), eh) - tc.test(t, h, s, eh) + sm := events.NewStoreManager(nil) + sm.SetStore(10, events.NewTraits().WithNamespace("mail")) + h := mail.NewHandler(tc.config, s, enginetest.NewEngine(), sm) + tc.test(t, h, s, sm) }) } } diff --git a/smtp/message.go b/smtp/message.go index b5c2841c4..6d9b12210 100644 --- a/smtp/message.go +++ b/smtp/message.go @@ -5,8 +5,6 @@ import ( "bytes" "encoding/base64" "fmt" - "github.com/google/uuid" - log "github.com/sirupsen/logrus" "io" "mime" "mime/multipart" @@ -18,6 +16,9 @@ import ( "os" "strings" "time" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" ) const DateTimeLayout = "02-Jan-2006 15:04:05 -0700" @@ -504,3 +505,12 @@ func (a *Attachment) Headers() map[string]string { } return headers } + +func DecodeHeaderValue(s string) (string, error) { + dec := new(mime.WordDecoder) + r, err := dec.DecodeHeader(s) + if err != nil { + return s, fmt.Errorf("failed to decode SMTP header: %v", err) + } + return r, nil +} diff --git a/smtp/message_test.go b/smtp/message_test.go new file mode 100644 index 000000000..04d31f73e --- /dev/null +++ b/smtp/message_test.go @@ -0,0 +1,43 @@ +package smtp_test + +import ( + "mokapi/smtp" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeHeaderValue(t *testing.T) { + testcases := []struct { + name string + in string + want string + }{ + { + name: "empty", + in: "", + want: "", + }, + { + name: "utf-8", + in: "=?UTF-8?Q?=C2=A1Buenos_d=C3=ADas!?=", + want: "¡Buenos días!", + }, + { + name: "base64", + in: "=?UTF-8?B?bW9rYXBp?=", + want: "mokapi", + }, + } + + t.Parallel() + for _, tc := range testcases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + act, err := smtp.DecodeHeaderValue(tc.in) + require.NoError(t, err) + require.Equal(t, tc.want, act) + }) + } +} From cd3b8d353775682a8029bcf8257f88c2b35e0bfe Mon Sep 17 00:00:00 2001 From: marle3003 Date: Tue, 17 Feb 2026 13:21:49 +0100 Subject: [PATCH 03/11] fix(webui): decode email subject and addresses also in events --- providers/mail/log.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/providers/mail/log.go b/providers/mail/log.go index 51cb33e33..df25b6e2f 100644 --- a/providers/mail/log.go +++ b/providers/mail/log.go @@ -5,6 +5,8 @@ import ( "mokapi/engine/common" "mokapi/runtime/events" "mokapi/smtp" + + log "github.com/sirupsen/logrus" ) type Log struct { @@ -27,7 +29,13 @@ func NewLogEvent(msg *smtp.Message, ctx *smtp.ClientContext, eh events.Handler, if msg != nil { event.MessageId = msg.MessageId - event.Subject = msg.Subject + subject, err := smtp.DecodeHeaderValue(msg.Subject) + if err != nil { + log.Errorf("failed to decode subject: %v", err) + event.Subject = msg.Subject + } else { + event.Subject = subject + } } _ = eh.Push(event, traits.WithNamespace("mail")) From e5271e822843a506c0fd764c86c8aab7d1dbb400 Mon Sep 17 00:00:00 2001 From: maesi Date: Tue, 17 Feb 2026 18:16:41 +0100 Subject: [PATCH 04/11] doc: update documentation --- .../blogs/dynamic-mocks-with-javascript.md | 209 ++++++++---------- 1 file changed, 92 insertions(+), 117 deletions(-) diff --git a/docs/resources/blogs/dynamic-mocks-with-javascript.md b/docs/resources/blogs/dynamic-mocks-with-javascript.md index 03ca3e387..e70458425 100644 --- a/docs/resources/blogs/dynamic-mocks-with-javascript.md +++ b/docs/resources/blogs/dynamic-mocks-with-javascript.md @@ -1,155 +1,130 @@ --- -title: Create Smart API Mocks with Mokapi Scripts -description: Tired of static mocks? Learn how Mokapi Scripts let you create dynamic mock APIs using JavaScript — perfect for development, testing, and rapid prototyping. +title: Bring Your Mock APIs to Life with JavaScript +description: Tired of static mocks? Mokapi Scripts let you create dynamic, intelligent mock APIs that react to real request data, powered by plain JavaScript. +subtitle: Tired of static mocks? Mokapi Scripts let you create dynamic, intelligent mock APIs that react to real request data, powered by plain JavaScript. --- -# Bring Your Mock APIs to Life with Mokapi and JavaScript +# Bring Your Mock APIs to Life with JavaScript -Example JavaScript code for Mokapi Scripts with annotated benefits of using dynamic API mocks +Mocking APIs is essential for fast development, but static mocks can quickly become a bottleneck. What if your mock +could think? Reacting to query parameters, headers, or body content. Simulating auth, errors, and pagination. +Reflecting state changes across requests. -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 are designed for. With just a few lines of JavaScript, you can turn a flat +JSON file into a dynamic, intelligent mock API. -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. +> No backend? No problem. With Mokapi Scripts, your mocks behave exactly the way you need them to, all in +> familiar JavaScript or TypeScript, with no new DSL to learn. ## What Are Mokapi Scripts? -Mokapi Scripts are lightweight JavaScript modules that give you full control -over how your mock APIs respond. Instead of static JSON, you define behavior -based on request data — query strings, headers, body content, and more. - -## Why Dynamic Mocks Matter +Mokapi Scripts are lightweight JavaScript modules that sit alongside your OpenAPI or AsyncAPI specification. +Instead of returning a fixed response, they let you define behavior, inspecting the incoming request and +deciding what to send back. -Static mock responses are fine for simple cases, but they quickly fall short when: +They're the difference between a mock that says *"here's a user"* and one that says *"here's the right user, +given who's asking what role they have, and what they just posted."* -- Your frontend depends on different user roles (e.g., admin vs. regular user) -- You need to simulate errors, timeouts, or permission checks -- Backend state changes over time and should affect future responses (e.g., after a POST, the next GET reflects the update) -- You need data that changes depending on query parameters or request bodies -- You want to test workflows or sequences of API calls that depend on each other -- You're working on features like pagination, filtering, or sorting -- You need to simulate authentication and session-specific behavior -- You want to create more realistic test scenarios for CI pipelines or manual testing -- Your team needs fast feedback loops without relying on a fully working backend +## Why Static Mocks Fall Short -Dynamic mocks make your development process more reliable, realistic, and efficient. - -## What You Can Do with Mokapi Scripts +Static responses are fine for trivial cases. But real development quickly surfaces their limits: -- ✅ Return different responses based on query parameters, headers, or body content -- ✅ Simulate authentication, authorization, and role-based access -- ✅ Generate random, structured, or context-aware dynamic data -- ✅ Mock complex workflows with conditional logic and stateful behavior -- ✅ Chain requests together to simulate real-world usage patterns -- ✅ Customize error responses, delays, and status codes +- **Role-based responses** + Admin and regular users see different data. A static mock can only show one. +- **Simulating errors & timeouts** + You need your frontend to handle 403s, 429s, and network failures, but your mock always returns 200. +- **Stateful workflows** + After a POST, the next GET should reflect the change. Static mocks have no memory. +- **Dynamic filtering & pagination** + Query parameters like `?page=2` or `?name=laptop` should produce meaningful results. +- **Sequential request chains** + Login → fetch profile → update settings: static mocks can't model these flows. +- **Auth & session behavior** + Missing or invalid tokens should behave differently from valid ones in your test environment. -All using familiar JavaScript or TypeScript — no need to learn a new DSL. +Dynamic mocks solve all of these. They make your development process more reliable, your test suites more +realistic, and your feedback loops faster. -## Example: Conditional Response Based on a Query Parameter +## What You Can Do with Mokapi Scripts -Let’s say your API returns a list of products. You want to simulate: +- Return different responses based on query parameters, headers, or body content +- Simulate authentication, authorization, and role-based access control +- Generate random, structured, or context-aware dynamic data on the fly +- Mock complex workflows with conditional logic and stateful behavior +- Chain requests together to simulate real-world usage patterns +- Customize error responses, status codes, and artificial delays +- Mock an HTML login form to simulate an external identity provider -- A search operation when a query parameter (name) is provided -- An error response when the query parameter is exactly "error" +## Example: Dynamic Product Search -Here’s how easy it is with Mokapi Scripts: +Let's build a realistic product list endpoint. It should: +- Return the full product catalog when no filter is applied +- Filter products by name when a ?name= query parameter is provided +- Return a `400` error with a custom message when `?name=error` is passed -```typescript -import { on } from 'mokapi'; +```javascript title=products.js +import { on } from 'mokapi' const products = [ - { name: 'Laptop Pro 15' }, - { name: 'Wireless Mouse' }, - { name: 'Mechanical Keyboard' }, - { name: 'Noise Cancelling Headphones' }, - { name: '4K Monitor' }, - { name: 'USB-C Hub' } -]; + { name: 'Laptop Pro 15' }, + { name: 'Wireless Mouse' }, + { name: 'Mechanical Keyboard' }, + { name: 'Noise Cancelling Headphones' }, + { name: '4K Monitor' }, + { name: 'USB-C Hub' } +] export default () => { - on('http', (request, response): boolean => { - if (request.query.name) { - if (request.query.name === 'error') { - response.body = 'A custom error message'; - response.statusCode = 400; - } else { - const matchingProducts = products.filter(p => - p.name.toLowerCase().includes(request.query.name.toLowerCase()) - ); - response.data = {products: matchingProducts}; - return true; - } - } - return false; - }); -} -``` - -### response.data vs. response.body -In Mokapi, you control the response with either `response.data` or `response.body`: - -#### `response.data` + on('http', (request, response) => { + const nameFilter = request.query.name -- Any JavaScript value (object, array, number, etc.) -- Mokapi: - - ✅ Validates it against your OpenAPI specification. - - ✅ Converts it to the correct format (JSON, XML, etc.) - -Use this when you want automatic validation and formatting. + if (!nameFilter) { + // No filter — return everything + response.data = { products: products } + return + } -#### `response.body` -- Must be a string. -- Mokapi: - - ❌ Skips validation - - ✅ Gives you full control (e.g., raw HTML, plain text) + if (nameFilter === 'error') { + // Simulate a validation error + response.rebuild(400) + response.data.message = 'A custom error message' + return + } -Use this when you want to simulate freeform or invalid content. + // Filter products by name (case-insensitive) + const matched = products.filter(p => + p.name.toLowerCase().includes(nameFilter.toLowerCase()) + ) + + response.data = { products: matched } + }) +} +``` ## Use Cases -### 1. Frontend Development - -Test UI flows with realistic behavior — pagination, filtering, auth, and more — -without waiting for backend implementation. - -### 2. Testing and QA - -Simulate edge cases, failures, and timeouts directly in your mock server — -ideal for automated or manual testing. - -### 3. Rapid Prototyping - -Show real-world behavior in your prototypes using dynamic data. Build better -demos and get faster feedback. +- **Frontend Development** + Test UI flows with realistic behavior—pagination, filtering, auth, and error states—without waiting for a working backend. +- **Testing & QA** + Simulate edge cases, failures, and timeouts directly in your mock server. Ideal for automated CI pipelines and manual exploratory testing. +- **Rapid Prototyping** + Show real-world behavior in demos and prototypes using dynamic data. Build faster, get feedback sooner. ## Getting Started -1. Write your OpenAPI or AsyncAPI spec (or generate it) -2. Add a Script where you control the response with JavaScript -3. Run Mokapi — that’s it! - -👉 Try the [OpenAPI Mocking Tutorial](/resources/tutorials/get-started-with-rest-api) for a guided walkthrough. - -👉 Check out the [Mokapi Installation Guide](/docs/get-started/installation.md) to get set up in minutes. - -## Conclusion - -Static mocks are yesterday’s solution. \ -Mokapi Scripts bring a new level of control, flexibility, and realism to your API development process — all powered by JavaScript. - -No backend? No problem. \ -With Mokapi, your mocks behave exactly the way you need them to. +1. **Write your specification** + Start with an OpenAPI spec, or generate one from an existing service. This defines the shape of your API. +2. **Add a Mokapi Script** + Drop a JavaScript file next to your spec. Register event handlers that inspect the request and set the response however you need. +3. **Run Mokapi** + That's it. Your dynamic mock is live, validated against your spec, and visible in the Mokapi dashboard. ## Further Reading -- [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](/resources/blogs/end-to-end-testing-with-mocked-apis)\ - Improve your end-to-end tests by mocking APIs with Mokapi. +- [Debugging Mokapi JavaScript](/resources/blogs/debugging-mokapi-scripts) + Learn how to use `console.log`, `console.error`, and event handler tracing to see exactly what your scripts are doing. +- [End-to-End Testing with Mock APIs Using Mokapi](/resources/blogs/end-to-end-testing-with-mocked-apis) + Improve your end-to-end test suites by replacing live backend dependencies with Mokapi. --- From 163ea231e48138b17e6f6791bac1409f799bbdf4 Mon Sep 17 00:00:00 2001 From: maesi Date: Tue, 17 Feb 2026 18:18:28 +0100 Subject: [PATCH 05/11] doc: update documentation --- docs/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.json b/docs/config.json index 92ec25bc3..5865d8ad8 100644 --- a/docs/config.json +++ b/docs/config.json @@ -620,7 +620,7 @@ { "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", + "path": "/resources/blogs/bring-your-mock-apis-to-life-with-javascript", "hideNavigation": true }, { From 8bbeb94177440870b60b44a63bfb3f199f9fb4bb Mon Sep 17 00:00:00 2001 From: maesi Date: Wed, 18 Feb 2026 08:22:48 +0100 Subject: [PATCH 06/11] doc: update documentation --- .../blogs/end-to-end-testing-mocked-apis.md | 140 ++++++++++++------ webui/public/ci-pipeline-flow.png | Bin 0 -> 22981 bytes webui/src/composables/markdown-box.ts | 4 +- webui/src/views/DocsView.vue | 24 +++ 4 files changed, 123 insertions(+), 45 deletions(-) create mode 100644 webui/public/ci-pipeline-flow.png 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 317a440bf..c17468c46 100644 --- a/docs/resources/blogs/end-to-end-testing-mocked-apis.md +++ b/docs/resources/blogs/end-to-end-testing-mocked-apis.md @@ -1,44 +1,60 @@ --- -title: End-to-End Testing with Mock APIs Using Mokapi -description: Improve your end-to-end tests by mocking APIs with Mokapi. Integrate it into your CI/CD pipeline for faster, more reliable testing. +title: End-to-End Testing with Mocked APIs +description: Stop relying on flaky external services. Build faster, more reliable test pipelines with Mokapi-powered API mocks in your CI/CD. +subtitle: Stop relying on flaky external services. Build faster, more reliable test pipelines with Mokapi-powered API mocks in your CI/CD. --- -# End-to-End Testing with Mocked APIs Using Mokapi +# End-to-End Testing with Mocked APIs -Building reliable applications often depends on being able to test real-world scenarios — including how your app -behaves when communicating with external APIs. However, relying on real APIs during development and CI/CD -pipelines can introduce problems like slow test runs, flaky results, or even test failures when external -services are unavailable. +Building reliable applications depends on being able to test real-world scenarios, including how your app +behaves when communicating with external APIs. But relying on live APIs during development and CI/CD +pipelines introduces problems: slow test runs, flaky results, and failures when external services are unavailable. -That’s where mocking APIs comes in. +That's where API mocking comes in and specifically, where Mokapi transforms your testing workflow. -## Why Mock APIs for Testing? +> With Mokapi, you can simulate external systems under controlled conditions using OpenAPI +> or AsyncAPI specifications. The result? Faster feedback, fewer bugs, and tests you can actually trust. -Mocking APIs allows you to simulate external systems under controlled conditions. This means: +## Why Mock APIs for Testing? -- ⚡ Faster and more reliable test runs -- 🎯 Full control over the data and behavior returned by APIs -- 🛠️ Easier testing of error scenarios, timeouts, and edge cases -- 🔗 No dependencies on the availability of third-party services +Mocking APIs gives you control over the testing environment in ways that real external services simply can't provide: -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. +``` box=benefits title="Faster Test Runs" +No network latency, no waiting for slow third-party responses. Tests complete in seconds, not minutes. +``` -## How Mokapi Fits into Your CI/CD Pipeline +``` box=benefits title="Full Control" +Return exactly the data you need. Simulate specific states, edge cases, and user scenarios on demand. +``` -Running Mokapi during your automated tests helps isolate your systems and catch integration bugs earlier. -Here's what a typical flow looks like: +``` box=benefits title="Test Error Scenarios" +Force 500 errors, timeouts, malformed responses, and rate limits without breaking real services. +``` -```shell -Code Push → Start Mokapi → Run Tests Against Mocks → Stop Mokapi +``` box=benefits title="Zero Dependencies" +Tests run offline, in CI, or on developer machines—no reliance on third-party availability or credentials. ``` -You can even run Mokapi in your GitHub Actions workflows, making your CI/CD pipelines faster and more predictable. + +Mokapi makes this straightforward: define your API contracts with OpenAPI or AsyncAPI specifications, +add optional JavaScript for dynamic behavior, and you're done. Your mocks are validated, versioned, +and ready to serve. + +## How Mokapi Fits Into Your CI/CD Pipeline + +Integrating Mokapi into your automated test pipeline isolates your system under test +and catches integration bugs early. The workflow is simple: + +Typical CI/CD Flow with Mokapi + +Mokapi runs as a service during your test phase—whether in Docker, Kubernetes, or directly on the CI runner. +Your tests hit the mocked endpoints instead of real APIs, and Mokapi validates every response against your +specifications. ## Example: GitHub Actions Workflow with Mokapi -Here's a basic example of using Mokapi in GitHub Actions: -```yaml +Here's a practical example showing how to integrate Mokapi into a GitHub Actions workflow for a Node.js backend: + +```yaml tab=.github/workflows/test.yml name: Node.js Backend Tests on: @@ -58,7 +74,10 @@ jobs: - name: Start Mokapi run: | - docker run -d --rm --name mokapi -p 80:80 -p 8080:8080 -v ${{ github.workspace }}/mocks:/mocks mokapi/mokapi:latest /mocks + docker run -d --name mokapi \ + -p 80:80 \ + -v ${{ github.workspace }}/mocks:/mocks \ + mokapi/mokapi:latest /mocks sleep 5 # Ensure Mokapi is running - name: Set Up Node.js @@ -76,33 +95,68 @@ jobs: run: docker stop mokapi ``` -In this setup, Mokapi runs as in a Docker container in GitHub Actions, making your mocked APIs available for the duration of your tests. +### What's Happening Here? + +- **Checkout:** The workflow pulls your code, including your mock definitions stored in `/mocks` +- **Start Mokapi:** A Docker container runs Mokapi, mounting your mock specifications and exposing port 80 (API) +- **Set Up Node.js:** The test environment is configured with Node.js 20 +- **Install & Test:** Dependencies are installed, and the test suite runs, hitting Mokapi instead of real APIs +- **Cleanup:** Mokapi stops automatically, keeping the CI environment clean + +## Advanced Mocking with Mokapi -## Beyond the Basics: Advanced Mocking with Mokapi +Basic mocking gets you far, but Mokapi's advanced features let you simulate complex real-world scenarios: -Want even more control? Mokapi supports advanced mocking features, such as: +``` box=feature title="Simulating Timeouts" +Test how your app handles slow APIs by adding artificial delays to responses +``` -- **Simulating Timeouts** — Test how your app handles slow APIs -- **Dynamic Responses** — Return different data based on query parameters or headers -- **Error Scenarios** — Force 500 errors or custom error messages to test error handling +``` box=feature title="Dynamic Responses" +Return different data based on query parameters, headers, or request body content +``` -You can even modify mocks dynamically during a test run with simple [JavaScripts](/docs/javascript-api/overview.md)! +``` box=feature title="Error Scenarios" +Force 500 errors, 429 rate limits, or custom error messages to validate error handling +``` -## Local Development and Mokapi +These capabilities are powered by Mokapi Scripts—lightweight JavaScript modules that let you +define custom behavior. Here's a quick example for delay simulation: + +```javascript tab=simulations.js +import { on, sleep } from 'mokapi' + +export default () => { + let delay = undefined + + on('http', (request, response) => { + if (request.key === '/simulations/delay') { + switch (request.method) { + case 'PUT': + delay = request.query.duration; + break; + case 'DELETE': + delay = undefined; + break; + } + } + }) + on('http', (request, response) => { + if (!delay) { + sleep(delay); + } + }, { track: true } ) +} +``` -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/get-started/running.md). +This script demonstrates how to use Mokapi Scripts to dynamically simulate response delays at runtime using HTTP requests. -## Conclusion +It allows you to enable, change, or disable artificial latency without restarting Mokapi. -Mocking APIs is a crucial part of building robust, scalable systems. With Mokapi, you can easily integrate mocking into your local -development and CI pipelines, leading to faster feedback, fewer bugs, and better products. +## Ready to ship faster, more reliable software? -**Ready to ship faster, more reliable software?** -[Get started with Mokapi](/docs/get-started/installation.md) +- [Get started with Mokapi](/docs/get-started/installation.md) +- [Full GitHub Actions Tutorial](/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline). -For a detailed, step-by-step guide on how to use Mokapi in your GitHub Actions workflows, -see the [GitHub Actions and Mokapi](/resources/tutorials/running-mokapi-in-a-ci-cd-pipeline). --- diff --git a/webui/public/ci-pipeline-flow.png b/webui/public/ci-pipeline-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..cc812851b0c6b1a3939dcff247b02fd0d0923d88 GIT binary patch literal 22981 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 literal 0 HcmV?d00001 diff --git a/webui/src/composables/markdown-box.ts b/webui/src/composables/markdown-box.ts index fcfe6f93f..a1361fe6b 100644 --- a/webui/src/composables/markdown-box.ts +++ b/webui/src/composables/markdown-box.ts @@ -114,14 +114,14 @@ export function MarkdownItBox(md: MarkdownIt, opts: Options) { break } - alert += `