sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.1.0). Switch to the current version →

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#

go
go
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/FromJSON are 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 json extension, 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-config target) 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.cert becomes {"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["…", "…"].
properties
properties
# 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#

go
go
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:

go
go
// 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#

go
go
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:

sh
sh
$ 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.