Skip to content

Commit 80f16aa

Browse files
gwleclercclaude
andcommitted
docs: add canonical mock JSON schema validated against fixtures
Add docs/mock.schema.json (draft 2020-12), derived from server/types. It is validated on the Go side by server/types/schema_test.go (santhosh-tekuri): every fixture under tests/data must match it, and clearly-invalid mocks are rejected. README documents it and the yaml-language-server $schema usage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 183b2e9 commit 80f16aa

5 files changed

Lines changed: 306 additions & 0 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ The documentation is available on [smocker.dev](https://smocker.dev).
2121
- [User Interface](#user-interface)
2222
- [Usage](#usage)
2323
- [Hello, World!](#hello-world)
24+
- [Mock format schema](#mock-format-schema)
2425
- [Development](#development)
2526
- [Backend](#backend)
2627
- [Frontend](#frontend)
@@ -133,6 +134,24 @@ curl -XPOST localhost:8081/reset
133134

134135
For more advanced usage, please read the [project's documentation](https://smocker.dev).
135136

137+
## Mock format schema
138+
139+
A JSON Schema describing the mock format lives at
140+
[`docs/mock.schema.json`](./docs/mock.schema.json). It is generated from and kept in sync with the
141+
Go types (`server/types`): the example mocks under `tests/data` are validated against it in CI, so
142+
it stays accurate. The documentation references this file as the canonical schema.
143+
144+
Editors can use it for autocompletion and validation. With the YAML language server (VS Code,
145+
Neovim, …), add this line at the top of a mocks file:
146+
147+
```yaml
148+
# yaml-language-server: $schema=https://raw.githubusercontent.com/smocker-dev/smocker/main/docs/mock.schema.json
149+
- request:
150+
path: /hello
151+
response:
152+
body: '{"message": "Hello, World!"}'
153+
```
154+
136155
## Development
137156
138157
### Backend

docs/mock.schema.json

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://raw.githubusercontent.com/smocker-dev/smocker/main/docs/mock.schema.json",
4+
"title": "Smocker mocks",
5+
"description": "Schema for Smocker mock definitions (a single mock or a list of mocks), as accepted by POST /mocks and stored in mocks.yml. Source of truth: server/types.",
6+
"oneOf": [
7+
{ "$ref": "#/$defs/mock" },
8+
{ "type": "array", "items": { "$ref": "#/$defs/mock" } }
9+
],
10+
"$defs": {
11+
"matcherName": {
12+
"type": "string",
13+
"enum": [
14+
"ShouldResemble",
15+
"ShouldAlmostEqual",
16+
"ShouldContainSubstring",
17+
"ShouldEndWith",
18+
"ShouldEqual",
19+
"ShouldEqualJSON",
20+
"ShouldStartWith",
21+
"ShouldBeEmpty",
22+
"ShouldMatch",
23+
"ShouldNotResemble",
24+
"ShouldNotAlmostEqual",
25+
"ShouldNotContainSubstring",
26+
"ShouldNotEndWith",
27+
"ShouldNotEqual",
28+
"ShouldNotStartWith",
29+
"ShouldNotBeEmpty",
30+
"ShouldNotMatch"
31+
]
32+
},
33+
"stringMatcherObject": {
34+
"type": "object",
35+
"properties": {
36+
"matcher": { "$ref": "#/$defs/matcherName" },
37+
"value": { "type": "string" }
38+
},
39+
"required": ["matcher"],
40+
"additionalProperties": false
41+
},
42+
"stringMatcher": {
43+
"description": "A plain string is shorthand for { matcher: ShouldEqual, value: <string> }.",
44+
"anyOf": [
45+
{ "type": "string" },
46+
{ "$ref": "#/$defs/stringMatcherObject" }
47+
]
48+
},
49+
"stringMatcherSlice": {
50+
"description": "A single matcher or a list of matchers.",
51+
"anyOf": [
52+
{ "$ref": "#/$defs/stringMatcher" },
53+
{ "type": "array", "items": { "$ref": "#/$defs/stringMatcher" } }
54+
]
55+
},
56+
"multimapMatcher": {
57+
"description": "Map of key to matcher(s), e.g. query parameters or headers to match.",
58+
"type": "object",
59+
"additionalProperties": { "$ref": "#/$defs/stringMatcherSlice" }
60+
},
61+
"multimap": {
62+
"description": "Map of key to string value(s), e.g. response headers.",
63+
"type": "object",
64+
"additionalProperties": {
65+
"anyOf": [
66+
{ "type": "string" },
67+
{ "type": "array", "items": { "type": "string" } }
68+
]
69+
}
70+
},
71+
"bodyMatcher": {
72+
"description": "Matches the request body: a matcher on the whole body (string shorthand or { matcher, value }), or a map of JSON paths to matchers.",
73+
"anyOf": [
74+
{ "type": "string" },
75+
{ "$ref": "#/$defs/stringMatcherObject" },
76+
{
77+
"type": "object",
78+
"additionalProperties": { "$ref": "#/$defs/stringMatcher" }
79+
}
80+
]
81+
},
82+
"duration": {
83+
"description": "A Go duration string (e.g. \"10ms\", \"1s\") or a number of nanoseconds.",
84+
"type": ["string", "integer"]
85+
},
86+
"delay": {
87+
"description": "A fixed delay, or a random delay between min and max.",
88+
"anyOf": [
89+
{ "$ref": "#/$defs/duration" },
90+
{
91+
"type": "object",
92+
"properties": {
93+
"min": { "$ref": "#/$defs/duration" },
94+
"max": { "$ref": "#/$defs/duration" }
95+
},
96+
"additionalProperties": false
97+
}
98+
]
99+
},
100+
"request": {
101+
"type": "object",
102+
"properties": {
103+
"method": { "$ref": "#/$defs/stringMatcher" },
104+
"path": { "$ref": "#/$defs/stringMatcher" },
105+
"body": { "$ref": "#/$defs/bodyMatcher" },
106+
"query_params": { "$ref": "#/$defs/multimapMatcher" },
107+
"headers": { "$ref": "#/$defs/multimapMatcher" }
108+
},
109+
"additionalProperties": false
110+
},
111+
"response": {
112+
"type": "object",
113+
"properties": {
114+
"body": { "type": "string" },
115+
"status": { "type": "integer" },
116+
"delay": { "$ref": "#/$defs/delay" },
117+
"headers": { "$ref": "#/$defs/multimap" }
118+
},
119+
"additionalProperties": false
120+
},
121+
"dynamicResponse": {
122+
"type": "object",
123+
"properties": {
124+
"engine": {
125+
"type": "string",
126+
"enum": ["go_template", "go_template_yaml", "go_template_json", "lua"]
127+
},
128+
"script": { "type": "string" }
129+
},
130+
"required": ["engine", "script"],
131+
"additionalProperties": false
132+
},
133+
"proxy": {
134+
"type": "object",
135+
"properties": {
136+
"host": { "type": "string" },
137+
"delay": { "$ref": "#/$defs/delay" },
138+
"follow_redirect": { "type": "boolean" },
139+
"skip_verify_tls": { "type": "boolean" },
140+
"keep_host": { "type": "boolean" },
141+
"headers": { "$ref": "#/$defs/multimap" }
142+
},
143+
"required": ["host"],
144+
"additionalProperties": false
145+
},
146+
"context": {
147+
"type": "object",
148+
"properties": {
149+
"times": { "type": "integer", "minimum": 0 }
150+
},
151+
"additionalProperties": false
152+
},
153+
"state": {
154+
"description": "Server-managed runtime state (present in stored/returned mocks, not written by hand).",
155+
"type": "object",
156+
"properties": {
157+
"id": { "type": "string" },
158+
"times_count": { "type": "integer" },
159+
"locked": { "type": "boolean" },
160+
"creation_date": { "type": "string" }
161+
},
162+
"additionalProperties": false
163+
},
164+
"mock": {
165+
"type": "object",
166+
"properties": {
167+
"request": { "$ref": "#/$defs/request" },
168+
"response": { "$ref": "#/$defs/response" },
169+
"dynamic_response": { "$ref": "#/$defs/dynamicResponse" },
170+
"proxy": { "$ref": "#/$defs/proxy" },
171+
"context": { "$ref": "#/$defs/context" },
172+
"state": { "$ref": "#/$defs/state" }
173+
},
174+
"additionalProperties": false,
175+
"oneOf": [
176+
{ "required": ["response"] },
177+
{ "required": ["dynamic_response"] },
178+
{ "required": ["proxy"] }
179+
]
180+
}
181+
}
182+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.26
55
require (
66
github.com/Masterminds/sprig/v3 v3.3.0
77
github.com/labstack/echo/v4 v4.15.4
8+
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
89
github.com/smarty/assertions v1.16.0
910
github.com/stretchr/objx v0.5.3
1011
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
1111
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
1212
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1313
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14+
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
15+
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
1416
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
1517
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
1618
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -41,6 +43,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
4143
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
4244
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
4345
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
46+
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
47+
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
4448
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
4549
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
4650
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=

server/types/schema_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package types
2+
3+
// Guards that docs/mock.schema.json stays accurate: every mock fixture under tests/data must
4+
// validate against it, and clearly-invalid mocks must be rejected. If a type/field changes in
5+
// server/types (and the fixtures/goldens follow), this fails until the published JSON Schema the
6+
// docs reference is updated to match — keeping it in sync with the real mock format.
7+
8+
import (
9+
"encoding/json"
10+
"os"
11+
"path/filepath"
12+
"testing"
13+
14+
"github.com/santhosh-tekuri/jsonschema/v6"
15+
"gopkg.in/yaml.v3"
16+
)
17+
18+
const mockSchemaPath = "../../docs/mock.schema.json"
19+
20+
func compileMockSchema(t *testing.T) *jsonschema.Schema {
21+
t.Helper()
22+
schemaData, err := os.ReadFile(mockSchemaPath)
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
var schemaDoc any
27+
if err := json.Unmarshal(schemaData, &schemaDoc); err != nil {
28+
t.Fatalf("invalid JSON in %s: %v", mockSchemaPath, err)
29+
}
30+
compiler := jsonschema.NewCompiler()
31+
if err := compiler.AddResource("mock.schema.json", schemaDoc); err != nil {
32+
t.Fatal(err)
33+
}
34+
schema, err := compiler.Compile("mock.schema.json")
35+
if err != nil {
36+
t.Fatalf("compile schema: %v", err)
37+
}
38+
return schema
39+
}
40+
41+
// asJSONValue parses a YAML document and re-encodes it through JSON so the instance uses
42+
// JSON-native types (float64 numbers, map[string]any) the validator expects.
43+
func asJSONValue(t *testing.T, raw []byte) any {
44+
t.Helper()
45+
var y any
46+
if err := yaml.Unmarshal(raw, &y); err != nil {
47+
t.Fatalf("unmarshal yaml: %v", err)
48+
}
49+
jsonBytes, err := json.Marshal(y)
50+
if err != nil {
51+
t.Fatal(err)
52+
}
53+
var instance any
54+
if err := json.Unmarshal(jsonBytes, &instance); err != nil {
55+
t.Fatal(err)
56+
}
57+
return instance
58+
}
59+
60+
func TestMockFixturesMatchJSONSchema(t *testing.T) {
61+
schema := compileMockSchema(t)
62+
entries, err := os.ReadDir(dataDir) // dataDir defined in serialization_test.go
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
for _, e := range entries {
67+
if e.IsDir() || filepath.Ext(e.Name()) != ".yml" {
68+
continue
69+
}
70+
name := e.Name()
71+
t.Run(name, func(t *testing.T) {
72+
raw, err := os.ReadFile(filepath.Join(dataDir, name))
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
if err := schema.Validate(asJSONValue(t, raw)); err != nil {
77+
t.Fatalf("%s does not match docs/mock.schema.json:\n%v", name, err)
78+
}
79+
})
80+
}
81+
}
82+
83+
func TestMockSchemaRejectsInvalidMocks(t *testing.T) {
84+
schema := compileMockSchema(t)
85+
cases := map[string]string{
86+
"unknown matcher": `[{request: {method: {matcher: DoesNotExist, value: GET}}, response: {status: 200}}]`,
87+
"no response/dynamic_response/proxy": `[{request: {path: /x}}]`,
88+
"both response and proxy": `[{request: {path: /x}, response: {status: 200}, proxy: {host: http://x}}]`,
89+
"unknown top-level field": `[{request: {path: /x}, response: {status: 200}, bogus: true}]`,
90+
"non-integer status": `[{request: {path: /x}, response: {status: "two hundred"}}]`,
91+
"invalid engine": `[{request: {path: /x}, dynamic_response: {engine: python, script: ""}}]`,
92+
}
93+
for name, doc := range cases {
94+
t.Run(name, func(t *testing.T) {
95+
if err := schema.Validate(asJSONValue(t, []byte(doc))); err == nil {
96+
t.Fatalf("expected the schema to reject an invalid mock (%s), but it passed", name)
97+
}
98+
})
99+
}
100+
}

0 commit comments

Comments
 (0)