diff --git a/README.md b/README.md index d5d8c3b..ca63174 100644 --- a/README.md +++ b/README.md @@ -9,36 +9,39 @@ An optional set of [naming conventions](https://en.wikipedia.org/wiki/Naming_convention_(programming)) mapping options for goschtalt. -Fairly often we want to use structures for configuration that we can't easily alter. -Fortunately goschtalt provides an easy way to inject arbitrary mappers, however -that code is fairly boiler plate and clunky to repeat. Hence the value of this +Fairly often we want to use structures for configuration that can't easily be +altered. Fortunately goschtalt provides an easy way to inject arbitrary mappers, +however that code is fairly boiler plate and clunky to repeat. Hence the value of this library. With the [janos/casbab](https://github.com/janos/casbab) and a bit of -glue code, it's now easy to automatically convert from config files that may be -`snake_case` into go structures that follow the general conventions. +glue code, it's now easy to automatically convert to/from config files that may +be `snake_case` into go structures that follow the general conventions. To set configuration from go structs (AddValue()) or read configuration values from a configuration that is `snake_case` just use the option in place of -`goschtalt.KeymapFn()`. +`goschtalt.KeymapMapper()`. ```go - casemapper.ConfigStoredAs("two_words") + casemapper.ConfigIs("two_words") ``` -With more complex structures there will likely be exceptions needed. For example, -if the input data has the field `HTTP-Header` but the normal format expects that -value as `Http-Header` no problem; there is an optional `map[string]string` parameter -that maps the **configuration field** to the **structure field**. +With more complex structures there will likely be adjustments will be needed. +Adjustments are in the form of a map where keys are the golang struct field name +and values are the configuration key name. + +For example: ```go - casemapper.ConfigStoredAs("Two_Words"), - goschtalt.Keymap( + casemapper.ConfigStoredAs("two_words", map[string]string{ - "Configuration-Field": "StructureField", - "HTTP-Header": "HTTPHeader", + "CfgField": "configuration_field", + "CNAMEs": "cnames", }, ) ``` +will use the normal snake case mapping except for `CfgField` and `CNAMEs` fields, +which are mapped to `configuration_field` and `cnames` respectively. + References ---------- - https://en.wikipedia.org/wiki/Naming_convention_(programming) diff --git a/casemapper.go b/casemapper.go index 13bb28e..266eb32 100644 --- a/casemapper.go +++ b/casemapper.go @@ -11,6 +11,7 @@ package casemapper import ( + "errors" "fmt" "sort" "strings" @@ -34,6 +35,10 @@ const ( TrainCase = "Two-Words" ) +var ( + ErrDuplicate = errors.New("duplicate adjustment") +) + var fmtToFunc = map[string]func(string) string{ "twowords": allLower, "TWOWORDS": allUpper, @@ -49,7 +54,20 @@ var fmtToFunc = map[string]func(string) string{ "Two-Words": casbab.CamelKebab, } -// ConfigStoredAs provides a strict field/key mapper that converts the config +type casemapper struct { + toCase func(string) string + adjustments map[string]string +} + +func (c casemapper) Map(in string) string { + out, found := c.adjustments[in] + if !found { + out = c.toCase(in) + } + return out +} + +// ConfigIs provides a strict field/key mapper that converts the config // values from the specified nomenclature into the go structure name. // // Since the names of the different formatting styles are not standardized, only @@ -68,18 +86,33 @@ var fmtToFunc = map[string]func(string) string{ // - Two-Words // - two-Words // -// This option provides a goschtalt.KeymapFunc based option that will convert -// every input string, effectively ending the chain 100% of the time. -// Generally, this option should be specified prior to any goschtalt.Keymap -// options that handle customization. -func ConfigStoredAs(format string) goschtalt.Option { +// This option provides a goschtalt.KeymapMapper based option that will convert +// every input string, ending the chain 100% of the time. +// +// To make adjustments pass in a map (or many) with keys being the golang +// structure field names and values being the configuration name. +// +// The map keys and values must be unique and the inverse mapping must also be +// unique or an error is returned. +func ConfigIs(format string, structToConfig ...map[string]string) goschtalt.Option { + sToC, cToS, err := merge(structToConfig) + if err != nil { + return goschtalt.WithError(err) + } + if toCase, found := fmtToFunc[format]; found { return goschtalt.Options( goschtalt.DefaultUnmarshalOptions( - goschtalt.KeymapFunc(toCase), + goschtalt.KeymapMapper(&casemapper{ + toCase: toCase, + adjustments: cToS, + }), ), goschtalt.DefaultValueOptions( - goschtalt.KeymapFunc(toCase), + goschtalt.KeymapMapper(&casemapper{ + toCase: toCase, + adjustments: sToC, + }), ), ) } @@ -92,7 +125,7 @@ func ConfigStoredAs(format string) goschtalt.Option { sort.Strings(keys) return goschtalt.WithError( - fmt.Errorf("%w, '%s' unknown format by casemapper.ConfigStoredAs(). Known formats: %s", + fmt.Errorf("%w, '%s' unknown format by casemapper.ConfigIs(). Known formats: %s", goschtalt.ErrInvalidInput, format, strings.Join(keys, ", "), @@ -100,6 +133,23 @@ func ConfigStoredAs(format string) goschtalt.Option { ) } +func merge(in []map[string]string) (map[string]string, map[string]string, error) { + sToC := make(map[string]string, len(in)) + cToS := make(map[string]string, len(in)) + for i := range in { + for k, v := range in[i] { + _, a := sToC[k] + _, b := cToS[v] + if a || b { + return nil, nil, fmt.Errorf("%w, '%s' is duplicated.", ErrDuplicate, k) + } + sToC[k] = v + cToS[v] = k + } + } + return sToC, cToS, nil +} + func allLower(s string) string { return strings.Join(strings.Split(casbab.Lower(s), " "), "") } diff --git a/casemapper_test.go b/casemapper_test.go index 1170e6a..2da491d 100644 --- a/casemapper_test.go +++ b/casemapper_test.go @@ -119,6 +119,112 @@ func TestMapping(t *testing.T) { } } +func TestInvalidMapping(t *testing.T) { + assert := assert.New(t) + + gs, err := goschtalt.New(goschtalt.AutoCompile(), + ConfigStoredAs("two_words", + map[string]string{ + "Foo": "foo", + }, + map[string]string{ + "Foo": "foo", + }, + )) + assert.Nil(gs) + assert.Error(err) +} + +func TestMapMerging(t *testing.T) { + tests := []struct { + description string + in []map[string]string + sToC map[string]string + cToS map[string]string + expectErr error + }{ + { + description: "single item", + in: []map[string]string{ + map[string]string{ + "a": "b", + }, + }, + sToC: map[string]string{ + "a": "b", + }, + cToS: map[string]string{ + "b": "a", + }, + }, { + description: "multiple in the array", + in: []map[string]string{ + map[string]string{ + "A": "a", + }, + map[string]string{ + "B": "b", + }, + }, + sToC: map[string]string{ + "A": "a", + "B": "b", + }, + cToS: map[string]string{ + "a": "A", + "b": "B", + }, + }, { + description: "invalid, duplicated config name", + in: []map[string]string{ + map[string]string{ + "A": "a", + }, + map[string]string{ + "B": "b", + }, + map[string]string{ + "C": "b", + }, + }, + expectErr: ErrDuplicate, + }, { + description: "invalid, duplicated field name", + in: []map[string]string{ + map[string]string{ + "A": "a", + }, + map[string]string{ + "B": "b", + }, + map[string]string{ + "B": "c", + }, + }, + expectErr: ErrDuplicate, + }, + } + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + assert := assert.New(t) + + a, b, e := merge(tc.in) + + if tc.expectErr == nil { + assert.Equal(a, tc.sToC) + assert.Equal(b, tc.cToS) + assert.NoError(e) + return + } + + assert.Nil(a) + assert.Nil(b) + assert.ErrorIs(e, tc.expectErr) + }) + } +} + func TestUnknown(t *testing.T) { expected := `Known formats: TWO-WORDS, TWOWORDS, TWO_WORDS, Two-Words, TwoWords, Two_Words, two-Words, two-words, twoWords, two_Words, two_words, twowords` diff --git a/example_configstoredas_test.go b/example_configstoredas_test.go index 0d46880..58f69a6 100644 --- a/example_configstoredas_test.go +++ b/example_configstoredas_test.go @@ -8,18 +8,29 @@ import ( "github.com/goschtalt/casemapper" "github.com/goschtalt/goschtalt" + "github.com/goschtalt/goschtalt/pkg/debug" ) -func ExampleConfigStoredAs() { +func ExampleConfigIs() { + c := debug.Collect{} gs, err := goschtalt.New( goschtalt.AutoCompile(), + goschtalt.DefaultUnmarshalOptions(goschtalt.KeymapReport(&c)), + goschtalt.DefaultValueOptions(goschtalt.KeymapReport(&c)), - casemapper.ConfigStoredAs("two_words"), + casemapper.ConfigStoredAs("two_words", + // Keys are the struct field names and values are the configuration + // names. + map[string]string{ + "Header": "http_header", + "Sally": "frog", + }, + ), // Normally you'll be including data from a file or something like that. // Here we want to use the built in options to avoid including additional // dependencies. - goschtalt.AddValue("incoming", "", + goschtalt.AddValue("incoming", goschtalt.Root, &struct { Name string `goschtalt:"FirstName"` // Rename via tags... Header string @@ -29,15 +40,6 @@ func ExampleConfigStoredAs() { Header: "Content-Type: text/plain", Sally: "Likes go", }, - - // or rename via mapping, which is useful if you can't change - // the target structure, but want the configuration to be a - // specific key. - goschtalt.Keymap( - map[string]string{ - "header": "HTTPHeader", // Convert Http back to HTTP - }, - ), ), ) if err != nil { @@ -50,22 +52,27 @@ func ExampleConfigStoredAs() { Frog string } - cfg, err := goschtalt.Unmarshal[Config](gs, "", - // You can also remap when you unmarshal if that's simpler - goschtalt.Keymap( - map[string]string{ - "frog": "sally", - }, - ), - ) + cfg, err := goschtalt.Unmarshal[Config](gs, goschtalt.Root) if err != nil { panic(err) } + fmt.Printf("Mappings:\n") + fmt.Print(c.String()) + + fmt.Printf("\nConfig:\n") fmt.Printf("Config.HTTPHeader: '%s'\n", cfg.HTTPHeader) fmt.Printf("Config.FirstName: '%s'\n", cfg.FirstName) fmt.Printf("Config.Frog: '%s'\n", cfg.Frog) // Output: + // Mappings: + // 'FirstName' --> 'first_name' + // 'Frog' --> 'frog' + // 'HTTPHeader' --> 'http_header' + // 'Header' --> 'http_header' + // 'Sally' --> 'frog' + // + // Config: // Config.HTTPHeader: 'Content-Type: text/plain' // Config.FirstName: 'Gopher' // Config.Frog: 'Likes go'