Custom Codecs

Extend configuration support to custom formats with codec implementation

The config package allows you to extend configuration support to any format by implementing and registering custom codecs.

Encoder and Decoder

Registration uses codec.Type as the name. A format codec is usually one type that implements both codec.Encoder and codec.Decoder:

type Encoder interface {
    Encode(v any) ([]byte, error)
}

type Decoder interface {
    Decode(data []byte, v any) error
}

Encode turns a value into bytes; Decode fills a value (often *map[string]any) from bytes.

Built-in Codecs

The config package includes several built-in codecs.

Format Codecs

CodecTypeCapabilities
JSONcodec.TypeJSONEncode & Decode
YAMLcodec.TypeYAMLEncode & Decode
TOMLcodec.TypeTOMLEncode & Decode
EnvVarcodec.TypeEnvVarDecode only

Caster Codecs

Caster codecs handle type conversion.

CodecTypeConverts To
Boolcodec.TypeCasterBoolbool
Intcodec.TypeCasterIntint
Int8/16/32/64codec.TypeCasterInt8, etc.int8, int16, etc.
Uintcodec.TypeCasterUintuint
Uint8/16/32/64codec.TypeCasterUint8, etc.uint8, uint16, etc.
Float32/64codec.TypeCasterFloat32, codec.TypeCasterFloat64float32, float64
Stringcodec.TypeCasterStringstring
Timecodec.TypeCasterTimetime.Time
Durationcodec.TypeCasterDurationtime.Duration

Implementing a Custom Codec

Basic Example: INI Format

Let’s implement a simple INI file codec.

package inicodec

import (
    "bufio"
    "bytes"
    "fmt"
    "strings"
    "rivaas.dev/config/codec"
)

type INICodec struct{}

func (c INICodec) Decode(data []byte, v any) error {
    result := make(map[string]any)
    scanner := bufio.NewScanner(bytes.NewReader(data))
    
    var currentSection string
    
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        
        // Skip empty lines and comments
        if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
            continue
        }
        
        // Section header
        if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
            currentSection = strings.Trim(line, "[]")
            if result[currentSection] == nil {
                result[currentSection] = make(map[string]any)
            }
            continue
        }
        
        // Key-value pair
        parts := strings.SplitN(line, "=", 2)
        if len(parts) != 2 {
            continue
        }
        
        key := strings.TrimSpace(parts[0])
        value := strings.TrimSpace(parts[1])
        
        if currentSection != "" {
            section := result[currentSection].(map[string]any)
            section[key] = value
        } else {
            result[key] = value
        }
    }
    
    // Type assertion to set result
    target := v.(*map[string]any)
    *target = result
    
    return scanner.Err()
}

func (c INICodec) Encode(v any) ([]byte, error) {
    data, ok := v.(map[string]any)
    if !ok {
        return nil, fmt.Errorf("expected map[string]any, got %T", v)
    }
    
    var buf bytes.Buffer
    
    for section, values := range data {
        sectionMap, ok := values.(map[string]any)
        if !ok {
            // Top-level key-value
            buf.WriteString(fmt.Sprintf("%s = %v\n", section, values))
            continue
        }
        
        // Section header
        buf.WriteString(fmt.Sprintf("[%s]\n", section))
        
        // Section key-values
        for key, value := range sectionMap {
            buf.WriteString(fmt.Sprintf("%s = %v\n", key, value))
        }
        
        buf.WriteString("\n")
    }
    
    return buf.Bytes(), nil
}

func init() {
    const iniType codec.Type = "ini"
    codec.RegisterEncoder(iniType, INICodec{})
    codec.RegisterDecoder(iniType, INICodec{})
}

Using the Custom Codec

package main

import (
    "context"
    "log"
    "rivaas.dev/config"
    "rivaas.dev/config/codec"
    _ "yourmodule/inicodec"  // Register codec via init()
)

func main() {
    cfg := config.MustNew(
        config.WithFileAs("config.ini", codec.Type("ini")),
    )
    
    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }
    
    host := cfg.String("server.host")
    port := cfg.Int("server.port")
    
    log.Printf("Server: %s:%d", host, port)
}

config.ini:

[server]
host = localhost
port = 8080

[database]
host = db.example.com
port = 5432

Registering Codecs

Codecs must be registered before use:

import "rivaas.dev/config/codec"

func init() {
    t := codec.Type("mytype")
    codec.RegisterEncoder(t, MyCodec{})
    codec.RegisterDecoder(t, MyCodec{})
}

Registration functions:

  • RegisterEncoder(name codec.Type, encoder codec.Encoder) — register an encoder
  • RegisterDecoder(name codec.Type, decoder codec.Decoder) — register a decoder

You can register the same codec for both or different codecs for each operation.

Decode-Only Codecs

Some codecs only support decoding (like the built-in EnvVar codec):

type EnvVarCodec struct{}

func (c EnvVarCodec) Decode(data []byte, v any) error {
    // Decode environment variable format
    // ...
}

func (c EnvVarCodec) Encode(v any) ([]byte, error) {
    return nil, errors.New("encoding to environment variables not supported")
}

func init() {
    codec.RegisterDecoder(codec.Type("envvar"), EnvVarCodec{})
    // Note: not registering an encoder
}

Advanced Example: XML Codec

A more complete example with error handling:

package xmlcodec

import (
    "encoding/xml"
    "fmt"
    "rivaas.dev/config/codec"
)

type XMLCodec struct{}

func (c XMLCodec) Decode(data []byte, v any) error {
    target, ok := v.(*map[string]any)
    if !ok {
        return fmt.Errorf("expected *map[string]any, got %T", v)
    }
    
    // XML unmarshaling to intermediate structure
    var intermediate struct {
        XMLName xml.Name
        Content []byte `xml:",innerxml"`
    }
    
    if err := xml.Unmarshal(data, &intermediate); err != nil {
        return fmt.Errorf("xml decode error: %w", err)
    }
    
    // Convert XML to map structure
    result := make(map[string]any)
    // ... conversion logic ...
    
    *target = result
    return nil
}

func (c XMLCodec) Encode(v any) ([]byte, error) {
    data, ok := v.(map[string]any)
    if !ok {
        return nil, fmt.Errorf("expected map[string]any, got %T", v)
    }
    
    // Convert map to XML structure
    xmlData, err := xml.MarshalIndent(data, "", "  ")
    if err != nil {
        return nil, fmt.Errorf("xml encode error: %w", err)
    }
    
    return xmlData, nil
}

func init() {
    xmlType := codec.Type("xml")
    codec.RegisterEncoder(xmlType, XMLCodec{})
    codec.RegisterDecoder(xmlType, XMLCodec{})
}

Caster Codecs

Built-in caster types are registered as decoders on the codec registry. Config getters (Int, Duration, and so on) use spf13/cast on merged values, not codec.GetDecoder(TypeCaster…) on each read.

port := cfg.Int("server.port")
timeout := cfg.Duration("timeout")

Custom Caster Example

If you need custom type conversion:

type URLCaster struct{}

func (c URLCaster) Decode(data []byte, v any) error {
    target, ok := v.(*url.URL)
    if !ok {
        return fmt.Errorf("expected *url.URL, got %T", v)
    }
    
    parsedURL, err := url.Parse(string(data))
    if err != nil {
        return fmt.Errorf("invalid URL: %w", err)
    }
    
    *target = *parsedURL
    return nil
}

func (c URLCaster) Encode(v any) ([]byte, error) {
    u, ok := v.(*url.URL)
    if !ok {
        return nil, fmt.Errorf("expected *url.URL, got %T", v)
    }
    return []byte(u.String()), nil
}

When to Create Custom Codecs

Use Custom Codecs For:

  1. Unsupported formats - XML, INI, HCL, proprietary formats
  2. Legacy formats - Converting old configuration formats
  3. Encrypted configurations - Decrypting config data
  4. Compressed data - Handling gzip/compressed configs
  5. Custom protocols - Special encoding schemes

Use Built-in Codecs For:

  1. Standard formats - JSON, YAML, TOML
  2. Type conversion on read — use typed getters; nested structures use struct binding
  3. Simple text formats - Can often use JSON/YAML

Best Practices

1. Error Handling

Provide clear error messages:

func (c MyCodec) Decode(data []byte, v any) error {
    if len(data) == 0 {
        return errors.New("empty data")
    }
    
    target, ok := v.(*map[string]any)
    if !ok {
        return fmt.Errorf("expected *map[string]any, got %T", v)
    }
    
    // ... decoding logic ...
    
    if err != nil {
        return fmt.Errorf("decode error at line %d: %w", line, err)
    }
    
    return nil
}

2. Type Validation

Validate expected types:

func (c MyCodec) Decode(data []byte, v any) error {
    target, ok := v.(*map[string]any)
    if !ok {
        return fmt.Errorf("MyCodec requires *map[string]any, got %T", v)
    }
    // ...
}

3. Use init() for Registration

Register codecs in init() for automatic setup:

func init() {
    t := codec.Type("myformat")
    codec.RegisterEncoder(t, MyCodec{})
    codec.RegisterDecoder(t, MyCodec{})
}

4. Thread Safety

Ensure your codec is thread-safe:

type MyCodec struct {
    // No mutable state
}

// OR use proper synchronization
type MyCodec struct {
    mu    sync.Mutex
    cache map[string]any
}

5. Document Your Codec

Include usage examples:

// MyCodec implements encoding/decoding for the XYZ format.
//
// Example usage:
//
//   import _ "yourmodule/mycodec"
//
//   cfg := config.MustNew(
//       config.WithFileAs("config.xyz", codec.Type("xyz")),
//   )
//
type MyCodec struct{}

Complete Example

package main

import (
    "context"
    "log"
    "rivaas.dev/config"
    "rivaas.dev/config/codec"
    _ "yourmodule/xmlcodec"   // Custom XML codec
    _ "yourmodule/inicodec"   // Custom INI codec
)

func main() {
    cfg := config.MustNew(
        config.WithFile("config.yaml"),           // Built-in YAML
        config.WithFileAs("config.xml", codec.Type("xml")), // Custom XML
        config.WithFileAs("config.ini", codec.Type("ini")), // custom INI
        config.WithEnv("MYAPP_"),                  // Built-in EnvVar
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }

    port := cfg.Int("server.port")
    host := cfg.String("server.host")
    
    log.Printf("Server: %s:%d", host, port)
}

Testing Custom Codecs

Write tests for your codecs:

func TestMyCodec_Decode(t *testing.T) {
    codec := MyCodec{}
    
    input := []byte(`
        [server]
        host = localhost
        port = 8080
    `)
    
    var result map[string]any
    err := subject.Decode(input, &result)
    
    assert.NoError(t, err)
    assert.Equal(t, "localhost", result["server"].(map[string]any)["host"])
    assert.Equal(t, "8080", result["server"].(map[string]any)["port"])
}

func TestMyCodec_Encode(t *testing.T) {
    codec := MyCodec{}
    
    data := map[string]any{
        "server": map[string]any{
            "host": "localhost",
            "port": 8080,
        },
    }
    
    output, err := subject.Encode(data)
    
    assert.NoError(t, err)
    assert.Contains(t, string(output), "[server]")
    assert.Contains(t, string(output), "host = localhost")
}

Next Steps

Tip: If you create a useful codec, consider contributing it to the community!