Skip to content

Commit ec05469

Browse files
Merge pull request #110 from vimeo/enums
Enum Support
2 parents 65960d1 + 40f0b71 commit ec05469

10 files changed

Lines changed: 548 additions & 7 deletions

File tree

README.md

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ go get github.com/vimeo/dials@latest
1515

1616
## Prerequisites
1717

18-
Dials requires Go 1.18 or later.
18+
Dials requires Go 1.24 or later.
1919

2020
## What is Dials?
2121

@@ -276,6 +276,78 @@ If you wish to watch the config file and make updates to your configuration, use
276276
}
277277
```
278278

279+
### Using Enums
280+
Dials provides some helper types to deal with values that are part of defined
281+
enumerations. All you need to do is have your enum implement the `dials.ValueMap`
282+
interface with `DialsValueMap()`, which provides a mapping from a string (your
283+
config value) to the enum value. Further more, when using flags, Enums are
284+
integrated into those packages so that Usage text for each Enum value will
285+
include a list of all the possible values.
286+
287+
Also, `FuzzyEnum` provides identical functionality to `Enum` except comparisons
288+
are done in a case-insensitive manner.
289+
290+
For example:
291+
292+
```go
293+
type Protocol int
294+
295+
const (
296+
HTTP Protocol = iota
297+
HTTPS
298+
Gopher
299+
FTP
300+
)
301+
302+
func (p Protocol) DialsValueMap() map[string]Protocol {
303+
return map[string]Protocol{
304+
"http": HTTP,
305+
"https": HTTPS,
306+
"gopher": Gopher,
307+
"ftp": FTP,
308+
}
309+
}
310+
311+
func (p Protocol) String() string {
312+
switch p {
313+
case HTTP:
314+
return "HyperText Transfer Protocol"
315+
case HTTPS:
316+
return "Secure HyperText Transfer Protocol"
317+
case Gopher:
318+
return "Gopher"
319+
case FTP:
320+
return "File Transfer Protocol"
321+
}
322+
return "unknown"
323+
}
324+
325+
type Config struct {
326+
SelectedProto dials.Enum[Protocol]
327+
Backup dials.Enum[Protocol]
328+
}
329+
330+
func main() {
331+
source := &static.StringSource{
332+
Data: `{"selectedProto": "gopher"}`,
333+
Decoder: &json.Decoder{},
334+
}
335+
336+
c := &EnumConfig{
337+
Backup: dials.EnumValue(HTTPS),
338+
}
339+
340+
d, err := dials.Config(context.Background(), c, source)
341+
if err != nil {
342+
panic("something went wrong!")
343+
}
344+
345+
v := d.View()
346+
fmt.Printf("selected: %s, backup: %s", v.SelectedProto.Value, v.Backup.Value)
347+
// Output: selected: Gopher, backup: Secure HyperText Transfer Protocol
348+
}
349+
```
350+
279351
### Aliased Configuration Values
280352
Dials supports aliases for fields where you want to change the name and support
281353
an orderly transition from an old name to a new name. Just as there is a

enum.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//go:build go1.23
2+
3+
package dials
4+
5+
import (
6+
"fmt"
7+
"maps"
8+
"slices"
9+
"strings"
10+
)
11+
12+
// ValueMap provides a mapping from a string to a type of your choice to be used
13+
// like an enum. Users should implement the `DialsValueMap()` method to provide
14+
// all possible mappings values. Any values not in the mapping will fail when
15+
// unmarshaling.
16+
type ValueMap[T any] interface {
17+
DialsValueMap() map[string]T
18+
}
19+
20+
// Enum allows you to treat a certain type like an enumeration. Enums are
21+
// integrated into the flags packages to automatically amend the `Usage` text to
22+
// include all allowed values.
23+
type Enum[T ValueMap[T]] struct {
24+
Value T
25+
}
26+
27+
// EnumValuable is an interface that is useful to get all the allowed enum
28+
// values as strings. It is used by the flag packages to adjust the usage text
29+
// to include all available values.
30+
type EnumValuable interface {
31+
AllowedEnumValues() []string
32+
}
33+
34+
// AllowedEnumValues implements the [EnumValuable] interface.
35+
func (e Enum[T]) AllowedEnumValues() []string {
36+
return slices.Sorted(maps.Keys(e.Value.DialsValueMap()))
37+
}
38+
39+
// UnmarshalText implements [encoding.TextUnmarshaler] so that we can map from
40+
// strings to our typed enum. If there is no appropriate mapping,
41+
// [ErrInvalidEnumValue] is returned.
42+
func (e *Enum[T]) UnmarshalText(text []byte) error {
43+
allVals := e.Value.DialsValueMap()
44+
if mapping, ok := allVals[string(text)]; ok {
45+
e.Value = mapping
46+
return nil
47+
}
48+
return &ErrInvalidEnumValue{
49+
Input: string(text),
50+
Allowed: e.AllowedEnumValues(),
51+
}
52+
}
53+
54+
// FuzzyEnum is just like an [Enum], but does case-insensitive comparisons. Just
55+
// like Enums, FuzzyEnums are integrated into the flags packages to
56+
// automatically amend the `Usage` text to include all allowed values and will
57+
// also indicate that the matching is case-insensitive.
58+
type FuzzyEnum[T ValueMap[T]] struct {
59+
Value T
60+
}
61+
62+
// FuzzyEnumComparer is an interface that allows us to detect that
63+
// case-insensitive comparison has been used.
64+
type FuzzyEnumComparer interface {
65+
isFuzzy()
66+
}
67+
68+
// AllowedEnumValues implements the [EnumValuable] interface.
69+
func (f FuzzyEnum[T]) AllowedEnumValues() []string {
70+
return slices.Sorted(maps.Keys(f.Value.DialsValueMap()))
71+
}
72+
73+
// isFuzzy implements the FuzzyEnumComparer interface.
74+
func (f FuzzyEnum[T]) isFuzzy() {}
75+
76+
// UnmarshalText implements [encoding.TextUnmarshaler] and does a case-insensitive
77+
// comparison of the string to map back to the appropriate value from the
78+
// enumeration. If there is no appropriate mapping, [ErrInvalidEnumValue] is
79+
// returned.
80+
func (f *FuzzyEnum[T]) UnmarshalText(text []byte) error {
81+
allVals := f.Value.DialsValueMap()
82+
noCase := make(map[string]T, len(allVals))
83+
for k, v := range allVals {
84+
noCase[strings.ToLower(k)] = v
85+
}
86+
87+
if mapping, ok := noCase[strings.ToLower(string(text))]; ok {
88+
f.Value = mapping
89+
return nil
90+
}
91+
92+
return &ErrInvalidEnumValue{
93+
Input: string(text),
94+
Allowed: f.AllowedEnumValues(),
95+
Fuzzy: true,
96+
}
97+
}
98+
99+
// ErrInvalidEnumValue is an error that is returned when there is no mapping for
100+
// a particular value in the enumeration.
101+
type ErrInvalidEnumValue struct {
102+
// All the allowed inputs.
103+
Allowed []string
104+
// The input that was provided (not in the Allowed list).
105+
Input string
106+
// true if the comparison is case-insensitive.
107+
Fuzzy bool
108+
}
109+
110+
// Error implements the error interface.
111+
func (e *ErrInvalidEnumValue) Error() string {
112+
return fmt.Sprintf("value %q is not part of the allowed enumeration: %+v, fuzzy: %t", e.Input, e.Allowed, e.Fuzzy)
113+
}
114+
115+
// StringValueMap is a helper that converts string-ish typed-enums to the
116+
// mapping table expected by the [ValueMap] interface.
117+
func StringValueMap[T ~string](in ...T) map[string]T {
118+
mapping := make(map[string]T, len(in))
119+
for _, element := range in {
120+
mapping[string(element)] = element
121+
}
122+
return mapping
123+
}
124+
125+
// StringerValueMap is a helper that converts types that implement the
126+
// [fmt.Stringer] interface to the mapping table expected by the [ValueMap]
127+
// interface.
128+
func StringerValueMap[T fmt.Stringer](in ...T) map[string]T {
129+
mapping := make(map[string]T, len(in))
130+
for _, element := range in {
131+
mapping[element.String()] = element
132+
}
133+
return mapping
134+
}
135+
136+
// EnumValue is a helper that's particularly useful when setting enum defaults in configuration.
137+
func EnumValue[T ValueMap[T]](in T) Enum[T] {
138+
return Enum[T]{
139+
Value: in,
140+
}
141+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package integrationtests_test
2+
3+
import (
4+
"context"
5+
6+
"fmt"
7+
8+
"github.com/vimeo/dials"
9+
"github.com/vimeo/dials/decoders/json"
10+
"github.com/vimeo/dials/sources/static"
11+
)
12+
13+
type Protocol int
14+
15+
const (
16+
HTTP Protocol = iota
17+
HTTPS
18+
Gopher
19+
FTP
20+
)
21+
22+
func (p Protocol) DialsValueMap() map[string]Protocol {
23+
return map[string]Protocol{
24+
"http": HTTP,
25+
"https": HTTPS,
26+
"gopher": Gopher,
27+
"ftp": FTP,
28+
}
29+
}
30+
31+
func (p Protocol) String() string {
32+
switch p {
33+
case HTTP:
34+
return "HyperText Transfer Protocol"
35+
case HTTPS:
36+
return "Secure HyperText Transfer Protocol"
37+
case Gopher:
38+
return "Gopher"
39+
case FTP:
40+
return "File Transfer Protocol"
41+
}
42+
return "unknown"
43+
}
44+
45+
type EnumConfig struct {
46+
SelectedProto dials.Enum[Protocol]
47+
Backup dials.Enum[Protocol]
48+
}
49+
50+
func ExampleEnum() {
51+
52+
source := &static.StringSource{
53+
Data: `{"selectedProto": "gopher"}`,
54+
Decoder: &json.Decoder{},
55+
}
56+
57+
c := &EnumConfig{
58+
Backup: dials.EnumValue(HTTPS),
59+
}
60+
61+
d, err := dials.Config(context.Background(), c, source)
62+
if err != nil {
63+
panic("something went wrong!")
64+
}
65+
66+
v := d.View()
67+
fmt.Printf("selected: %s, backup: %s", v.SelectedProto.Value, v.Backup.Value)
68+
// Output: selected: Gopher, backup: Secure HyperText Transfer Protocol
69+
}

0 commit comments

Comments
 (0)