Writing a config format provider
JSON is the native config format — always enabled, handled by the
core itself. Everything else enters through a format provider: a
service that transcodes its format to and from JSON. The shipped
configfmt/yaml is one; this demo builds another, for Java
.properties files.
The contract#
type ConfigFormatProvider interface { Extensions() []string // e.g. ["properties"] ToJSON(in io.Reader) (io.Reader, error) // native format → JSON FromJSON(in io.Reader) (io.Reader, error) // JSON → native (--write-config) }
The rules that come with it:
ToJSON/FromJSONare pure stream transforms — the core uses them while loading config files, before anything is configured or started, so a provider must not depend on its own lifecycle.- Claiming the native
jsonextension, or an extension another provider already claims, is a startup violation. - Providers are ordinary services, registered cold. The provider whose
extension matched a file that was actually loaded (or the
--write-configtarget) is added as a closure seed; unused providers are ejected like any other cold service.
Mapping properties to JSON#
Properties files are flat, untyped key=value pairs, so the provider
makes three decisions:
- Dots nest:
tls.certbecomes{"tls": {"cert": …}}. - Types are guessed:
true/false→ bool, numeric → number, anything else → string. Wrapping a value in double quotes forces string (version="1.0"). - Indexed keys become arrays:
tags.0,tags.1→["…", "…"].
# mytool.properties — same config as the JSON/YAML examples mytool.name=alice mytool.port=8080 mytool.verbose=true mytool.timeout=1h30m mytool.tags.0=a mytool.tags.1=b mytool.tls.cert=/etc/ssl/mytool.pem mytool.tls.key=/etc/ssl/mytool.key
(1h30m survives as a string — it isn't numeric — which is exactly what
a time.Duration field wants. A bare 90 would arrive as a number and
be rejected: durations require units in every source.)
The provider#
package propfmt import ( "bufio" "bytes" "encoding/json" "fmt" "io" "sort" "strconv" "strings" fw "sxcli.dev/fw" ) type Properties struct{} func (p *Properties) Extensions() []string { return []string{"properties"} } func (p *Properties) ToJSON(in io.Reader) (io.Reader, error) { root := map[string]any{} sc := bufio.NewScanner(in) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || line[0] == '#' || line[0] == '!' { continue } sep := strings.IndexAny(line, "=:") if sep < 0 { return nil, fmt.Errorf("propfmt: no separator in %q", line) } key := strings.TrimSpace(line[:sep]) value := strings.TrimSpace(line[sep+1:]) insert(root, strings.Split(key, "."), typed(value)) } if err := sc.Err(); err != nil { return nil, err } out, err := json.Marshal(arrayify(root)) if err != nil { return nil, err } return bytes.NewReader(out), nil } func (p *Properties) FromJSON(in io.Reader) (io.Reader, error) { var root map[string]any if err := json.NewDecoder(in).Decode(&root); err != nil { return nil, err } var lines []string flatten("", root, &lines) sort.Strings(lines) return strings.NewReader(strings.Join(lines, "\n") + "\n"), nil }
The helpers carry the three mapping decisions:
// typed guesses the JSON type of an untyped properties value. func typed(v string) any { if len(v) > 1 && v[0] == '"' && v[len(v)-1] == '"' { return v[1 : len(v)-1] // quoted: definitely a string } if v == "true" || v == "false" { return v == "true" } if n, err := strconv.ParseFloat(v, 64); err == nil { return n } return v } // insert walks/creates nested maps along the dotted path. func insert(node map[string]any, path []string, v any) { for len(path) > 1 { child, ok := node[path[0]].(map[string]any) if !ok { child = map[string]any{} node[path[0]] = child } node = child path = path[1:] } node[path[0]] = v } // arrayify turns {"0": a, "1": b} into [a, b], recursively. func arrayify(v any) any { node, ok := v.(map[string]any) if !ok { return v } arr := make([]any, len(node)) isArr := len(node) > 0 for k, child := range node { child = arrayify(child) node[k] = child if i, err := strconv.Atoi(k); err == nil && i >= 0 && i < len(arr) { arr[i] = child } else { isArr = false } } if isArr { return arr } return node } // flatten renders JSON back to sorted key=value lines. func flatten(prefix string, v any, out *[]string) { switch t := v.(type) { case map[string]any: for k, child := range t { flatten(join(prefix, k), child, out) } case []any: for i, child := range t { flatten(join(prefix, strconv.Itoa(i)), child, out) } case string: if t == "true" || t == "false" { t = `"` + t + `"` // would be mis-guessed on the way back in } else if _, err := strconv.ParseFloat(t, 64); err == nil { t = `"` + t + `"` } *out = append(*out, prefix+"="+t) default: // bool, float64 *out = append(*out, fmt.Sprintf("%s=%v", prefix, t)) } } func join(prefix, k string) string { if prefix == "" { return k } return prefix + "." + k }
(Real Java properties have more: \uXXXX escapes, line continuations,
whitespace-separated pairs. Skipped for clarity — the shape of the
provider is the point.)
Registration and use#
func init() { fw.Register("propfmt", &Properties{}, fw.Provides[fw.ConfigFormatProvider]()) }
Blank-import it from main and .properties joins the formats the
binary understands — in the standard location search
(mytool-config.properties next to the binary, and so on), explicitly,
and as a --write-config target:
$ mytool -c ./mytool.properties # load it $ mytool --write-config -c ./mytool.properties # write/normalize it — # also converts from JSON/YAML
Roundtripping is the free lunch: because every provider goes through
JSON, --write-config converts between any two registered formats —
point it at a .properties target while your config lives in YAML and
the framework does the rest.