Skip to content

Commit fc30420

Browse files
committed
fix: template engine, chaos API, and Mockoon import bugs
- Case-insensitive faker resolution (faker.Name == faker.name) - Add randomInt/randomString/randomFloat aliases and faker.url - Add random.float/randomFloat to space-separated handler - ChaosFaultConfig custom UnmarshalJSON absorbs flat config fields - Mockoon import converts to dot-notation (request.pathParam.id) - Mockoon import maps query/header/body to correct engine syntax - Add mockoon format to import help text and docs
1 parent 8bb6b34 commit fc30420

7 files changed

Lines changed: 367 additions & 39 deletions

File tree

pkg/api/types/responses.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package types
44

55
import (
6+
"encoding/json"
67
"time"
78

89
"github.com/getmockd/mockd/pkg/config"
@@ -237,6 +238,70 @@ type ChaosFaultConfig struct {
237238
Config map[string]any `json:"config,omitempty"`
238239
}
239240

241+
// knownFaultFields lists the JSON keys that map to struct fields on ChaosFaultConfig.
242+
// Everything else is treated as a fault-specific config parameter and gets merged
243+
// into the Config map so users don't have to nest them under "config".
244+
var knownFaultFields = map[string]bool{
245+
"type": true,
246+
"probability": true,
247+
"config": true,
248+
}
249+
250+
// UnmarshalJSON accepts both the canonical format (config params nested under "config")
251+
// and the flat format (config params alongside "type" and "probability").
252+
//
253+
// Canonical: {"type":"circuit_breaker","probability":1.0,"config":{"tripAfter":3}}
254+
// Flat: {"type":"circuit_breaker","probability":1.0,"tripAfter":3}
255+
//
256+
// When both forms are present for the same key, the explicit "config" value wins.
257+
func (f *ChaosFaultConfig) UnmarshalJSON(data []byte) error {
258+
// Use an alias to avoid infinite recursion.
259+
type Alias ChaosFaultConfig
260+
var alias Alias
261+
if err := json.Unmarshal(data, &alias); err != nil {
262+
return err
263+
}
264+
265+
// Decode the full blob to catch any extra keys.
266+
var raw map[string]json.RawMessage
267+
if err := json.Unmarshal(data, &raw); err != nil {
268+
return err
269+
}
270+
271+
// Collect unknown top-level keys as config params.
272+
var extras map[string]any
273+
for key, val := range raw {
274+
if knownFaultFields[key] {
275+
continue
276+
}
277+
if extras == nil {
278+
extras = make(map[string]any)
279+
}
280+
var decoded any
281+
if err := json.Unmarshal(val, &decoded); err != nil {
282+
return err
283+
}
284+
extras[key] = decoded
285+
}
286+
287+
// Merge: extras form the base, explicit "config" keys override.
288+
if len(extras) > 0 {
289+
if alias.Config == nil {
290+
alias.Config = extras
291+
} else {
292+
// Explicit "config" wins — layer it on top of the extras.
293+
for k, v := range extras {
294+
if _, exists := alias.Config[k]; !exists {
295+
alias.Config[k] = v
296+
}
297+
}
298+
}
299+
}
300+
301+
*f = ChaosFaultConfig(alias)
302+
return nil
303+
}
304+
240305
// ChaosStats represents chaos injection statistics.
241306
type ChaosStats struct {
242307
TotalRequests int64 `json:"totalRequests"`

pkg/api/types/responses_test.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,234 @@ func TestPaginatedResponse(t *testing.T) {
221221
assert.Equal(t, 100, result.Total)
222222
assert.Equal(t, []string{"a", "b", "c"}, result.Items)
223223
}
224+
225+
// --- ChaosFaultConfig UnmarshalJSON Tests ---
226+
227+
func TestChaosFaultConfig_UnmarshalJSON_Canonical(t *testing.T) {
228+
t.Parallel()
229+
230+
input := `{"type":"circuit_breaker","probability":1.0,"config":{"tripAfter":3,"openDuration":"10s"}}`
231+
232+
var f ChaosFaultConfig
233+
err := json.Unmarshal([]byte(input), &f)
234+
require.NoError(t, err)
235+
236+
assert.Equal(t, "circuit_breaker", f.Type)
237+
assert.Equal(t, 1.0, f.Probability)
238+
assert.Equal(t, float64(3), f.Config["tripAfter"])
239+
assert.Equal(t, "10s", f.Config["openDuration"])
240+
}
241+
242+
func TestChaosFaultConfig_UnmarshalJSON_Flat(t *testing.T) {
243+
t.Parallel()
244+
245+
// User sends config params at top level — the common mistake / natural format.
246+
input := `{"type":"circuit_breaker","probability":1.0,"tripAfter":3,"openDuration":"10s"}`
247+
248+
var f ChaosFaultConfig
249+
err := json.Unmarshal([]byte(input), &f)
250+
require.NoError(t, err)
251+
252+
assert.Equal(t, "circuit_breaker", f.Type)
253+
assert.Equal(t, 1.0, f.Probability)
254+
assert.Equal(t, float64(3), f.Config["tripAfter"])
255+
assert.Equal(t, "10s", f.Config["openDuration"])
256+
}
257+
258+
func TestChaosFaultConfig_UnmarshalJSON_BothFlatAndNested(t *testing.T) {
259+
t.Parallel()
260+
261+
// Explicit "config" value wins when both are present for the same key.
262+
input := `{
263+
"type": "circuit_breaker",
264+
"probability": 1.0,
265+
"tripAfter": 5,
266+
"openStatusCode": 502,
267+
"config": {
268+
"tripAfter": 3,
269+
"openDuration": "10s"
270+
}
271+
}`
272+
273+
var f ChaosFaultConfig
274+
err := json.Unmarshal([]byte(input), &f)
275+
require.NoError(t, err)
276+
277+
assert.Equal(t, "circuit_breaker", f.Type)
278+
assert.Equal(t, 1.0, f.Probability)
279+
// Explicit config wins for tripAfter
280+
assert.Equal(t, float64(3), f.Config["tripAfter"])
281+
// openDuration comes from config
282+
assert.Equal(t, "10s", f.Config["openDuration"])
283+
// openStatusCode comes from flat (not in config)
284+
assert.Equal(t, float64(502), f.Config["openStatusCode"])
285+
}
286+
287+
func TestChaosFaultConfig_UnmarshalJSON_NoExtras(t *testing.T) {
288+
t.Parallel()
289+
290+
// Basic fault with no config params at all — Config stays nil.
291+
input := `{"type":"timeout","probability":0.5}`
292+
293+
var f ChaosFaultConfig
294+
err := json.Unmarshal([]byte(input), &f)
295+
require.NoError(t, err)
296+
297+
assert.Equal(t, "timeout", f.Type)
298+
assert.Equal(t, 0.5, f.Probability)
299+
assert.Nil(t, f.Config)
300+
}
301+
302+
func TestChaosFaultConfig_UnmarshalJSON_EmptyConfig(t *testing.T) {
303+
t.Parallel()
304+
305+
input := `{"type":"latency","probability":0.3,"config":{}}`
306+
307+
var f ChaosFaultConfig
308+
err := json.Unmarshal([]byte(input), &f)
309+
require.NoError(t, err)
310+
311+
assert.Equal(t, "latency", f.Type)
312+
assert.Equal(t, 0.3, f.Probability)
313+
// Empty config stays as empty map (not nil) since it was explicitly provided.
314+
assert.NotNil(t, f.Config)
315+
assert.Empty(t, f.Config)
316+
}
317+
318+
func TestChaosFaultConfig_UnmarshalJSON_RetryAfterFlat(t *testing.T) {
319+
t.Parallel()
320+
321+
input := `{"type":"retry_after","probability":1.0,"statusCode":429,"retryAfter":"30s","body":"rate limited"}`
322+
323+
var f ChaosFaultConfig
324+
err := json.Unmarshal([]byte(input), &f)
325+
require.NoError(t, err)
326+
327+
assert.Equal(t, "retry_after", f.Type)
328+
assert.Equal(t, float64(429), f.Config["statusCode"])
329+
assert.Equal(t, "30s", f.Config["retryAfter"])
330+
assert.Equal(t, "rate limited", f.Config["body"])
331+
}
332+
333+
func TestChaosFaultConfig_UnmarshalJSON_ProgressiveDegradationFlat(t *testing.T) {
334+
t.Parallel()
335+
336+
input := `{
337+
"type": "progressive_degradation",
338+
"probability": 1.0,
339+
"initialDelay": "20ms",
340+
"delayIncrement": "5ms",
341+
"maxDelay": "5s",
342+
"errorAfter": 100
343+
}`
344+
345+
var f ChaosFaultConfig
346+
err := json.Unmarshal([]byte(input), &f)
347+
require.NoError(t, err)
348+
349+
assert.Equal(t, "progressive_degradation", f.Type)
350+
assert.Equal(t, "20ms", f.Config["initialDelay"])
351+
assert.Equal(t, "5ms", f.Config["delayIncrement"])
352+
assert.Equal(t, "5s", f.Config["maxDelay"])
353+
assert.Equal(t, float64(100), f.Config["errorAfter"])
354+
}
355+
356+
func TestChaosFaultConfig_UnmarshalJSON_InvalidJSON(t *testing.T) {
357+
t.Parallel()
358+
359+
input := `{"type":"circuit_breaker","probability":}`
360+
361+
var f ChaosFaultConfig
362+
err := json.Unmarshal([]byte(input), &f)
363+
assert.Error(t, err)
364+
}
365+
366+
func TestChaosFaultConfig_UnmarshalJSON_NestedArray(t *testing.T) {
367+
t.Parallel()
368+
369+
// ChaosRuleConfig.Faults is a slice of ChaosFaultConfig — ensure the
370+
// custom UnmarshalJSON works correctly when deserialized as part of a parent struct.
371+
input := `{
372+
"pathPattern": "/api/.*",
373+
"faults": [
374+
{"type":"circuit_breaker","probability":1.0,"tripAfter":3},
375+
{"type":"latency","probability":0.5,"config":{"min":"100ms","max":"500ms"}}
376+
]
377+
}`
378+
379+
var rule ChaosRuleConfig
380+
err := json.Unmarshal([]byte(input), &rule)
381+
require.NoError(t, err)
382+
383+
require.Len(t, rule.Faults, 2)
384+
385+
// First fault: flat format
386+
assert.Equal(t, "circuit_breaker", rule.Faults[0].Type)
387+
assert.Equal(t, float64(3), rule.Faults[0].Config["tripAfter"])
388+
389+
// Second fault: canonical format
390+
assert.Equal(t, "latency", rule.Faults[1].Type)
391+
assert.Equal(t, "100ms", rule.Faults[1].Config["min"])
392+
assert.Equal(t, "500ms", rule.Faults[1].Config["max"])
393+
}
394+
395+
func TestChaosFaultConfig_UnmarshalJSON_FullChaosConfig(t *testing.T) {
396+
t.Parallel()
397+
398+
// End-to-end: full ChaosConfig with flat fault params, as a user would send it.
399+
input := `{
400+
"enabled": true,
401+
"rules": [
402+
{
403+
"pathPattern": "/api/orders.*",
404+
"faults": [
405+
{
406+
"type": "circuit_breaker",
407+
"probability": 1.0,
408+
"tripAfter": 3,
409+
"openDuration": "10s",
410+
"openStatusCode": 503
411+
}
412+
]
413+
}
414+
]
415+
}`
416+
417+
var cfg ChaosConfig
418+
err := json.Unmarshal([]byte(input), &cfg)
419+
require.NoError(t, err)
420+
421+
require.True(t, cfg.Enabled)
422+
require.Len(t, cfg.Rules, 1)
423+
require.Len(t, cfg.Rules[0].Faults, 1)
424+
425+
fault := cfg.Rules[0].Faults[0]
426+
assert.Equal(t, "circuit_breaker", fault.Type)
427+
assert.Equal(t, 1.0, fault.Probability)
428+
assert.Equal(t, float64(3), fault.Config["tripAfter"])
429+
assert.Equal(t, "10s", fault.Config["openDuration"])
430+
assert.Equal(t, float64(503), fault.Config["openStatusCode"])
431+
}
432+
433+
func TestChaosFaultConfig_MarshalJSON_RoundTrip(t *testing.T) {
434+
t.Parallel()
435+
436+
// Marshal always produces the canonical format with nested "config".
437+
original := ChaosFaultConfig{
438+
Type: "circuit_breaker",
439+
Probability: 1.0,
440+
Config: map[string]any{"tripAfter": float64(3), "openDuration": "10s"},
441+
}
442+
443+
data, err := json.Marshal(original)
444+
require.NoError(t, err)
445+
446+
var roundTripped ChaosFaultConfig
447+
err = json.Unmarshal(data, &roundTripped)
448+
require.NoError(t, err)
449+
450+
assert.Equal(t, original.Type, roundTripped.Type)
451+
assert.Equal(t, original.Probability, roundTripped.Probability)
452+
assert.Equal(t, original.Config["tripAfter"], roundTripped.Config["tripAfter"])
453+
assert.Equal(t, original.Config["openDuration"], roundTripped.Config["openDuration"])
454+
}

pkg/cli/bridge.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ Supported Formats:
8686
postman Postman Collection v2.x
8787
har HTTP Archive (browser recordings)
8888
wiremock WireMock JSON mappings
89+
mockoon Mockoon environment JSON
8990
curl cURL command
9091
wsdl WSDL 1.1 service definition (generates SOAP mocks)`,
9192
Example: ` # Import from OpenAPI spec (auto-detected)
@@ -97,6 +98,9 @@ Supported Formats:
9798
# Import from HAR file including static assets
9899
mockd import recording.har --include-static
99100
101+
# Import from Mockoon environment
102+
mockd import environment.json -f mockoon
103+
100104
# Import from cURL command
101105
mockd import "curl -X POST https://api.example.com/users -H 'Content-Type: application/json' -d '{\"name\": \"test\"}'"
102106

pkg/cli/doc.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// - list: Display all configured mocks
88
// - get: Show details of a specific mock
99
// - delete: Remove a mock by ID
10-
// - import: Import mocks from various formats (OpenAPI, Postman, HAR, WireMock, cURL)
10+
// - import: Import mocks from various formats (OpenAPI, Postman, HAR, WireMock, Mockoon, cURL)
1111
// - export: Export mocks to native format or OpenAPI
1212
// - new: Create new mock collections from templates (blank, crud, auth, pagination, errors)
1313
// - generate: AI-powered mock generation from OpenAPI specs or descriptions
@@ -48,6 +48,7 @@
4848
// - postman: Postman Collection v2.x
4949
// - har: HTTP Archive (browser recordings)
5050
// - wiremock: WireMock JSON mappings
51+
// - mockoon: Mockoon environment JSON
5152
// - curl: cURL commands
5253
//
5354
// Export formats:

pkg/portability/mockoon.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -386,16 +386,16 @@ var mockoonHelperPatterns = []struct {
386386
pattern *regexp.Regexp
387387
replacement string
388388
}{
389-
// Request data helpers
390-
{regexp.MustCompile(`\{\{urlParam\s+'(\w+)'\}\}`), `{{ request.pathParam "$1" }}`},
391-
{regexp.MustCompile(`\{\{urlParam\s+"(\w+)"\}\}`), `{{ request.pathParam "$1" }}`},
392-
{regexp.MustCompile(`\{\{queryParam\s+'(\w+)'\}\}`), `{{ request.queryParam "$1" }}`},
393-
{regexp.MustCompile(`\{\{queryParam\s+"(\w+)"\}\}`), `{{ request.queryParam "$1" }}`},
394-
{regexp.MustCompile(`\{\{header\s+'([^']+)'\}\}`), `{{ request.header "$1" }}`},
395-
{regexp.MustCompile(`\{\{header\s+"([^"]+)"\}\}`), `{{ request.header "$1" }}`},
396-
{regexp.MustCompile(`\{\{body\s+'([^']+)'\}\}`), `{{ request.body "$.$1" }}`},
397-
{regexp.MustCompile(`\{\{body\s+"([^"]+)"\}\}`), `{{ request.body "$.$1" }}`},
398-
{regexp.MustCompile(`\{\{bodyRaw\}\}`), `{{ request.rawBody }}`},
389+
// Request data helpers — mockd uses dot-notation: {{request.pathParam.id}}, {{request.query.page}}, etc.
390+
{regexp.MustCompile(`\{\{urlParam\s+'(\w+)'\}\}`), `{{request.pathParam.$1}}`},
391+
{regexp.MustCompile(`\{\{urlParam\s+"(\w+)"\}\}`), `{{request.pathParam.$1}}`},
392+
{regexp.MustCompile(`\{\{queryParam\s+'(\w+)'\}\}`), `{{request.query.$1}}`},
393+
{regexp.MustCompile(`\{\{queryParam\s+"(\w+)"\}\}`), `{{request.query.$1}}`},
394+
{regexp.MustCompile(`\{\{header\s+'([^']+)'\}\}`), `{{request.header.$1}}`},
395+
{regexp.MustCompile(`\{\{header\s+"([^"]+)"\}\}`), `{{request.header.$1}}`},
396+
{regexp.MustCompile(`\{\{body\s+'([^']+)'\}\}`), `{{request.body.$1}}`},
397+
{regexp.MustCompile(`\{\{body\s+"([^"]+)"\}\}`), `{{request.body.$1}}`},
398+
{regexp.MustCompile(`\{\{bodyRaw\}\}`), `{{request.rawBody}}`},
399399

400400
// Utility helpers
401401
{regexp.MustCompile(`\{\{uuid\}\}`), `{{ uuid }}`},
@@ -412,7 +412,7 @@ var mockoonHelperPatterns = []struct {
412412
{regexp.MustCompile(`\{\{faker\s+"person\.fullName"\}\}`), `{{ faker.name }}`},
413413
{regexp.MustCompile(`\{\{faker\s+'internet\.email'\}\}`), `{{ faker.email }}`},
414414
{regexp.MustCompile(`\{\{faker\s+"internet\.email"\}\}`), `{{ faker.email }}`},
415-
{regexp.MustCompile(`\{\{faker\s+'internet\.url'\}\}`), `{{ faker.sentence }}`}, // no URL faker yet
415+
{regexp.MustCompile(`\{\{faker\s+'internet\.url'\}\}`), `{{faker.url}}`},
416416
{regexp.MustCompile(`\{\{faker\s+'internet\.ip'\}\}`), `{{ faker.ipv4 }}`},
417417
{regexp.MustCompile(`\{\{faker\s+"internet\.ip"\}\}`), `{{ faker.ipv4 }}`},
418418
{regexp.MustCompile(`\{\{faker\s+'internet\.ipv4'\}\}`), `{{ faker.ipv4 }}`},

0 commit comments

Comments
 (0)