Skip to content

Commit 566f16d

Browse files
authored
feat: JSON schemas (#70)
* Read JSON schemas * Separate validation from parsing * Stop using yaml.MapSlice * Remove WeightsFromYAML and ToYAML * Write JSON by default, but continue writing YAML if yaml exists * Bump the version * Fix segfault in `testtrack schema generate` (unrelated fix) * Fix broken symlink detection logic * Change the signature for `findSchemaPath` * Update docs/help information to include schema.json
1 parent d1a4701 commit 566f16d

16 files changed

Lines changed: 116 additions & 97 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
SHELL = /bin/sh
22

3-
VERSION=1.7.1
3+
VERSION=1.8.0
44
BUILD=`git rev-parse HEAD`
55

66
LDFLAGS=-ldflags "-w -s \

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ If you have a large organization, you may wish to tag ownership of splits to a s
102102

103103
### Syncing split assignments
104104

105-
If you want to ensure that your local split assignments are in sync with your remote (production) assignments, you can run `TESTTRACK_CLI_URL=<base_url> testtrack sync` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack sync`) from your project directory to pull the assignments from your remote server into your local `schema.yml` file.
105+
If you want to ensure that your local split assignments are in sync with your remote (production) assignments, you can run `TESTTRACK_CLI_URL=<base_url> testtrack sync` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack sync`) from your project directory to pull the assignments from your remote server into your local `schema.{json,yml}` file.
106106

107107
## How to Contribute
108108

cmds/schema_generate.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import (
77

88
var schemaGenerateDoc = `
99
Reads the migrations in testtrack/migrate and writes the resulting schema state
10-
to testtrack/schema.yml, overwriting the file if it already exists. Generate
10+
to testtrack/schema.{json,yml}, overwriting the file if it already exists. Generate
1111
makes no TestTrack API calls.
1212
13-
In addition to refreshing a schema.yml file that may have been corrupted due to
13+
In addition to refreshing a schema file that may have been corrupted due to
1414
a bad merge or bug that produced incorrect schema state, 'schema generate' will
1515
also validate that migrations merged from multiple development branches don't
1616
logically conflict, or else it will fail with errors.
@@ -30,7 +30,7 @@ func init() {
3030

3131
var schemaGenerateCmd = &cobra.Command{
3232
Use: "generate",
33-
Short: "Generate schema.yml from migration files",
33+
Short: "Generate schema.{json,yml} from migration files",
3434
Long: schemaGenerateDoc,
3535
Args: cobra.NoArgs,
3636
RunE: func(cmd *cobra.Command, _ []string) error {

cmds/schema_load.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
)
77

88
var schemaLoadDoc = `
9-
Loads the testtrack/schema.yml state into TestTrack server. This operation is
9+
Loads the testtrack/schema.{json,yml} state into TestTrack server. This operation is
1010
idempotent with a valid, consistent schema file, though might fail if your
1111
schema file became invalid due to a bad merge or a bug.
1212
@@ -27,7 +27,7 @@ func init() {
2727

2828
var schemaLoadCmd = &cobra.Command{
2929
Use: "load",
30-
Short: "Load schema.yml state into TestTrack server",
30+
Short: "Load schema.{json,yml} state into TestTrack server",
3131
Long: schemaLoadDoc,
3232
Args: cobra.NoArgs,
3333
RunE: func(cmd *cobra.Command, _ []string) error {

cmds/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
)
77

88
var serverDoc = `
9-
Run a fake TestTrack server for local development, backed by schema.yml files
9+
Run a fake TestTrack server for local development, backed by schema.{json,yml} files
1010
and nonsense.
1111
`
1212

cmds/sync.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func Sync() error {
4848
remoteSplit, exists := splitRegistry.Splits[localSplit.Name]
4949
if exists {
5050
remoteWeights := splits.Weights(remoteSplit.Weights)
51-
localSchema.Splits[ind].Weights = remoteWeights.ToYAML()
51+
localSchema.Splits[ind].Weights = remoteWeights
5252
}
5353
}
5454

fakeserver/routes.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ func getV1SplitRegistry() (interface{}, error) {
192192
}
193193
splitRegistry := map[string]*splits.Weights{}
194194
for _, split := range schema.Splits {
195-
splitRegistry[split.Name], err = splits.WeightsFromYAML(split.Weights)
195+
splitRegistry[split.Name], err = splits.NewWeights(split.Weights)
196196
if err != nil {
197197
return nil, err
198198
}
@@ -208,7 +208,7 @@ func getV2PlusSplitRegistry() (interface{}, error) {
208208
splitRegistry := map[string]*v2Split{}
209209
for _, split := range schema.Splits {
210210
isFeatureGate := splits.IsFeatureGateFromName(split.Name)
211-
weights, err := splits.WeightsFromYAML(split.Weights)
211+
weights, err := splits.NewWeights(split.Weights)
212212
if err != nil {
213213
return nil, err
214214
}
@@ -231,7 +231,7 @@ func getV4SplitRegistry() (interface{}, error) {
231231
v4Splits := make([]v4Split, 0, len(schema.Splits))
232232
for _, split := range schema.Splits {
233233
isFeatureGate := splits.IsFeatureGateFromName(split.Name)
234-
weights, err := splits.WeightsFromYAML(split.Weights)
234+
weights, err := splits.NewWeights(split.Weights)
235235
if err != nil {
236236
return nil, err
237237
}

fakeserver/server_test.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ splits:
3333
treatment: 40
3434
`
3535

36+
var otherTestSchema = `{
37+
"serializer_version": 1,
38+
"schema_version": "2020011774023",
39+
"splits": [
40+
{
41+
"name": "test.json_experiment",
42+
"weights": { "control": 50, "treatment": 50 }
43+
}
44+
]
45+
}`
46+
3647
var testAssignments = `
3748
something_something_enabled: "true"
3849
`
@@ -52,7 +63,12 @@ func TestMain(m *testing.M) {
5263
}
5364

5465
schemaContent := []byte(testSchema)
55-
if err := os.WriteFile(filepath.Join(schemasDir, "test.yml"), schemaContent, 0644); err != nil {
66+
if err := os.WriteFile(filepath.Join(schemasDir, "a.yml"), schemaContent, 0644); err != nil {
67+
log.Fatal(err)
68+
}
69+
70+
otherSchemaContent := []byte(otherTestSchema)
71+
if err := os.WriteFile(filepath.Join(schemasDir, "b.json"), otherSchemaContent, 0644); err != nil {
5672
log.Fatal(err)
5773
}
5874

@@ -140,6 +156,22 @@ func TestSplitRegistry(t *testing.T) {
140156
require.Equal(t, 40, treatment.Weight)
141157
require.Equal(t, false, split.FeatureGate)
142158
})
159+
160+
t.Run("it loads JSON schemas from home directory", func(t *testing.T) {
161+
w := httptest.NewRecorder()
162+
h := createHandler()
163+
164+
h.ServeHTTP(w, httptest.NewRequest("GET", "/api/v2/split_registry", nil))
165+
166+
require.Equal(t, http.StatusOK, w.Code)
167+
168+
registry := v2SplitRegistry{}
169+
err := json.Unmarshal(w.Body.Bytes(), &registry)
170+
require.Nil(t, err)
171+
172+
require.Equal(t, 50, registry.Splits["test.json_experiment"].Weights["control"])
173+
require.Equal(t, 50, registry.Splits["test.json_experiment"].Weights["treatment"])
174+
})
143175
}
144176

145177
func TestVisitorConfig(t *testing.T) {

schema/schema.go

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package schema
22

33
import (
4+
"encoding/json"
45
"errors"
56
"fmt"
67
"os"
@@ -15,12 +16,24 @@ import (
1516
"gopkg.in/yaml.v2"
1617
)
1718

19+
// Finds the path to the schema file (preferring JSON), or returns testtrack/schema.json
20+
func findSchemaPath() (string, bool) {
21+
if _, err := os.Stat("testtrack/schema.json"); err == nil {
22+
return "testtrack/schema.json", true
23+
}
24+
if _, err := os.Stat("testtrack/schema.yml"); err == nil {
25+
return "testtrack/schema.yml", true
26+
}
27+
return "testtrack/schema.json", false
28+
}
29+
1830
// Read a schema from disk or generate one
1931
func Read() (*serializers.Schema, error) {
20-
if _, err := os.Stat("testtrack/schema.yml"); os.IsNotExist(err) {
32+
schemaPath, exists := findSchemaPath()
33+
if !exists {
2134
return Generate()
2235
}
23-
schemaBytes, err := os.ReadFile("testtrack/schema.yml")
36+
schemaBytes, err := os.ReadFile(schemaPath)
2437
if err != nil {
2538
return nil, err
2639
}
@@ -53,12 +66,21 @@ func Generate() (*serializers.Schema, error) {
5366
// Write a schema to disk after alpha-sorting its resources
5467
func Write(schema *serializers.Schema) error {
5568
SortAlphabetically(schema)
56-
out, err := yaml.Marshal(schema)
69+
70+
schemaPath, _ := findSchemaPath()
71+
72+
var out []byte
73+
var err error
74+
if filepath.Ext(schemaPath) == ".yml" {
75+
out, err = yaml.Marshal(schema)
76+
} else {
77+
out, err = json.MarshalIndent(schema, "", " ")
78+
}
5779
if err != nil {
5880
return err
5981
}
6082

61-
err = os.WriteFile("testtrack/schema.yml", out, 0644)
83+
err = os.WriteFile(schemaPath, out, 0644)
6284
if err != nil {
6385
return err
6486
}
@@ -68,8 +90,9 @@ func Write(schema *serializers.Schema) error {
6890

6991
// Link a schema to the user's home dir
7092
func Link(force bool) error {
71-
if _, err := os.Stat("testtrack/schema.yml"); os.IsNotExist(err) {
72-
return errors.New("testtrack/schema.yml does not exist. Are you in your app root dir? If so, call testtrack init_project first")
93+
schemaPath, exists := findSchemaPath()
94+
if !exists {
95+
return errors.New("testtrack/schema.{json,yml} does not exist. Are you in your app root dir? If so, call testtrack init_project first")
7396
}
7497
dir, err := os.Getwd()
7598
if err != nil {
@@ -84,11 +107,12 @@ func Link(force bool) error {
84107
if err != nil {
85108
return err
86109
}
87-
path := fmt.Sprintf("%s/schemas/%s.yml", *configDir, dirname)
110+
ext := filepath.Ext(schemaPath)
111+
path := fmt.Sprintf("%s/schemas/%s%s", *configDir, dirname, ext)
88112
if force {
89113
os.Remove(path) // If this fails it might just not exist, we'll error on the next line if something else is up
90114
}
91-
return os.Symlink(dir+"/testtrack/schema.yml", path)
115+
return os.Symlink(dir+"/"+schemaPath, path)
92116
}
93117

94118
// ReadMerged merges schemas linked at ~/testtrack/schemas into a single virtual schema
@@ -97,28 +121,20 @@ func ReadMerged() (*serializers.Schema, error) {
97121
if err != nil {
98122
return nil, err
99123
}
100-
paths, err := filepath.Glob(*configDir + "/schemas/*.yml")
124+
paths, err := filepath.Glob(*configDir + "/schemas/*.*")
101125
if err != nil {
102126
return nil, err
103127
}
104128
var mergedSchema serializers.Schema
105129
for _, path := range paths {
106-
// Deref symlink
107-
fi, err := os.Lstat(path)
108-
if err != nil {
109-
return nil, err
110-
}
111-
if fi.Mode()&os.ModeSymlink != 0 {
112-
path, err = os.Readlink(path)
113-
if err != nil {
114-
continue // It's OK if this symlink isn't traversable (e.g. app was uninstalled), we'll just skip it.
115-
}
116-
}
117-
// Read file
118130
schemaBytes, err := os.ReadFile(path)
119131
if err != nil {
132+
if os.IsNotExist(err) {
133+
continue // It's OK if this file doesn't exist (e.g. broken symlink, app was uninstalled), we'll just skip it.
134+
}
120135
return nil, err
121136
}
137+
122138
var schema serializers.Schema
123139
err = yaml.Unmarshal(schemaBytes, &schema)
124140
if err != nil {
@@ -156,18 +172,18 @@ func mergeLegacySchema(schema *serializers.Schema) error {
156172
if !ok {
157173
return fmt.Errorf("expected split name, got %v", mapSlice.Key)
158174
}
159-
weightsYAML, ok := mapSlice.Value.(yaml.MapSlice)
175+
weightsYAML, ok := mapSlice.Value.(map[string]int)
160176
if !ok {
161177
return fmt.Errorf("expected weights, got %v", mapSlice.Value)
162178
}
163-
weights, err := splits.WeightsFromYAML(weightsYAML)
179+
weights, err := splits.NewWeights(weightsYAML)
164180
if err != nil {
165181
return err
166182
}
167183

168184
schema.Splits = append(schema.Splits, serializers.SchemaSplit{
169185
Name: name,
170-
Weights: weights.ToYAML(),
186+
Weights: *weights,
171187
Decided: false,
172188
})
173189
}

schemaloaders/schemaloaders.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func schemaSplitMigrations(schemaSplit serializers.SchemaSplit) ([]migrations.IM
9898

9999
if schemaSplit.Decided {
100100
var decision *string
101-
weights, err := splits.WeightsFromYAML(schemaSplit.Weights)
101+
weights, err := splits.NewWeights(schemaSplit.Weights)
102102
if err != nil {
103103
return nil, fmt.Errorf("schema split %s invalid: %w", schemaSplit.Name, err)
104104
}

0 commit comments

Comments
 (0)