From 699dfe693f2fcdc811aee379f895411edafd20fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bastian=20M=C3=BCller?= Date: Tue, 9 Jun 2026 13:16:53 -0700 Subject: [PATCH] preserve flow.json key order across load/save --- config/json/account.go | 29 +++-- config/json/account_test.go | 6 +- config/json/config.go | 18 ++- config/json/config_test.go | 134 ++++++++++++++++++++++ config/json/contract.go | 47 ++++---- config/json/dependency.go | 12 +- config/json/deploy.go | 30 ++--- config/json/emulator.go | 18 +-- config/json/network.go | 12 +- config/json/network_test.go | 2 +- config/json/ordered_map.go | 191 ++++++++++++++++++++++++++++++++ config/json/ordered_map_test.go | 173 +++++++++++++++++++++++++++++ 12 files changed, 591 insertions(+), 81 deletions(-) create mode 100644 config/json/ordered_map.go create mode 100644 config/json/ordered_map_test.go diff --git a/config/json/account.go b/config/json/account.go index 708c8333..c10da683 100644 --- a/config/json/account.go +++ b/config/json/account.go @@ -34,7 +34,9 @@ import ( "github.com/onflow/flowkit/v2/config" ) -type jsonAccounts map[string]account +type jsonAccounts struct { + orderedMap[account] +} const ( defaultHashAlgo = crypto.SHA3_256 @@ -239,22 +241,19 @@ func transformAdvancedToConfig(accountName string, a advancedAccount) (*config.A func (j jsonAccounts) transformToConfig() (config.Accounts, error) { accounts := make(config.Accounts, 0) - for accountName, a := range j { - var account *config.Account + for accountName, a := range j.All { + var acc *config.Account var err error if a.Simple.Address != "" { - account, err = transformSimpleToConfig(accountName, a.Simple) - if err != nil { - return nil, err - } + acc, err = transformSimpleToConfig(accountName, a.Simple) } else { // advanced format - account, err = transformAdvancedToConfig(accountName, a.Advanced) - if err != nil { - return nil, err - } + acc, err = transformAdvancedToConfig(accountName, a.Advanced) + } + if err != nil { + return nil, err } - accounts = append(accounts, *account) + accounts = append(accounts, *acc) } return accounts, nil @@ -262,13 +261,13 @@ func (j jsonAccounts) transformToConfig() (config.Accounts, error) { // transformToJSON transforms config structure to json structures for saving. func transformAccountsToJSON(accounts config.Accounts) jsonAccounts { - jsonAccounts := jsonAccounts{} + var jsonAccounts jsonAccounts for _, a := range accounts { if a.Key.IsDefault() { - jsonAccounts[a.Name] = transformSimpleAccountToJSON(a) + jsonAccounts.Set(a.Name, transformSimpleAccountToJSON(a)) } else { - jsonAccounts[a.Name] = transformAdvancedAccountToJSON(a) + jsonAccounts.Set(a.Name, transformAdvancedAccountToJSON(a)) } } diff --git a/config/json/account_test.go b/config/json/account_test.go index 49861572..b4fdaaa5 100644 --- a/config/json/account_test.go +++ b/config/json/account_test.go @@ -119,8 +119,10 @@ func Test_ConfigAccountKeysAdvancedFile(t *testing.T) { assert.Equal(t, "", key.ResourceID) jsonAccs := transformAccountsToJSON(accounts) - assert.Equal(t, "./test.pkey", jsonAccs["test"].Advanced.Key.Location) - assert.Equal(t, "", jsonAccs["test"].Advanced.Key.PrivateKey) + testAcc, ok := jsonAccs.Get("test") + assert.True(t, ok) + assert.Equal(t, "./test.pkey", testAcc.Advanced.Key.Location) + assert.Equal(t, "", testAcc.Advanced.Key.PrivateKey) } func Test_ConfigAccountKeysAdvancedKMS(t *testing.T) { diff --git a/config/json/config.go b/config/json/config.go index 7d7625d7..6d7b9019 100644 --- a/config/json/config.go +++ b/config/json/config.go @@ -26,13 +26,19 @@ import ( ) // jsonConfig implements JSON format for persisting and parsing configuration. +// +// Sections use ordered maps to preserve user-chosen key ordering across +// load/save cycles. Both `omitempty` and `omitzero` are set: `omitzero` is what +// actually skips empty sections during marshaling (since `omitempty` is a no-op +// for struct values), while `omitempty` is read by the invopop jsonschema +// reflector to mark these fields as optional in the generated schema. type jsonConfig struct { - Emulators jsonEmulators `json:"emulators,omitempty"` - Contracts jsonContracts `json:"contracts,omitempty"` - Dependencies jsonDependencies `json:"dependencies,omitempty"` - Networks jsonNetworks `json:"networks,omitempty"` - Accounts jsonAccounts `json:"accounts,omitempty"` - Deployments jsonDeployments `json:"deployments,omitempty"` + Emulators jsonEmulators `json:"emulators,omitempty,omitzero"` + Contracts jsonContracts `json:"contracts,omitempty,omitzero"` + Dependencies jsonDependencies `json:"dependencies,omitempty,omitzero"` + Networks jsonNetworks `json:"networks,omitempty,omitzero"` + Accounts jsonAccounts `json:"accounts,omitempty,omitzero"` + Deployments jsonDeployments `json:"deployments,omitempty,omitzero"` } func (j *jsonConfig) transformToConfig() (*config.Config, error) { diff --git a/config/json/config_test.go b/config/json/config_test.go index 51f5fd63..088d5b27 100644 --- a/config/json/config_test.go +++ b/config/json/config_test.go @@ -18,6 +18,7 @@ package json import ( + "encoding/json" "testing" "github.com/onflow/flow-go-sdk" @@ -255,3 +256,136 @@ func Test_SerializeConfigToJsonEmulatorNotDefault(t *testing.T) { assert.JSONEq(t, string(configJson), string(conf)) } + +// Test_RoundTripPreservesKeyOrder verifies that loading a flow.json and then +// re-serializing it preserves the user's chosen ordering of keys within each +// section, including nested deployments. This was the motivating bug for the +// orderedMap data structure. +func Test_RoundTripPreservesKeyOrder(t *testing.T) { + input := []byte(`{ + "contracts": { + "ZetaContract": "./z.cdc", + "AlphaContract": "./a.cdc", + "MuContract": "./m.cdc" + }, + "dependencies": { + "Burner": "testnet://9a0766d93b6608b7.Burner", + "AAA": "testnet://9a0766d93b6608b7.AAA" + }, + "networks": { + "mainnet": "access.mainnet.nodes.onflow.org:9000", + "testnet": "access.testnet.nodes.onflow.org:9000", + "emulator": "127.0.0.1:3569" + }, + "accounts": { + "zoo-account": { + "address": "f8d6e0586b0a20c7", + "key": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7" + }, + "alpha-account": { + "address": "f8d6e0586b0a20c7", + "key": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7" + } + }, + "deployments": { + "testnet": { + "zoo-account": ["ZetaContract"] + }, + "emulator": { + "alpha-account": ["MuContract", "AlphaContract"] + } + } +}`) + + parser := NewParser() + conf, err := parser.Deserialize(input) + assert.NoError(t, err) + + out, err := parser.Serialize(conf) + assert.NoError(t, err) + + // Re-parse into the JSON layer directly to compare structural ordering + // without depending on whitespace. + var original, roundTripped jsonConfig + assert.NoError(t, json.Unmarshal(input, &original)) + assert.NoError(t, json.Unmarshal(out, &roundTripped)) + + assert.Equal(t, + sectionKeys(original.Contracts.orderedMap), + sectionKeys(roundTripped.Contracts.orderedMap), + "contracts order changed") + assert.Equal(t, + sectionKeys(original.Dependencies.orderedMap), + sectionKeys(roundTripped.Dependencies.orderedMap), + "dependencies order changed") + assert.Equal(t, + sectionKeys(original.Networks.orderedMap), + sectionKeys(roundTripped.Networks.orderedMap), + "networks order changed") + assert.Equal(t, + sectionKeys(original.Accounts.orderedMap), + sectionKeys(roundTripped.Accounts.orderedMap), + "accounts order changed") + assert.Equal(t, + sectionKeys(original.Deployments.orderedMap), + sectionKeys(roundTripped.Deployments.orderedMap), + "deployments order changed") + + // Verify inner deployment account ordering is also preserved (this is the + // nested orderedMap[[]deployment] case). + originalEmu, _ := original.Deployments.Get("emulator") + roundTrippedEmu, _ := roundTripped.Deployments.Get("emulator") + assert.Equal(t, + sectionKeys(originalEmu.orderedMap), + sectionKeys(roundTrippedEmu.orderedMap), + "deployments.emulator account order changed") +} + +// Test_NewKeysAppendAtEnd verifies that when a new entry is added programmatically +// (e.g. a new dependency is fetched), it is appended at the end rather than +// inserted alphabetically. +func Test_NewKeysAppendAtEnd(t *testing.T) { + input := []byte(`{ + "contracts": { + "ZetaContract": "./z.cdc", + "AlphaContract": "./a.cdc" + }, + "networks": { + "emulator": "127.0.0.1:3569" + }, + "accounts": { + "emulator-account": { + "address": "f8d6e0586b0a20c7", + "key": "11c5dfdeb0ff03a7a73ef39788563b62c89adea67bbb21ab95e5f710bd1d40b7" + } + } +}`) + + parser := NewParser() + conf, err := parser.Deserialize(input) + assert.NoError(t, err) + + conf.Contracts.AddOrUpdate(config.Contract{ + Name: "MuContract", + Location: "./m.cdc", + }) + + out, err := parser.Serialize(conf) + assert.NoError(t, err) + + var result jsonConfig + assert.NoError(t, json.Unmarshal(out, &result)) + + assert.Equal(t, + []string{"ZetaContract", "AlphaContract", "MuContract"}, + sectionKeys(result.Contracts.orderedMap), + "new contract should be appended at the end, not inserted alphabetically") +} + +func sectionKeys[V any](m orderedMap[V]) []string { + keys := make([]string, 0, m.Len()) + for k := range m.All { + keys = append(keys, k) + } + return keys +} diff --git a/config/json/contract.go b/config/json/contract.go index 5f20837e..236d122c 100644 --- a/config/json/contract.go +++ b/config/json/contract.go @@ -29,36 +29,37 @@ import ( "github.com/onflow/flowkit/v2/config" ) -type jsonContracts map[string]jsonContract +type jsonContracts struct { + orderedMap[jsonContract] +} // transformToConfig transforms json structures to config structure. func (j jsonContracts) transformToConfig() (config.Contracts, error) { contracts := make(config.Contracts, 0) - for contractName, c := range j { + for contractName, c := range j.All { if c.Simple != "" { - contract := config.Contract{ + contracts = append(contracts, config.Contract{ Name: contractName, Location: c.Simple, - } + }) + continue + } - contracts = append(contracts, contract) - } else { - contract := config.Contract{ - Name: contractName, - Location: c.Advanced.Source, - Canonical: c.Advanced.Canonical, + contract := config.Contract{ + Name: contractName, + Location: c.Advanced.Source, + Canonical: c.Advanced.Canonical, + } + for network, alias := range c.Advanced.Aliases { + address := flow.HexToAddress(alias) + if address == flow.EmptyAddress { + return nil, fmt.Errorf("invalid alias address for a contract") } - for network, alias := range c.Advanced.Aliases { - address := flow.HexToAddress(alias) - if address == flow.EmptyAddress { - return nil, fmt.Errorf("invalid alias address for a contract") - } - contract.Aliases.Add(network, address) - } - contracts = append(contracts, contract) + contract.Aliases.Add(network, address) } + contracts = append(contracts, contract) } return contracts, nil @@ -66,7 +67,7 @@ func (j jsonContracts) transformToConfig() (config.Contracts, error) { // transformToJSON transforms config structure to json structures for saving. func transformContractsToJSON(contracts config.Contracts) jsonContracts { - jsonContracts := jsonContracts{} + var jsonContracts jsonContracts for _, c := range contracts { // If it's a dependency, skip. These are used under the hood and should not be saved. @@ -76,9 +77,9 @@ func transformContractsToJSON(contracts config.Contracts) jsonContracts { // if simple case (no aliases and no canonical) if !c.IsAliased() && c.Canonical == "" { - jsonContracts[c.Name] = jsonContract{ + jsonContracts.Set(c.Name, jsonContract{ Simple: filepath.ToSlash(c.Location), - } + }) } else { // if advanced config // check if we already created for this name then add or create aliases := make(map[string]string) @@ -86,13 +87,13 @@ func transformContractsToJSON(contracts config.Contracts) jsonContracts { aliases[alias.Network] = alias.Address.String() } - jsonContracts[c.Name] = jsonContract{ + jsonContracts.Set(c.Name, jsonContract{ Advanced: jsonContractAdvanced{ Source: filepath.ToSlash(c.Location), Aliases: aliases, Canonical: c.Canonical, }, - } + }) } } diff --git a/config/json/dependency.go b/config/json/dependency.go index c500f30a..daf35952 100644 --- a/config/json/dependency.go +++ b/config/json/dependency.go @@ -30,12 +30,14 @@ import ( "github.com/onflow/flowkit/v2/config" ) -type jsonDependencies map[string]jsonDependency +type jsonDependencies struct { + orderedMap[jsonDependency] +} func (j jsonDependencies) transformToConfig() (config.Dependencies, error) { deps := make(config.Dependencies, 0) - for dependencyName, dependency := range j { + for dependencyName, dependency := range j.All { var dep config.Dependency if dependency.Simple != "" { @@ -87,7 +89,7 @@ func (j jsonDependencies) transformToConfig() (config.Dependencies, error) { } func transformDependenciesToJSON(configDependencies config.Dependencies, configContracts config.Contracts) jsonDependencies { - jsonDeps := jsonDependencies{} + var jsonDeps jsonDependencies for _, dep := range configDependencies { aliases := make(map[string]string) @@ -99,7 +101,7 @@ func transformDependenciesToJSON(configDependencies config.Dependencies, configC } } - jsonDeps[dep.Name] = jsonDependency{ + jsonDeps.Set(dep.Name, jsonDependency{ Extended: jsonDependencyExtended{ Source: buildSourceString(dep.Source), Hash: dep.Hash, @@ -107,7 +109,7 @@ func transformDependenciesToJSON(configDependencies config.Dependencies, configC Aliases: aliases, Canonical: dep.Canonical, }, - } + }) } return jsonDeps diff --git a/config/json/deploy.go b/config/json/deploy.go index e96847b3..7af0e290 100644 --- a/config/json/deploy.go +++ b/config/json/deploy.go @@ -28,17 +28,17 @@ import ( "github.com/onflow/flowkit/v2/config" ) -type jsonDeployments map[string]jsonDeployment +type jsonDeployments struct { + orderedMap[jsonDeployment] +} // transformToConfig transforms json structures to config structure. func (j jsonDeployments) transformToConfig() (config.Deployments, error) { deployments := make(config.Deployments, 0) - for networkName, deploys := range j { - - var deploy config.Deployment - for accountName, contracts := range deploys { - deploy = config.Deployment{ + for networkName, deploys := range j.All { + for accountName, contracts := range deploys.All { + deploy := config.Deployment{ Network: networkName, Account: accountName, } @@ -89,7 +89,7 @@ func (j jsonDeployments) transformToConfig() (config.Deployments, error) { // transformToJSON transforms config structure to json structures for saving. func transformDeploymentsToJSON(configDeployments config.Deployments) jsonDeployments { - jsonDeploys := jsonDeployments{} + var jsonDeploys jsonDeployments for _, d := range configDeployments { @@ -114,14 +114,12 @@ func transformDeploymentsToJSON(configDeployments config.Deployments) jsonDeploy } } - if _, ok := jsonDeploys[d.Network]; ok { - jsonDeploys[d.Network][d.Account] = deployments - } else { - jsonDeploys[d.Network] = jsonDeployment{ - d.Account: deployments, - } + network, ok := jsonDeploys.Get(d.Network) + if !ok { + network = jsonDeployment{} } - + network.Set(d.Account, deployments) + jsonDeploys.Set(d.Network, network) } return jsonDeploys @@ -137,7 +135,9 @@ type deployment struct { advanced contractDeployment } -type jsonDeployment map[string][]deployment +type jsonDeployment struct { + orderedMap[[]deployment] +} func (d *deployment) UnmarshalJSON(b []byte) error { diff --git a/config/json/emulator.go b/config/json/emulator.go index 6dcd6183..c4fd24b3 100644 --- a/config/json/emulator.go +++ b/config/json/emulator.go @@ -24,24 +24,24 @@ import ( "github.com/onflow/flowkit/v2/config" ) -type jsonEmulators map[string]jsonEmulator +type jsonEmulators struct { + orderedMap[jsonEmulator] +} // transformToConfig transforms json structures to config structure. func (j jsonEmulators) transformToConfig() (config.Emulators, error) { emulators := make(config.Emulators, 0) - for name, e := range j { + for name, e := range j.All { if e.Port < 0 || e.Port > 65535 { return nil, fmt.Errorf("invalid port value") } - emulator := config.Emulator{ + emulators = append(emulators, config.Emulator{ Name: name, Port: e.Port, ServiceAccount: e.ServiceAccount, - } - - emulators = append(emulators, emulator) + }) } return emulators, nil @@ -49,16 +49,16 @@ func (j jsonEmulators) transformToConfig() (config.Emulators, error) { // transformToJSON transforms config structure to json structures for saving. func transformEmulatorsToJSON(emulators config.Emulators) jsonEmulators { - jsonEmulators := jsonEmulators{} + var jsonEmulators jsonEmulators for _, e := range emulators { if e == config.DefaultEmulator { continue } - jsonEmulators[e.Name] = jsonEmulator{ + jsonEmulators.Set(e.Name, jsonEmulator{ Port: e.Port, ServiceAccount: e.ServiceAccount, - } + }) } return jsonEmulators diff --git a/config/json/network.go b/config/json/network.go index b41a6d7b..635dc108 100644 --- a/config/json/network.go +++ b/config/json/network.go @@ -30,13 +30,15 @@ import ( "github.com/onflow/flowkit/v2/config" ) -type jsonNetworks map[string]jsonNetwork +type jsonNetworks struct { + orderedMap[jsonNetwork] +} // transformToConfig transforms json structures to config structure. func (j jsonNetworks) transformToConfig() (config.Networks, error) { networks := make(config.Networks, 0) - for networkName, n := range j { + for networkName, n := range j.All { // Advanced form: host required, key optional, fork optional if n.Advanced.Host != "" || n.Advanced.Fork != "" { if n.Advanced.Key != "" { @@ -65,14 +67,14 @@ func (j jsonNetworks) transformToConfig() (config.Networks, error) { // transformNetworksToJSON transforms config structure to json structures for saving. func transformNetworksToJSON(networks config.Networks) jsonNetworks { - jsonNetworks := jsonNetworks{} + var jsonNetworks jsonNetworks for _, n := range networks { // Use advanced when key or fork present; otherwise simple if n.Key != "" || n.Fork != "" { - jsonNetworks[n.Name] = transformAdvancedNetworkToJSON(n) + jsonNetworks.Set(n.Name, transformAdvancedNetworkToJSON(n)) } else { - jsonNetworks[n.Name] = transformSimpleNetworkToJSON(n) + jsonNetworks.Set(n.Name, transformSimpleNetworkToJSON(n)) } } diff --git a/config/json/network_test.go b/config/json/network_test.go index 28e91629..c364ebb3 100644 --- a/config/json/network_test.go +++ b/config/json/network_test.go @@ -96,7 +96,7 @@ func Test_IgnoreOldFormat(t *testing.T) { conf, err := jsonNetworks.transformToConfig() assert.NoError(t, err) - assert.Len(t, jsonNetworks, 3) + assert.Equal(t, 3, jsonNetworks.Len()) testnet, err := conf.ByName("testnet") assert.NoError(t, err) diff --git a/config/json/ordered_map.go b/config/json/ordered_map.go new file mode 100644 index 00000000..17397368 --- /dev/null +++ b/config/json/ordered_map.go @@ -0,0 +1,191 @@ +/* + * Flow CLI + * + * Copyright 2019 Dapper Labs, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package json + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + + "github.com/invopop/jsonschema" +) + +// orderedMap is a string-keyed map that preserves insertion order +// across JSON marshal/unmarshal cycles. flow.json sections need this +// so that round-tripping the file does not reorder entries. +// +// Receiver convention: mutating methods (Set, UnmarshalJSON) use a pointer +// receiver; all others use a value receiver. This intentionally mirrors the +// pattern used by the standard library's encoding/json marshalers (e.g. +// time.Time) — MarshalJSON must be on a value receiver because encoding/json +// only calls a pointer-receiver Marshaler when the value is addressable, which +// it is not when callers pass an orderedMap directly to json.Marshal. +type orderedMap[V any] struct { + entries []orderedEntry[V] +} + +type orderedEntry[V any] struct { + Key string + Value V +} + +// Set inserts or updates an entry. New keys are appended at the end; +// existing keys keep their original position. +func (m *orderedMap[V]) Set(key string, value V) { + for i := range m.entries { + if m.entries[i].Key == key { + m.entries[i].Value = value + return + } + } + m.entries = append(m.entries, orderedEntry[V]{Key: key, Value: value}) +} + +// Get returns the value for key and whether it was found. +func (m orderedMap[V]) Get(key string) (V, bool) { + for _, e := range m.entries { + if e.Key == key { + return e.Value, true + } + } + var zero V + return zero, false +} + +// Len returns the number of entries. +func (m orderedMap[V]) Len() int { + return len(m.entries) +} + +// All is an iter.Seq2-shaped iterator over entries in insertion order, +// usable directly with `for k, v := range m.All`. +func (m orderedMap[V]) All(yield func(string, V) bool) { + for _, e := range m.entries { + if !yield(e.Key, e.Value) { + return + } + } +} + +func (m orderedMap[V]) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + for i, e := range m.entries { + if i > 0 { + buf.WriteByte(',') + } + keyBytes, err := json.Marshal(e.Key) + if err != nil { + return nil, err + } + buf.Write(keyBytes) + buf.WriteByte(':') + valueBytes, err := json.Marshal(e.Value) + if err != nil { + return nil, err + } + buf.Write(valueBytes) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +func (m *orderedMap[V]) UnmarshalJSON(data []byte) error { + dec := json.NewDecoder(bytes.NewReader(data)) + tok, err := dec.Token() + if err != nil { + return err + } + if tok == nil { + m.entries = nil + return nil + } + delim, ok := tok.(json.Delim) + if !ok || delim != '{' { + return fmt.Errorf("expected JSON object for orderedMap, got %v", tok) + } + + m.entries = m.entries[:0] + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return err + } + key, ok := keyTok.(string) + if !ok { + return fmt.Errorf("expected string key, got %T", keyTok) + } + var value V + if err := dec.Decode(&value); err != nil { + return err + } + m.Set(key, value) + } + + if _, err := dec.Token(); err != nil { // closing '}' + return err + } + return nil +} + +// JSONSchema renders orderedMap as a JSON object whose values match V's schema, +// matching invopop's default representation for map[string]V so the generated +// schema is unaffected by the switch from a plain map. +func (m orderedMap[V]) JSONSchema() *jsonschema.Schema { + definitions := map[string]*jsonschema.Schema{} + valueSchema := refSchemaForType(reflect.TypeFor[V](), definitions) + + schema := &jsonschema.Schema{ + Type: "object", + PatternProperties: map[string]*jsonschema.Schema{ + ".*": valueSchema, + }, + } + if len(definitions) > 0 { + schema.Definitions = definitions + } + return schema +} + +// refSchemaForType returns a schema for t. Named types become "$ref" entries +// and their reflected schema is added to definitions; slice/array types yield +// an inline array schema whose element schema is resolved recursively. +func refSchemaForType(t reflect.Type, definitions map[string]*jsonschema.Schema) *jsonschema.Schema { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + if name := t.Name(); name != "" { + if _, exists := definitions[name]; !exists { + definitions[name] = jsonschema.Reflect(reflect.New(t).Elem().Interface()) + } + return &jsonschema.Schema{Ref: "#/$defs/" + name} + } + + switch t.Kind() { + case reflect.Slice, reflect.Array: + return &jsonschema.Schema{ + Type: "array", + Items: refSchemaForType(t.Elem(), definitions), + } + } + + return jsonschema.Reflect(reflect.New(t).Elem().Interface()) +} diff --git a/config/json/ordered_map_test.go b/config/json/ordered_map_test.go new file mode 100644 index 00000000..6b1fc61d --- /dev/null +++ b/config/json/ordered_map_test.go @@ -0,0 +1,173 @@ +/* + * Flow CLI + * + * Copyright 2019 Dapper Labs, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package json + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_OrderedMap_SetAppendsNewKeys(t *testing.T) { + var m orderedMap[int] + m.Set("zeta", 1) + m.Set("alpha", 2) + m.Set("mu", 3) + + assert.Equal(t, 3, m.Len()) + assert.Equal(t, []string{"zeta", "alpha", "mu"}, collectKeys(m)) +} + +func Test_OrderedMap_SetUpdatesPreservesPosition(t *testing.T) { + var m orderedMap[int] + m.Set("a", 1) + m.Set("b", 2) + m.Set("c", 3) + + m.Set("b", 99) + + assert.Equal(t, []string{"a", "b", "c"}, collectKeys(m)) + v, ok := m.Get("b") + assert.True(t, ok) + assert.Equal(t, 99, v) +} + +func Test_OrderedMap_GetMissing(t *testing.T) { + var m orderedMap[string] + m.Set("x", "X") + + v, ok := m.Get("y") + assert.False(t, ok) + assert.Equal(t, "", v) +} + +func Test_OrderedMap_MarshalPreservesInsertionOrder(t *testing.T) { + var m orderedMap[int] + m.Set("zeta", 1) + m.Set("alpha", 2) + m.Set("mu", 3) + + b, err := json.Marshal(m) + require.NoError(t, err) + assert.Equal(t, `{"zeta":1,"alpha":2,"mu":3}`, string(b)) +} + +func Test_OrderedMap_MarshalEmpty(t *testing.T) { + var m orderedMap[int] + + b, err := json.Marshal(m) + require.NoError(t, err) + assert.Equal(t, `{}`, string(b)) +} + +func Test_OrderedMap_UnmarshalPreservesSourceOrder(t *testing.T) { + input := []byte(`{"zeta":1,"alpha":2,"mu":3}`) + + var m orderedMap[int] + err := json.Unmarshal(input, &m) + require.NoError(t, err) + + assert.Equal(t, []string{"zeta", "alpha", "mu"}, collectKeys(m)) +} + +func Test_OrderedMap_UnmarshalReusesExistingBacking(t *testing.T) { + var m orderedMap[int] + m.Set("preexisting", 7) + + err := json.Unmarshal([]byte(`{"b":2,"a":1}`), &m) + require.NoError(t, err) + + assert.Equal(t, []string{"b", "a"}, collectKeys(m)) + _, ok := m.Get("preexisting") + assert.False(t, ok) +} + +func Test_OrderedMap_UnmarshalNull(t *testing.T) { + var m orderedMap[int] + m.Set("x", 1) + + err := json.Unmarshal([]byte(`null`), &m) + require.NoError(t, err) + assert.Equal(t, 0, m.Len()) +} + +func Test_OrderedMap_UnmarshalRejectsNonObject(t *testing.T) { + var m orderedMap[int] + err := json.Unmarshal([]byte(`[1, 2, 3]`), &m) + assert.Error(t, err) +} + +func Test_OrderedMap_RoundTripPreservesOrder(t *testing.T) { + input := []byte(`{"first":"a","second":"b","third":"c","fourth":"d"}`) + + var m orderedMap[string] + require.NoError(t, json.Unmarshal(input, &m)) + + out, err := json.Marshal(m) + require.NoError(t, err) + assert.Equal(t, string(input), string(out)) +} + +func Test_OrderedMap_StructValue(t *testing.T) { + type point struct { + X int `json:"x"` + Y int `json:"y"` + } + + input := []byte(`{"second":{"x":2,"y":20},"first":{"x":1,"y":10}}`) + + var m orderedMap[point] + require.NoError(t, json.Unmarshal(input, &m)) + + assert.Equal(t, []string{"second", "first"}, collectKeys(m)) + + first, ok := m.Get("first") + assert.True(t, ok) + assert.Equal(t, point{X: 1, Y: 10}, first) + + out, err := json.Marshal(m) + require.NoError(t, err) + assert.Equal(t, string(input), string(out)) +} + +func Test_OrderedMap_RangeBreak(t *testing.T) { + var m orderedMap[int] + m.Set("a", 1) + m.Set("b", 2) + m.Set("c", 3) + + var visited []string + for k := range m.All { + visited = append(visited, k) + if k == "b" { + break + } + } + + assert.Equal(t, []string{"a", "b"}, visited) +} + +func collectKeys[V any](m orderedMap[V]) []string { + keys := make([]string, 0, m.Len()) + for k := range m.All { + keys = append(keys, k) + } + return keys +}