Skip to content

Commit d469220

Browse files
gwleclercclaude
andcommitted
feat(server): load mocks from a file at startup (--init-mocks)
Add --init-mocks / SMOCKER_INIT_MOCKS: a YAML file of mocks (the exact POST /mocks format) loaded into a fresh "init-mocks" session when the mocks service is constructed. Useful for declarative, ephemeral setups (e.g. a Kubernetes ConfigMap) where mocks should be present on boot without an API call. It's read-only (never written back) and mutually exclusive with --persistence-directory: seeding a fixed set and resuming/rewriting persisted state are opposite intents, so setting both fails fast at startup. Addresses #217 / #222. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 74d4a9d commit d469220

5 files changed

Lines changed: 130 additions & 6 deletions

File tree

main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func parseConfig() (c config.Config) {
4040
fs.StringVar(&c.StaticFiles, "static-files", "client", "Location of the static files to serve (index.html, etc.)")
4141
fs.IntVar(&c.HistoryMaxRetention, "history-retention", 0, "Maximum number of calls to keep in the history per session (0 = no limit)")
4242
fs.StringVar(&c.PersistenceDirectory, "persistence-directory", "", "If defined, the directory where the sessions will be synchronized")
43+
fs.StringVar(&c.InitMocks, "init-mocks", "", "If defined, a YAML file of mocks (same format as POST /mocks) loaded into a session at startup; mutually exclusive with --persistence-directory")
4344
fs.BoolVar(&c.TLSEnable, "tls-enable", false, "Enable TLS using the provided certificate")
4445
fs.StringVar(&c.TLSCertFile, "tls-cert-file", "/etc/smocker/tls/certs/cert.pem", "Path to TLS certificate file ")
4546
fs.StringVar(&c.TLSKeyFile, "tls-private-key-file", "/etc/smocker/tls/private/key.pem", "Path to TLS key file")
@@ -104,5 +105,14 @@ func setupLogger(logLevel string) {
104105
func main() {
105106
c := parseConfig()
106107
setupLogger(c.LogLevel)
108+
109+
// Loading mocks at startup and persisting sessions are opposite intents: a seed file is a
110+
// fixed, read-only starting point, while persistence resumes (and rewrites) whatever state it
111+
// last held. Both define the boot state, so allowing both would be ambiguous on restart.
112+
if c.InitMocks != "" && c.PersistenceDirectory != "" {
113+
slog.Error("--init-mocks and --persistence-directory are mutually exclusive: seed mocks from a file (ephemeral) or persist sessions to a directory, not both")
114+
os.Exit(1)
115+
}
116+
107117
server.Serve(c)
108118
}

server/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type Config struct {
88
StaticFiles string
99
HistoryMaxRetention int
1010
PersistenceDirectory string
11+
InitMocks string
1112
TLSEnable bool
1213
TLSCertFile string
1314
TLSKeyFile string

server/mock_server.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package server
33
import (
44
"log/slog"
55
"net/http"
6+
"os"
67
"strconv"
78

89
"github.com/labstack/echo/v4"
@@ -18,7 +19,11 @@ func NewMockServer(cfg config.Config) (*http.Server, services.Mocks) {
1819
if err != nil {
1920
slog.Error("Unable to load sessions", "error", err)
2021
}
21-
mockServices := services.NewMocks(sessions, cfg.HistoryMaxRetention, persistence)
22+
mockServices, err := services.NewMocks(sessions, cfg.HistoryMaxRetention, persistence, cfg.InitMocks)
23+
if err != nil {
24+
slog.Error("Unable to load initial mocks", "error", err, "init-mocks", cfg.InitMocks)
25+
os.Exit(1)
26+
}
2227

2328
mockServerEngine.HideBanner = true
2429
mockServerEngine.HidePort = true

server/services/mocks.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ package services
22

33
import (
44
"fmt"
5+
"log/slog"
6+
"os"
57
"regexp"
68
"strings"
79
"sync"
810
"time"
911

1012
"github.com/smocker-dev/smocker/server/types"
13+
"gopkg.in/yaml.v3"
1114
)
1215

1316
var (
@@ -44,7 +47,7 @@ type mocks struct {
4447
persistence Persistence
4548
}
4649

47-
func NewMocks(sessions types.Sessions, historyRetention int, persistence Persistence) Mocks {
50+
func NewMocks(sessions types.Sessions, historyRetention int, persistence Persistence, initMocksFile string) (Mocks, error) {
4851
s := &mocks{
4952
sessions: types.Sessions{},
5053
historyRetention: historyRetention,
@@ -53,7 +56,42 @@ func NewMocks(sessions types.Sessions, historyRetention int, persistence Persist
5356
if sessions != nil {
5457
s.sessions = sessions
5558
}
56-
return s
59+
if initMocksFile != "" {
60+
if err := s.seedFromFile(initMocksFile); err != nil {
61+
return nil, err
62+
}
63+
}
64+
return s, nil
65+
}
66+
67+
// seedFromFile loads mocks from a YAML file (the POST /mocks format) into a fresh "init-mocks"
68+
// session. Backs the --init-mocks startup flag; the mocks are never written back.
69+
func (s *mocks) seedFromFile(path string) error {
70+
file, err := os.Open(path)
71+
if err != nil {
72+
return err
73+
}
74+
defer file.Close()
75+
76+
var loaded types.Mocks
77+
if err := yaml.NewDecoder(file).Decode(&loaded); err != nil {
78+
return err
79+
}
80+
for _, mock := range loaded {
81+
if err := mock.Validate(); err != nil {
82+
return err
83+
}
84+
}
85+
86+
sessionID := s.NewSession("init-mocks").ID
87+
for _, mock := range loaded {
88+
if _, err := s.AddMock(sessionID, mock); err != nil {
89+
return err
90+
}
91+
}
92+
93+
slog.Info("Loaded initial mocks", "count", len(loaded), "init-mocks", path)
94+
return nil
5795
}
5896

5997
func (s *mocks) AddMock(sessionID string, newMock *types.Mock) (*types.Mock, error) {

server/services/mocks_test.go

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
package services
22

33
import (
4+
"os"
5+
"path/filepath"
46
"testing"
57

68
"github.com/smocker-dev/smocker/server/types"
79
"gopkg.in/yaml.v3"
810
)
911

12+
func newTestMocks(t *testing.T) Mocks {
13+
t.Helper()
14+
svc, err := NewMocks(nil, 0, NewPersistence(""), "")
15+
if err != nil {
16+
t.Fatalf("NewMocks: %v", err)
17+
}
18+
return svc
19+
}
20+
1021
func mockFromYAML(t *testing.T, s string) *types.Mock {
1122
t.Helper()
1223
var m types.Mock
@@ -16,10 +27,69 @@ func mockFromYAML(t *testing.T, s string) *types.Mock {
1627
return &m
1728
}
1829

30+
// TestNewMocksInitFile covers the --init-mocks path: a YAML file of mocks (the POST /mocks format)
31+
// is loaded into a fresh "init-mocks" session when the service is constructed.
32+
func TestNewMocksInitFile(t *testing.T) {
33+
path := filepath.Join(t.TempDir(), "mocks.yml")
34+
content := `
35+
- request:
36+
method: GET
37+
path: /hello
38+
response:
39+
status: 200
40+
body: hi
41+
- request:
42+
method: GET
43+
path: /health
44+
response:
45+
status: 204
46+
`
47+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
48+
t.Fatal(err)
49+
}
50+
51+
svc, err := NewMocks(nil, 0, NewPersistence(""), path)
52+
if err != nil {
53+
t.Fatalf("NewMocks: %v", err)
54+
}
55+
56+
session := svc.GetLastSession()
57+
if session == nil {
58+
t.Fatal("expected a seeded session")
59+
}
60+
if session.Name != "init-mocks" {
61+
t.Fatalf("expected the seeded session to be named %q, got %q", "init-mocks", session.Name)
62+
}
63+
mocks, err := svc.GetMocks(session.ID)
64+
if err != nil {
65+
t.Fatalf("GetMocks: %v", err)
66+
}
67+
if len(mocks) != 2 {
68+
t.Fatalf("expected 2 seeded mocks, got %d", len(mocks))
69+
}
70+
}
71+
72+
func TestNewMocksInitFileMissing(t *testing.T) {
73+
if _, err := NewMocks(nil, 0, NewPersistence(""), filepath.Join(t.TempDir(), "nope.yml")); err == nil {
74+
t.Fatal("expected an error for a missing file")
75+
}
76+
}
77+
78+
func TestNewMocksInitFileInvalid(t *testing.T) {
79+
path := filepath.Join(t.TempDir(), "mocks.yml")
80+
// A mock with neither response, proxy, nor dynamic_response fails Validate().
81+
if err := os.WriteFile(path, []byte("- request: {path: /a}\n"), 0o644); err != nil {
82+
t.Fatal(err)
83+
}
84+
if _, err := NewMocks(nil, 0, NewPersistence(""), path); err == nil {
85+
t.Fatal("expected a validation error for an incomplete mock")
86+
}
87+
}
88+
1989
// TestUpdateAndDeleteMock covers the happy path for the edit/delete feature (issues #299/#266/#303)
2090
// while the session history is still empty.
2191
func TestUpdateAndDeleteMock(t *testing.T) {
22-
svc := NewMocks(nil, 0, NewPersistence(""))
92+
svc := newTestMocks(t)
2393
session := svc.NewSession("edit")
2494

2595
added, err := svc.AddMock(
@@ -71,7 +141,7 @@ func TestUpdateAndDeleteMock(t *testing.T) {
71141

72142
// TestDeleteSession covers removing a single session and the not-found path.
73143
func TestDeleteSession(t *testing.T) {
74-
svc := NewMocks(nil, 0, NewPersistence(""))
144+
svc := newTestMocks(t)
75145
keep := svc.NewSession("keep")
76146
drop := svc.NewSession("drop")
77147

@@ -95,7 +165,7 @@ func TestDeleteSession(t *testing.T) {
95165
// TestEditForbiddenOnceCalled locks the maintainers' position: once the session has received calls,
96166
// mocks are append-only (history entries are tied to the mock that answered them).
97167
func TestEditForbiddenOnceCalled(t *testing.T) {
98-
svc := NewMocks(nil, 0, NewPersistence(""))
168+
svc := newTestMocks(t)
99169
session := svc.NewSession("called")
100170
added, err := svc.AddMock(
101171
session.ID,

0 commit comments

Comments
 (0)