diff --git a/docs/config.json b/docs/config.json index 36b069359..0233330fa 100644 --- a/docs/config.json +++ b/docs/config.json @@ -201,6 +201,7 @@ "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", diff --git a/docs/javascript-api/mokapi/eventhandler/httprequest.md b/docs/javascript-api/mokapi/eventhandler/httprequest.md index 066114331..c754f43cd 100644 --- a/docs/javascript-api/mokapi/eventhandler/httprequest.md +++ b/docs/javascript-api/mokapi/eventhandler/httprequest.md @@ -18,4 +18,5 @@ that contains request-specific data such as HTTP headers. | 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 | diff --git a/docs/resources/blogs/ensuring-api-contract-compliance-with-mokapi.md b/docs/resources/blogs/ensuring-api-contract-compliance-with-mokapi.md new file mode 100644 index 000000000..798d2b049 --- /dev/null +++ b/docs/resources/blogs/ensuring-api-contract-compliance-with-mokapi.md @@ -0,0 +1,266 @@ +--- +title: Ensure API Contract Compliance with Mokapi Validation +description: Validate HTTP API requests and responses with Mokapi to catch breaking changes early and keep backend implementations aligned with your OpenAPI spec. +image: + url: /mokapi-using-as-proxy.png + alt: Flow diagram illustrating how Mokapi enforces OpenAPI contracts between clients, Playwright tests, and backend APIs. +--- + +# Ensuring Compliance with the HTTP API Contract Using Mokapi for Request Forwarding and Validation + +In modern distributed systems, APIs are everywhere — frontend-to-backend, +backend-to-backend, microservices communicating internally, mobile apps, test +automation tools, and more. Each interaction relies on a shared API contract, +often expressed through an OpenAPI specification. Even small +deviations can introduce bugs, break integrations, or slow down development. + +By placing Mokapi between a client and a backend, you can ensure that every +**request and response adheres to your OpenAPI specification**. With a few lines +of JavaScript, Mokapi can forward requests to your backend while validating both +sides of the interaction. This provides a powerful way to enforce API correctness — +whether the client is a browser, Playwright tests, your mobile app, or even +another backend service. + +In this article, I explore how Mokapi can act as **a contract-enforcing validation layer** +and why this approach benefits frontend developers, backend teams, QA engineers, +and platform engineers alike. + +Flow diagram illustrating how Mokapi enforces OpenAPI contracts between clients, Playwright tests, and backend APIs. + +## How to Use Mokapi for API Validation with Request Forwarding? + +Mokapi cannot only be used for mocking APIs, but it can also sit between any +consumer and a backend service to validate real traffic. Using a small +JavaScript script, Mokapi can forward requests to your backend and +validates both requests and responses. + + + Consumer (Frontend, Playwright, Microservice) → Mokapi → Backend API + +```typescript +import { on } from 'mokapi'; +import { fetch } from 'mokapi/http'; + +/** + * This script demonstrates how to forward incoming HTTP requests + * to a real backend while letting Mokapi validate responses according + * to your OpenAPI spec. + * + * The script listens to all HTTP requests and forwards them based + * on the `request.api` field. Responses from the backend are + * validated when possible, and any errors are reported back to + * the client. + */ +export default async function () { + + /** + * Register a global HTTP event handler. + * This function is called for every incoming request. + */ + on('http', async (request, response) => { + + // Determine the backend URL to forward this request to + const url = getForwardUrl(request) + + // If no URL could be determined, return an error immediately + if (!url) { + response.statusCode = 500; + response.body = 'Failed to forward request: unknown backend'; + return; + } + + try { + // Forward the request to the backend + const res = await fetch(url, { + method: request.method, + body: request.body, + headers: request.header, + timeout: '30s' + }); + + // Copy status code and headers from the backend response + response.statusCode = res.statusCode; + response.headers = res.headers + + // Check the content type to decide whether to validate the response + const contentType = res.headers['Content-Type']?.[0] || ''; + + if (contentType.includes('application/json')) { + // Mokapi can validate JSON responses automatically + response.data = res.json(); + } else { + // For other content types, skip validation + response.body = res.body; + } + + } catch (e) { + // Handle any errors that occur while forwarding + response.statusCode = 500; + response.body = e.toString(); + } + }); + + /** + * Maps the incoming request to a backend URL based on the API name + * defined in the OpenAPI specification (`info.title`). + * @see https://mokapi.io/docs/javascript-api/mokapi/eventhandler/httprequest + * + * @param request - the incoming Mokapi HTTP request + * @returns the full URL to forward the request to, or undefined + */ + 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://backend1.example.com${request.url.path}?${request.url.query}`; + } + default: + return undefined; + } + } +} +``` + +For each interaction, Mokapi performs four important steps: + +### 1. Validates incoming requests + +Mokapi checks every incoming request against your OpenAPI specification: + +- HTTP method +- URL & parameters +- headers +- request body + +If the client sends anything invalid, Mokapi blocks it and returns a clear +validation error. + +### 2. Forwards valid requests to your backend + +If the request is valid, Mokapi forwards it unchanged to the backend using JavaScript. + +- No changes are required in your backend. +- No additional infrastructure is necessary. + +### 3. Validates backend responses + +Once the backend responds, Mokapi validates the response against the OpenAPI specification: + +- status codes +- headers +- response body + +If something doesn't match the contract, Mokapi blocks it and sends a validation error back to the client. + +### 4. Return the validated response to the client + +Only responses that pass validation reach the client, guaranteeing contract fidelity end-to-end. + +## Where You Can Use Mokapi for Request Forwarding and Validation + +Mokapi’s forwarding and validation capabilities make it useful far beyond local development or Playwright scripting. + +### Between Frontend and Backend + +Placing Mokapi between your frontend and backend ensures: +- automatic request and response validation +- immediate detection of breaking changes +- backend and API specification evolve together +- fewer “why is the frontend broken?” debugging loops + +Frontend developers can experiment with confidence, knowing the backend +cannot silently diverge from the published contract. + +### Between Backend Services (Service-to-Service) + +In microservice architectures, API drift between services is a frequent cause of instability. +Routing service-to-service traffic through Mokapi gives you: +- strict contract enforcement between services +- early detection of incompatible changes +- stable integrations even as teams evolve independently +- clear validation errors during development and CI + +Mokapi becomes a lightweight, spec-driven contract guardian across your backend ecosystem. + +### In Automated Testing (e.g., Playwright) + +This is one of the most powerful setups. + + Playwright → Mokapi → Backend + +Benefits: +- CI fails immediately when the backend breaks the API contract +- tests interact with the real backend, not mocks +- validation errors are clear and actionable +- tests remain simpler — no need to validate everything in Playwright + +Your tests are guaranteed to hit a backend that actually matches the API contract. + +### In Kubernetes Test Environments + +Mokapi can also be used in temporary or preview environments to ensure contract validation across the entire cluster. + +In Kubernetes, Mokapi can be deployed as: +- a sidecar container +- a standalone validation layer in front of backend services +- a temporary component inside preview environments + +This brings: +- consistent contract validation for all cluster traffic +- early detection of breaking API changes before staging +- contract enforcement without modifying backend services +- transparent operation — apps talk to Mokapi, Mokapi talks to the backend + +You can integrate Mokapi into Helm charts, GitOps workflows, or test namespaces. + +## Why Teams Benefit from Using Mokapi Between Client and Backend + +### Automatic Contract Enforcement + +Every interaction is validated against your OpenAPI specification. Your backend can no longer quietly drift from the contract. + +### Immediate Detection of Breaking Changes + +Issues are caught early, not just in staging or production, such as: + - renamed or missing fields + - wrong or inconsistent formats + - unexpected status codes + - mismatched data types + +### More Reliable Frontend Development + +Frontend teams get: +- consistent, validated API responses +- fewer sudden breaking changes +- a smoother development workflow + +This reduces context-switching and debugging time. + +### Better Collaboration Between Teams + +With Mokapi validating both sides: +- backend developers instantly see when they violate the contract +- frontend engineers get stable, predictable APIs +- QA gets reliable test environments +- platform engineers reduce risk during deployments + +Mokapi becomes a shared API contract watchdog across the organization. + +### Smooth Transition from Mocks to Real Systems + +Teams often start with mocked endpoints in early development. Later, they can simply begin forwarding requests to the +real backend—while keeping validation in place. + +## Conclusion + +Using Mokapi between frontend and backend, between backend services, or inside Kubernetes environments provides: +- strong contract enforcement +- automatic validation for every interaction +- early detection of breaking changes +- stable multi-team integration +- more reliable CI pipelines +- a smooth path from mocking to real backend validation + +Mokapi ensures your API stays aligned with its specification, no matter how quickly your system evolves. \ No newline at end of file diff --git a/engine/js_test.go b/engine/js_test.go index cd4b2b3b9..b95f6e04e 100644 --- a/engine/js_test.go +++ b/engine/js_test.go @@ -13,7 +13,6 @@ import ( "net/url" "strings" "testing" - "time" r "github.com/stretchr/testify/require" ) @@ -23,31 +22,30 @@ func TestJsScriptEngine(t *testing.T) { t.Run("valid", func(t *testing.T) { t.Parallel() e := enginetest.NewEngine() - err := e.AddScript(newScript("test.js", "export default function(){}")) + err := e.AddScript(newScript("valid.js", "export default function(){}")) r.NoError(t, err) r.Equal(t, 0, e.Scripts(), "no events and jobs, script should be closed") }) t.Run("blank", func(t *testing.T) { t.Parallel() e := enginetest.NewEngine() - err := e.AddScript(newScript("test.js", "")) + err := e.AddScript(newScript("blank.js", "")) r.NoError(t, err) r.Equal(t, 0, e.Scripts(), "no events and jobs, script should be closed") }) t.Run("typescript", func(t *testing.T) { t.Parallel() e := enginetest.NewEngine() - err := e.AddScript(newScript("test.ts", "const msg: string = 'Hello World';")) + err := e.AddScript(newScript("typescript.ts", "const msg: string = 'Hello World';")) r.NoError(t, err) r.Equal(t, 0, e.Scripts(), "no events and jobs, script should be closed") }) - t.Run("typescript async default function", func(t *testing.T) { + t.Run("async default function", func(t *testing.T) { t.Parallel() e := enginetest.NewEngine() - err := e.AddScript(newScript("test.js", "import { every } from 'mokapi'; export default async function(){ setTimeout(() => { every('1m', function() {}) }, 500)}")) + err := e.AddScript(newScript("async.js", "import { every } from 'mokapi'; export default async function(){ setTimeout(() => { every('1m', function() {}) }, 500)}")) r.NoError(t, err) r.Equal(t, 1, e.Scripts()) - time.Sleep(2 * time.Second) e.Close() }) t.Run("script from GIT provider", func(t *testing.T) { @@ -60,6 +58,63 @@ func TestJsScriptEngine(t *testing.T) { r.Equal(t, 1, e.Scripts()) e.Close() }) + t.Run("example from Mokapi as proxy article", func(t *testing.T) { + t.Parallel() + e := enginetest.NewEngine() + err := e.AddScript(newScript("proxy.ts", ` +import { on } from 'mokapi'; +import { fetch } from 'mokapi/http'; + +export default async function () { + on('http', async (request, response) => { + const url = getForwardUrl(request) + if (!url) { + response.statusCode = 500; + response.body = 'failed to forward request'; + } else { + 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 + switch (res.headers['Content-Type'][0]) { + case 'application/json': + // mokapi validates the data + response.data = res.json(); + default: + // mokapi skips validation + response.body = res.body; + } + } catch (e) { + response.statusCode = 500; + response.body = e.toString(); + } + } + }); + + function getForwardUrl(request: HttpRequest): string | undefined { + switch (request.api) { + case 'backend-1': { + const url = {host: 'https://backend1.example.com', ...request.url} + return url.toString(); + } + case 'backend-2': { + const url = {host: 'https://backend1.example.com', ...request.url} + return url.toString(); + } + default: + return undefined; + } + } +} +`)) + r.NoError(t, err) + e.Close() + }) } func TestJsEvery(t *testing.T) { diff --git a/go.mod b/go.mod index 89ec96c95..13ac3ea1c 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,12 @@ go 1.25.1 require ( github.com/Masterminds/sprig v2.22.0+incompatible - github.com/blevesearch/bleve/v2 v2.5.5 + github.com/blevesearch/bleve/v2 v2.5.6 github.com/blevesearch/bleve_index_api v1.2.11 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.0 + github.com/evanw/esbuild v0.27.1 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.4 diff --git a/go.sum b/go.sum index 62f3bc215..3b9e17c71 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/blevesearch/bleve/v2 v2.5.5 h1:lzC89QUCco+y1qBnJxGqm4AbtsdsnlUvq0kXok8n3C8= -github.com/blevesearch/bleve/v2 v2.5.5/go.mod h1:t5WoESS5TDteTdnjhhvpA1BpLYErOBX2IQViTMLK7wo= +github.com/blevesearch/bleve/v2 v2.5.6 h1:YdixQmOUuZHojQRe8Te7BY2cRirbzpbcpybAFs0m2DI= +github.com/blevesearch/bleve/v2 v2.5.6/go.mod h1:t5WoESS5TDteTdnjhhvpA1BpLYErOBX2IQViTMLK7wo= github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s= github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= @@ -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.0 h1:1fbrgepqU1rZeu4VPcQRZJpvIfQpbrYqRr1wJdeMkfM= -github.com/evanw/esbuild v0.27.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/evanw/esbuild v0.27.1 h1:SkYgb1wrwJkJYwBp5hjmQGm3riUHbCdfViyEvjAA/KA= +github.com/evanw/esbuild v0.27.1/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= diff --git a/js/mokapi/on.go b/js/mokapi/on.go index 5b32e2c40..5e234a806 100644 --- a/js/mokapi/on.go +++ b/js/mokapi/on.go @@ -42,16 +42,6 @@ func (m *Module) On(event string, do goja.Value, vArgs goja.Value) { if err != nil { return nil, err } - /*for i, param := range params { - e := param.Export() - if p, ok := e.(*Proxy); ok { - rv := reflect.ValueOf(ctx.Args[i]) - if rv.Kind() != reflect.Ptr { - panic(m.vm.ToValue(fmt.Errorf("parameter %v is not a pointer", i))) - } - rv.Elem().Set(reflect.ValueOf(p.Export())) - } - }*/ return v, nil }, &eventloop.JobContext{EventLogger: ctx.EventLogger}) diff --git a/js/mokapi/proxy.go b/js/mokapi/proxy.go index f893d546a..32d3678b8 100644 --- a/js/mokapi/proxy.go +++ b/js/mokapi/proxy.go @@ -192,10 +192,16 @@ func (p *Proxy) Keys() []string { t := target.Type() for i := 0; i < t.NumField(); i++ { f := t.Field(i) - if f.PkgPath == "" { + if f.PkgPath != "" { continue } - result = append(result, f.Name) + v := f.Tag.Get("json") + if v != "" { + tagValues := strings.Split(v, ",") + result = append(result, tagValues[0]) + } else { + result = append(result, f.Name) + } } } diff --git a/js/mokapi/shared.go b/js/mokapi/shared.go index 6b91ee439..0b018cc63 100644 --- a/js/mokapi/shared.go +++ b/js/mokapi/shared.go @@ -50,8 +50,10 @@ func (m *SharedMemory) Clear() { func (m *SharedMemory) Update(key string, fn goja.Value) any { p := m.store.Update(key, func(v any) any { var arg goja.Value + var sv *SharedValue if v != nil { - arg = v.(*SharedValue).Use(m.vm).ToValue() + sv = v.(*SharedValue) + arg = sv.ToValue() } call, ok := goja.AssertFunction(fn) if !ok { @@ -61,6 +63,9 @@ func (m *SharedMemory) Update(key string, fn goja.Value) any { if err != nil { panic(m.vm.ToValue(err)) } + if sv != nil && r == arg { + return sv.Use(m.vm) + } return NewSharedValue(r, m.vm) }) @@ -110,9 +115,6 @@ func Export(v any) any { // SharedValue represents a Go-managed value that can be shared across // multiple Goja runtimes, while maintaining reference identity. type SharedValue struct { - KeyNormalizer func(string) string - ToJSValue func(vm *goja.Runtime, k string, v goja.Value) goja.Value - vm *goja.Runtime source goja.Value } @@ -125,23 +127,16 @@ func NewSharedValue(v goja.Value, vm *goja.Runtime) *SharedValue { } func (p *SharedValue) Use(vm *goja.Runtime) *SharedValue { - return &SharedValue{source: p.source, vm: vm} + return NewSharedValue(p.source, vm) } func (p *SharedValue) Get(key string) goja.Value { - if p.KeyNormalizer != nil { - key = p.KeyNormalizer(key) - } - switch v := p.source.(type) { case *goja.Object: f := v.Get(key) if _, ok := goja.AssertFunction(f); ok { return f } else if _, isObject := f.(*goja.Object); isObject { - if p.ToJSValue != nil { - return p.ToJSValue(p.vm, key, f) - } return p.vm.NewDynamicObject(NewSharedValue(f, p.vm)) } return f @@ -151,10 +146,6 @@ func (p *SharedValue) Get(key string) goja.Value { } func (p *SharedValue) Has(key string) bool { - if p.KeyNormalizer != nil { - key = p.KeyNormalizer(key) - } - switch v := p.source.(type) { case *goja.Object: return slices.Contains(v.Keys(), key) @@ -164,10 +155,6 @@ func (p *SharedValue) Has(key string) bool { } func (p *SharedValue) Set(key string, value goja.Value) bool { - if p.KeyNormalizer != nil { - key = p.KeyNormalizer(key) - } - switch v := p.source.(type) { case *goja.Object: err := v.Set(key, value) @@ -180,10 +167,6 @@ func (p *SharedValue) Set(key string, value goja.Value) bool { } func (p *SharedValue) Delete(key string) bool { - if p.KeyNormalizer != nil { - key = p.KeyNormalizer(key) - } - switch v := p.source.(type) { case *goja.Object: err := v.Delete(key) @@ -209,6 +192,7 @@ func (p *SharedValue) ToValue() goja.Value { if p.source == nil { return goja.Undefined() } + switch p.source.ExportType().Kind() { case reflect.Map, reflect.Slice: return p.vm.NewDynamicObject(p) diff --git a/js/mokapi/shared_test.go b/js/mokapi/shared_test.go index f881fc808..385cee25d 100644 --- a/js/mokapi/shared_test.go +++ b/js/mokapi/shared_test.go @@ -9,6 +9,7 @@ import ( "mokapi/js/eventloop" "mokapi/js/mokapi" "mokapi/js/require" + "mokapi/try" "testing" "github.com/dop251/goja" @@ -307,6 +308,37 @@ func TestModule_Shared(t *testing.T) { r.Equal(t, []any{int64(1), int64(2), int64(3)}, mokapi.Export(v)) }, }, + { + name: "set array using index operator", + test: func(t *testing.T, newVm func() *goja.Runtime) { + vm1 := newVm() + + v, err := vm1.RunString(` + const m = require('mokapi'); + m.shared.set('foo', [1,2]) + const shared = m.shared.get('foo'); + shared[1] = 10 + m.shared.get('foo') + `) + r.NoError(t, err) + r.Equal(t, []any{int64(1), int64(10)}, mokapi.Export(v)) + }, + }, + { + name: "get index from array", + test: func(t *testing.T, newVm func() *goja.Runtime) { + vm1 := newVm() + + v, err := vm1.RunString(` + const m = require('mokapi'); + m.shared.set('foo', [1,2]) + const shared = m.shared.get('foo'); + shared[1]; + `) + r.NoError(t, err) + r.Equal(t, int64(2), mokapi.Export(v)) + }, + }, { name: "splice array", test: func(t *testing.T, newVm func() *goja.Runtime) { @@ -375,3 +407,51 @@ func TestModule_Shared(t *testing.T) { }) } } + +func TestSharedFromModule(t *testing.T) { + // import shared multiple times should not result to illegal runtime error + + host := &enginetest.Host{} + host.OpenFunc = func(file, hint string) (*dynamic.Config, error) { + return &dynamic.Config{ + Info: dynamictest.NewConfigInfo(), + Raw: []byte(`import { shared } from "mokapi" +export const store = shared.update('stored', (v) => v ?? [{ foo: 'bar' }]); +`), + }, nil + } + + s1, err := js.New(&dynamic.Config{ + Info: dynamictest.NewConfigInfo(), + Raw: []byte(`import { store } from "store.js" + +`), + }, host) + r.NoError(t, err) + defer s1.Close() + + err = s1.Run() + r.NoError(t, err) + + s2, err := js.New(&dynamic.Config{ + Info: dynamic.ConfigInfo{Url: try.MustUrl("script1.js")}, + Raw: []byte(`import { store } from "store.js" +`), + }, host) + r.NoError(t, err) + defer s2.Close() + + err = s2.Run() + r.NoError(t, err) + + s3, err := js.New(&dynamic.Config{ + Info: dynamic.ConfigInfo{Url: try.MustUrl("script1.js")}, + Raw: []byte(`import { store } from "store.js" +`), + }, host) + r.NoError(t, err) + defer s3.Close() + + err = s3.Run() + r.NoError(t, err) +} diff --git a/npm/types/http.d.ts b/npm/types/http.d.ts index 314b9c31a..5bbc019a3 100644 --- a/npm/types/http.d.ts +++ b/npm/types/http.d.ts @@ -86,11 +86,83 @@ export function del(url: string, body?: JSONValue, args?: Args): Response; */ export function options(url: string, body?: JSONValue, args?: Args): Response; -export function fetch(url: string, args: FetchOptions): Promise +/** + * Sends an HTTP request and returns a Promise resolving to the response. + * + * @param {string} url - The URL to request. + * @param {FetchOptions} opts - Optional fetch configuration. + * @returns {Promise} A Promise that resolves with the response. + * + * @example + * // Simple GET request + * const res = await fetch('https://foo.bar/api'); + * const data = await res.json(); + * + * @example + * // POST request with JSON body + * const res = await fetch('https://foo.bar/api', { + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: { name: 'Alice' } + * }); + * + * @example + * // Request with timeout and redirect settings + * const res = await fetch('https://foo.bar/api', { + * timeout: '5s', + * maxRedirects: 2 + * }); + */ +export function fetch(url: string, opts?: FetchOptions): Promise +/** + * Options for the {@link fetch} function. + */ export interface FetchOptions { - method: string - body: any + /** + * HTTP method to use for the request. + * @default "GET" + * @example + * const res = await fetch(url, { method: 'POST' }); + */ + method?: string; + + /** + * The body of the request, such as a string or object. + * @example + * const res = await fetch(url, { body: JSON.stringify({ name: 'Alice' }) }); + */ + body?: any; + + /** + * Request headers. + * @example + * const res = await fetch(url, { + * headers: { 'Authorization': 'Bearer token123' } + * }); + */ + headers?: { [name: string]: string }; + + /** + * The number of redirects to follow for this request. + * @default 5 + * @example + * const res = await fetch(url, { maxRedirects: 1 }); + */ + maxRedirects?: number; + + /** + * Maximum time to wait for the request to complete. Default + * timeout is 60 seconds ("60s"). The type can also be a number, in which + * case Mokapi interprets it as milliseconds + * @default "60s" + * @example + * const res = get(url, { timeout: '5m' }) + * @example + * // Timeout as milliseconds + * const res = await fetch(url, { timeout: 3000 }); + */ + timeout?: number | string; } /** @@ -103,8 +175,8 @@ export interface Args { /** * The number of redirects to follow for this request. * @default 5 - **/ - maxRedirects?: number + */ + maxRedirects?: number; /** * Maximum time to wait for the request to complete. Default * timeout is 60 seconds ("60s"). The type can also be a number, in which @@ -112,7 +184,7 @@ export interface Args { * @example * const res = get(url, { timeout: '5m' }) */ - timeout?: number | string + timeout?: number | string; } /** diff --git a/npm/types/index.d.ts b/npm/types/index.d.ts index ca4746a10..4bb611536 100644 --- a/npm/types/index.d.ts +++ b/npm/types/index.d.ts @@ -29,7 +29,7 @@ import "./mail"; * }) * } */ -export function on(event: T, handler: EventHandler[T], args?: EventArgs): void | Promise; +export function on(event: T, handler: EventHandler[T], args?: EventArgs): void; /** * Schedules a new periodic job with interval. @@ -122,7 +122,7 @@ export interface EventHandler { * }) * } */ -export type HttpEventHandler = (request: HttpRequest, response: HttpResponse) => void; +export type HttpEventHandler = (request: HttpRequest, response: HttpResponse) => void | Promise; /** * HttpRequest is an object used by HttpEventHandler that contains request-specific @@ -133,7 +133,7 @@ export interface HttpRequest { /** * Request method. * @example GET - * */ + */ readonly method: string; /** Represents a parsed URL. */ @@ -157,15 +157,13 @@ export interface HttpRequest { /** Object contains querystring parameters specified by OpenAPI querystring parameters. */ readonly querystring: any; - /** API name where this request is specified */ - readonly api: string; - /** Path value specified by the OpenAPI path */ readonly key: string; /** OperationId defined in OpenAPI */ readonly operationId: string; + /** Returns a string representing this HttpRequest object. */ toString(): string } @@ -206,7 +204,8 @@ export interface Url { /** URL query string. */ readonly query: string; - toString(): string + /** Returns a string representing this Url object. */ + toString(): string; } /** @@ -427,12 +426,6 @@ export type DateLayout = * * Use this to customize how the event appears in the dashboard or to control tracking. * - * @property tags Optional key-value pairs used to label the event in the dashboard. - * - * @property track Set to `true` to enable tracking of this event handler in the dashboard. - * If omitted, Mokapi automatically checks whether the response object has - * been modified and tracks the handler only if a change is detected. - * * @example * export default function() { * on('http', function(req, res) { @@ -445,7 +438,7 @@ export type DateLayout = */ export interface EventArgs { /** - * Adds or overrides existing tags used in dashboard + * Adds or overrides existing tags used to label the event in dashboard */ tags?: { [key: string]: string }; @@ -458,7 +451,7 @@ export interface EventArgs { } /** - * cheduledEventArgs is an object used by every and cron function. + * ScheduledEventHandler is an object used by every and cron function. * https://mokapi.io/docs/javascript-api/mokapi/eventhandler/scheduledeventargs * @example * export default function() { @@ -559,7 +552,7 @@ export interface SharedMemory { * @param key The key to retrieve. * @returns The stored value, or `undefined` if not found. */ - get(key: string): T | undefined + get(key: string): any; /** * Sets a value for the given key. @@ -567,7 +560,7 @@ export interface SharedMemory { * @param key The key to store the value under. * @param value The value to store. */ - set(key: string, value: T): void + set(key: string, value: any): void; /** * Updates a value atomically using an updater function. @@ -583,32 +576,32 @@ export interface SharedMemory { * @param updater Function that receives the current value and returns the new value. * @returns The new value after update. */ - update(key: string, updater: (value: T | undefined) => T): T + update(key: string, updater: (value: T | undefined) => T): T; /** * Checks if the given key exists in shared memory. * @param key The key to check. * @returns `true` if the key exists, otherwise `false`. */ - has(key: string): boolean + has(key: string): boolean; /** * Removes the specified key and its value from shared memory. * @param key The key to remove. */ - delete(key: string): void + delete(key: string): void; /** * Removes all stored entries from shared memory. * Use with caution — this clears all shared state. */ - clear(): void + clear(): void; /** * Returns a list of all stored keys. * @returns An array of key names. */ - keys(): string[] + keys(): string[]; /** * Creates or returns a namespaced shared memory store. @@ -623,7 +616,7 @@ export interface SharedMemory { * @param name The namespace identifier. * @returns A `SharedMemory` object scoped to the given namespace. */ - namespace(name: string): SharedMemory + namespace(name: string): SharedMemory; } /** @@ -647,4 +640,4 @@ export interface SharedMemory { * mokapi.log(`Current counter: ${count}`) * ``` */ -export const shared: SharedMemory \ No newline at end of file +export const shared: SharedMemory; \ No newline at end of file diff --git a/webui/e2e/home.spec.ts b/webui/e2e/home.spec.ts index 41f50acd9..bd43367ad 100644 --- a/webui/e2e/home.spec.ts +++ b/webui/e2e/home.spec.ts @@ -3,6 +3,6 @@ import { test, expect } from './models/fixture-website' test('home overview', async ({ home }) => { await home.open() - await expect(home.heroTitle).toHaveText('Mock and Take Control of APIs You Don’t Own') - await expect(home.heroDescription).toHaveText(`Build better software by mocking external APIs and testing without dependencies.Free, open-source, and under your control — your data is yours.`) + await expect(home.heroTitle).toHaveText('Mock APIs. Test Faster. Ship Better.') + await expect(home.heroDescription).toHaveText(`Test without external dependencies and build more reliable software.Free, open-source, and fully under your control.`) }) \ No newline at end of file diff --git a/webui/package-lock.json b/webui/package-lock.json index 19e5c76ee..230bd0123 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -12,7 +12,7 @@ "@ssthouse/vue3-tree-chart": "^0.3.0", "@types/bootstrap": "^5.2.10", "@types/whatwg-mimetype": "^3.0.2", - "ace-builds": "^1.43.4", + "ace-builds": "^1.43.5", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "dayjs": "^1.11.19", @@ -41,9 +41,9 @@ "eslint": "^9.39.1", "eslint-plugin-vue": "^10.6.2", "npm-run-all": "^4.1.5", - "prettier": "^3.6.2", + "prettier": "^3.7.4", "typescript": "~5.9.3", - "vite": "^7.2.4", + "vite": "^7.2.6", "vue-tsc": "^3.1.5", "xml2js": "^0.6.2" } @@ -1812,9 +1812,9 @@ } }, "node_modules/ace-builds": { - "version": "1.43.4", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.4.tgz", - "integrity": "sha512-8hAxVfo2ImICd69BWlZwZlxe9rxDGDjuUhh+WeWgGDvfBCE+r3lkynkQvIovDz4jcMi8O7bsEaFygaDT+h9sBA==", + "version": "1.43.5", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.5.tgz", + "integrity": "sha512-iH5FLBKdB7SVn9GR37UgA/tpQS8OTWIxWAuq3Ofaw+Qbc69FfPXsXd9jeW7KRG2xKpKMqBDnu0tHBrCWY5QI7A==", "license": "BSD-3-Clause" }, "node_modules/acorn": { @@ -4637,9 +4637,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "bin": { @@ -5371,9 +5371,9 @@ } }, "node_modules/vite": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", - "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", + "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/webui/package.json b/webui/package.json index 6c4de06d4..af0f0ad74 100644 --- a/webui/package.json +++ b/webui/package.json @@ -20,7 +20,7 @@ "@ssthouse/vue3-tree-chart": "^0.3.0", "@types/bootstrap": "^5.2.10", "@types/whatwg-mimetype": "^3.0.2", - "ace-builds": "^1.43.4", + "ace-builds": "^1.43.5", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "dayjs": "^1.11.19", @@ -49,9 +49,9 @@ "eslint": "^9.39.1", "eslint-plugin-vue": "^10.6.2", "npm-run-all": "^4.1.5", - "prettier": "^3.6.2", + "prettier": "^3.7.4", "typescript": "~5.9.3", - "vite": "^7.2.4", + "vite": "^7.2.6", "vue-tsc": "^3.1.5", "xml2js": "^0.6.2" } diff --git a/webui/public/.htaccess b/webui/public/.htaccess index a08523afc..f951f7940 100644 --- a/webui/public/.htaccess +++ b/webui/public/.htaccess @@ -44,11 +44,13 @@ 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] RewriteCond %{REQUEST_URI} !\.(html|css|js|less|jpg|png|gif|svg|woff2|xml)$ RewriteCond %{REQUEST_FILENAME} !^$ - RewriteCond %{REQUEST_FILENAME} !-f + # Only rewrite for bots IF the .html file exists + RewriteCond %{REQUEST_FILENAME}.html -f RewriteRule ^(.+)$ $1.html [L] - # rewrite for bots + # rewrite for bots for root page 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] + RewriteCond %{DOCUMENT_ROOT}/home.html -f RewriteRule ^$ home.html [L] # rewrite for spa vuejs diff --git a/webui/public/mokapi-using-as-proxy.png b/webui/public/mokapi-using-as-proxy.png new file mode 100644 index 000000000..31cb6a12e Binary files /dev/null and b/webui/public/mokapi-using-as-proxy.png differ diff --git a/webui/src/assets/main.css b/webui/src/assets/main.css index f851f7c5b..9bd9fd781 100644 --- a/webui/src/assets/main.css +++ b/webui/src/assets/main.css @@ -13,7 +13,7 @@ body { color: var(--color-text); background: var(--color-background); - font-family: Roboto Flex, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 1.0rem; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; diff --git a/webui/src/components/dashboard/http/RequestInfoCard.vue b/webui/src/components/dashboard/http/RequestInfoCard.vue index 37af97ae5..d8ef830f8 100644 --- a/webui/src/components/dashboard/http/RequestInfoCard.vue +++ b/webui/src/components/dashboard/http/RequestInfoCard.vue @@ -75,7 +75,7 @@ const apiRoute = computed(() => {

Duration

{{ duration(eventData.duration) }}

-
+

Client IP

{{ eventData.clientIP }}

diff --git a/webui/src/components/docs/Examples.vue b/webui/src/components/docs/Examples.vue index 77bc9f040..9610aba18 100644 --- a/webui/src/components/docs/Examples.vue +++ b/webui/src/components/docs/Examples.vue @@ -39,6 +39,12 @@ const items = computed(() => { } items.sort((x1, x2) => { + if (!x1 || !x1.meta || !x1.meta.title) { + console.error('missing meta title for ' + x1.key) + } + if (!x2 || !x2.meta || !x2.meta.title) { + console.error('missing meta title for ' + x2.key) + } return x1.meta.title.localeCompare(x2.meta.title) }) return items diff --git a/webui/src/views/DocsView.vue b/webui/src/views/DocsView.vue index c955d4042..b3d0897f3 100644 --- a/webui/src/views/DocsView.vue +++ b/webui/src/views/DocsView.vue @@ -202,17 +202,20 @@ function formatParam(label: any): string { font-size: 0.9rem; } .content { - line-height: 1.75; - font-size: 1rem; + line-height: 1.63; + font-size: 1.15rem; } .content h1 { - margin-bottom: 2.5rem; - margin-top: 1rem; + margin-top: 1.2rem; + margin-bottom: 1.6rem; + font-size: 2.25rem; } .content h2 { - margin-bottom: 1.5rem; + margin-top: 1.6rem; + margin-bottom: 1rem; + font-size: 1.8rem; } .content h2 > * { @@ -225,10 +228,22 @@ function formatParam(label: any): string { } .content h3 { - margin-top: 2.5rem; - margin-bottom: 1rem; + margin-top: 1.3rem; + margin-bottom: 0.75rem; + font-size: 1.4rem; } +.content p { + margin-bottom: 20px; +} + +.content ul { + padding-left: 1.5rem; + margin-top: 1.25rem; + margin-bottom: 1.5rem; +} + + .content a { color: var(--color-doc-link); } @@ -250,6 +265,7 @@ table { text-align: start; width: 100%; margin-bottom: 20px; + font-size: 1rem; } table.selectable td { cursor: pointer; diff --git a/webui/src/views/Home.vue b/webui/src/views/Home.vue index 1a5f9b2f7..73d920376 100644 --- a/webui/src/views/Home.vue +++ b/webui/src/views/Home.vue @@ -4,8 +4,8 @@ import Footer from '@/components/Footer.vue' import { ref, onMounted } from 'vue' import { Modal } from 'bootstrap' -const title = 'Mock APIs with Realistic Data | Mokapi Open Source Tool' -const description = `Mokapi is a developer-friendly open-source API mocking tool that allows you to prototype, test, and demonstrate APIs using realistic data scenarios.` +const title = 'Mock APIs with Realistic Test Data | Mokapi – Open-Source API Mocking Tool' +const description = `Mock any external API and test without real dependencies. Mokapi is free, open-source, and build for realistic, spec-driven test data.` useMeta(title, description, 'https://mokapi.io') @@ -38,14 +38,14 @@ function hasTouchSupport() {
-

Mock and Take Control of APIs You Don’t Own

+

Mock APIs. Test Faster. Ship Better.

-

Build better software by mocking external APIs and testing without dependencies.
Free, open-source, and under your control — your data is yours.

+

Test without external dependencies and build more reliable software.
Free, open-source, and fully under your control.