puzzle

package module
v0.0.0-...-4c9bcf5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 28, 2025 License: MIT Imports: 14 Imported by: 4

README

asiffer/puzzle

Introduction

All we need is configuration. Yes we also need commands, flags, env variables, config files... but they are just frontends. Why should we create configuration from command flags? And not the opposite?

puzzle aims to centralize the configuration management and to automatically create the bindings you need from other sources at runtime (like environment, json files, flag, spf13/cobra, urfave/cli...). No annotations, just generics.

Go Report Card Test Go Reference GitHub License Library base size codecov

Install

go get -u github.com/asiffer/puzzle
import "github.com/asiffer/puzzle"

Get started

First define a Config object.

// config.go

var config = puzzle.NewConfig()

Then, anywhere in your code, define configuration variables.

func init() {
    puzzle.Define[string](config, "question", "The Ultimate Question of Life, the Universe and Everything")
    puzzle.Define[int](config, "answer", 42)
}

You can also be responsible of variable storage.

import "github.com/asiffer/puzzle"

var question string = "The Ultimate Question of Life, the Universe and Everything"
var anwser int = 42

func init() {
    puzzle.DefineVar[string](config, "question", &question)
    puzzle.DefineVar[int](config, "answer", &anwser)
}

You can access it the Get method.

func main() {
    q, err := puzzle.Get[string](config, "question")
    if err != nil {
        panic(err)
    }
    fmt.Println(q == question)
}

But, the most interesting thing is that you can directly create the related flags.

import "github.com/asiffer/puzzle/flagset"

func main() {
    // from your config, generate the flagset
    fs, err := flagset.Build(config, "myApp", flag.PanicOnError)
    if err != nil {
        panic(err)
    }
    // fill the config while parsing flags
    if err := fs.Parse(os.Args); err != nil {
        panic(err)
    }
    
    // access via puzzle
    q, err := puzzle.Get[string](config, "question")
    if err != nil {
        panic(err)
    } else {
        fmt.Println(q)
    }
    // or directly (if defined with puzzle.DefineVar[string](config, "question", &question))
    fmt.Println(question)
}

Frontends

Once a config is defined. The goal of puzzle is to be able to automatically binds to incoming source (a.k.a. frontends).

Environment

package main

import (
	"fmt"

	"github.com/asiffer/puzzle"
)

var config = puzzle.NewConfig()

var counter uint64 = 0
var adminUser string = "me"
var secret string = "p4$$w0rD"

func init() {
	puzzle.DefineVar(config, "admin-user", &adminUser)                   // default env name is set to ADMIN_USER
	puzzle.DefineVar(config, "count", &counter, puzzle.WithEnvName("N")) // we redefine it to N
	puzzle.DefineVar(config, "secret", &secret, puzzle.WithoutEnv())     // we disable env for this entry
}

func main() {
	// update the config from env
	if err := puzzle.ReadEnv(config); err != nil {
		panic(err)
	}
	fmt.Println(counter, adminUser, secret)
}

CLI flags

There are several options to parse input flags. The puzzle library aims to target the most popular.

flag
package main

import (
	"flag"
	"fmt"
	"os"

	"github.com/asiffer/puzzle"
	"github.com/asiffer/puzzle/flagset"
)

var config = puzzle.NewConfig()

var counter uint64 = 1
var adminUser string = "me"
var secret string = "p4$$w0rD"

func init() {
	puzzle.DefineVar(config, "admin-user", &adminUser)                         // default flag is set to -admin-user
	puzzle.DefineVar(config, "count", &counter, puzzle.WithFlagName("number")) // we redefine it to -number
	puzzle.DefineVar(config, "secret", &secret, puzzle.WithoutFlagName())      // we disable flag for this entry
}

func main() {
	fs, err := flagset.Build(config, "myApp", flag.ContinueOnError)
	if err != nil {
		panic(err)
	}

	// all the config is updated when args are parsed
	if err := fs.Parse(os.Args[1:]); err != nil {
		panic(err)
	}

	fmt.Println(counter, adminUser, secret)
}

spf13/pflag
package main

import (
	"fmt"
	"os"

	"github.com/asiffer/puzzle"
	"github.com/asiffer/puzzle/pflagset"
	"github.com/spf13/pflag"
)

var config = puzzle.NewConfig()

var counter uint64 = 1
var adminUser string = "me"
var secret string = "p4$$w0rD"
var verbose = false

func init() {
	puzzle.DefineVar(config, "admin-user", &adminUser)                           // default flag is set to -admin-user
	puzzle.DefineVar(config, "count", &counter, puzzle.WithFlagName("number"))   // we redefine it to -number
	puzzle.DefineVar(config, "secret", &secret, puzzle.WithoutFlagName())        // we disable flag for this entry
	puzzle.DefineVar(config, "verbose", &verbose, puzzle.WithShortFlagName("v")) // you can use -v
}

func main() {
	fs, err := pflagset.Build(config, "myApp", pflag.ContinueOnError)
	if err != nil {
		panic(err)
	}

	// all the config is updated when args are parsed
	if err := fs.Parse(os.Args[1:]); err != nil {
		panic(err)
	}

	fmt.Println(counter, adminUser, secret, verbose)
}

spf13/cobra
package main

import (
	"fmt"

	"github.com/asiffer/puzzle"
	"github.com/asiffer/puzzle/pflagset"
	"github.com/spf13/cobra"
)

var config = puzzle.NewConfig()

var counter uint64 = 1
var adminUser string = "me"
var secret string = "p4$$w0rD"
var verbose = false

var rootCmd = &cobra.Command{
	Use:   "puzzle-cobra",
	Short: "A example of binding puzzle and spf13/cobra",
	Run: func(cmd *cobra.Command, args []string) {
		// Do Stuff Here
		fmt.Println(counter, adminUser, secret, verbose)
	},
}

func init() {
	puzzle.DefineVar(config, "admin-user", &adminUser)                           // default flag is set to -admin-user
	puzzle.DefineVar(config, "count", &counter, puzzle.WithFlagName("number"))   // we redefine it to -number
	puzzle.DefineVar(config, "secret", &secret, puzzle.WithoutFlagName())        // we disable flag for this entry
	puzzle.DefineVar(config, "verbose", &verbose, puzzle.WithShortFlagName("v")) // you can use -v
}

func main() {
	pflagset.Populate(config, rootCmd.Flags()) // here is the magic
	if err := rootCmd.Execute(); err != nil {
		panic(err)
	}
}

urfave/cli/v3
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/asiffer/puzzle"
	"github.com/asiffer/puzzle/urfave3"
	"github.com/urfave/cli/v3"
)

var config = puzzle.NewConfig()

var counter uint64 = 0
var adminUser string = "me"
var secret string = "p4$$w0rD"

func init() {
	puzzle.DefineVar(config, "admin-user", &adminUser, puzzle.WithShortFlagName("a"))
	puzzle.DefineVar(config, "count", &counter)
	puzzle.DefineVar(config, "secret", &secret)
}

func main() {
	flags0, err := urfave3.Build(config)
	if err != nil {
		panic(err)
	}

	cmd := &cli.Command{
		Name:  "puzzle-urfave3",
		Usage: "A example of binding puzzle and urfave/cli (v3)",
		Flags: flags0,
		Action: func(ctx context.Context, cmd *cli.Command) error {
			fmt.Println(counter, adminUser, secret)
			return nil
		},
	}

	if err := cmd.Run(context.Background(), os.Args); err != nil {
		log.Fatal(err)
	}
}

JSON file

package main

import (
	"fmt"

	"github.com/asiffer/puzzle"
	"github.com/asiffer/puzzle/jsonfile"
)

var config = puzzle.NewConfig()

var counter uint64 = 0
var adminUser string = "me"
var secret string = "p4$$w0rD"

func init() {
	puzzle.DefineConfigFile(config, "config", []string{"config.json"})
	puzzle.DefineVar(config, "admin-user", &adminUser) // we directly use the key to read the json
	puzzle.DefineVar(config, "count", &counter)
	puzzle.DefineVar(config, "secret", &secret)
}

func main() {
	if err := jsonfile.ReadJSON(config); err != nil {
		// /!\ if it fails during parsing the json file the config can be corrupted
		// (only some values are updated)
		panic(err)
	}
	// all the config is updated
	fmt.Println(counter, adminUser, secret)
}

JSON Schema

package main

import (
	"encoding/json"
	"fmt"

	"github.com/asiffer/puzzle"
	"github.com/asiffer/puzzle/jsonschema"
)

var config = puzzle.NewConfig()

var counter uint64 = 0
var adminUser string = "me"
var secret string = "p4$$w0rD"

func init() {
	puzzle.DefineConfigFile(config, "config", []string{"config.json"})
	puzzle.DefineVar(config, "admin-user", &adminUser) // we directly use the key to read the json
	puzzle.DefineVar(config, "count", &counter)
	puzzle.DefineVar(config, "secret", &secret)
}

func main() {
	// generate the schema from your config
	schema, err := jsonschema.Generate(config)
	if err != nil {
		// /!\ if it fails during parsing the json file the config can be corrupted
		// (only some values are updated)
		panic(err)
	}
	// export the schema
	bytes, err := json.MarshalIndent(schema, "", "  ")
	if err != nil {
		panic(err)
	}
	fmt.Println(string(bytes))
}

Customizations

While defining a config variable you can customize some of its properties, useful for subsequent flag generation or env parsing tasks.

var exampleVar time.Duration = 5 * time.Minute

puzzle.DefineVar[time.Duration](
    config, // your config
    "example_var", // the key to access it
    &exampleVar, // the storage location
    puzzle.WithDescription("my example variable"), // for flag usage notably
    puzzle.WithEnvName("EXAMPLE"), // instead of EXAMPLE_VAR
    puzzle.WithFlagName("example"), // instead of example-var
    puzzle.WithShortFlagName("e"), // no short flag by default (used for pflag)
)

Patterns

Helpers (DX)

In the case where we have a single config, we can create wrappers to ease config definition. In the example below, we hide konf in a dedicated config package, exposing only (simpler) i/o functions.

// config/config.go
package config

// the configuration is hidden from other packages
var konf = puzzle.NewConfig()

func Define[T any](key string, defaultValue T, options ...puzzle.MetadataOption) error {
    return puzzle.Define[T](konf, key, defaultValue, options...)
}

func DefineVar[T any](key string, boundVariable *T, options ...puzzle.MetadataOption) error {
    return puzzle.DefineVar[T](konf, key, boundVariable, options...)
}

func Get[T](key string) (T, error) {
    return puzzle.Get[T](konf, key)
}

Config struct

If your whole config is stored in a struct, you probably need another library to manage it. At small scale, you can use puzzle on every attribute.

var config = puzzle.NewConfig()

type ConfigurationType struct {
    Level int 
    Verbose bool 
    Name string
    Modules []string
}

var Configuration = Configuration{
    Level: 1,
    Verbose: false,
    Name: "remote",
    Modules: []string{"user", "auth"}
}

func init() {
    puzzle.DefineVar[int](config, "level", &Configuration.Level)
    puzzle.DefineVar[bool](config, "verbose", &Configuration.Verbose)
    puzzle.DefineVar[string](config, "remote", &Configuration.Name)
    puzzle.DefineVar[[]string](config, "modules", &Configuration.Modules)
}

Config file

In many cases, you may need to read the config from both the command line and a config file (also provided by the command line). To handle this case, you should split the process (ignoring or considering only the config key).

Configurable config file

In this case, the config file can be configured by the end user. The following example gives an example where we take its value from cli flags (but it could be read from env or any other supported source).

var level int = 3

var config = puzzle.NewConfig()

func init() {
    puzzle.DefineVar[int](config, "level", &level)
    puzzle.DefineConfigFile(config, "config", []string{"conf.json", "/etc/app/conf.json"})
}

func main() {
    // if you need to read the config from the command line
    fs, err := flagset.Build(config.Only("config"), "myApp", flag.PanicOnError)
    if err != nil {
        panic(err)
    }

    // set the value of the config file
    if err := fs.Parse(os.Args); err != nil {
        panic(err)
    }
    // here the value of the config file is populated
    // we just have to read it with ReadJSON()
    // (puzzle looks for the value, opens the file and reads it)
    if err := config.ReadJSON(); err != nil {
        panic(err)
    }
    // then we can read other flags
    fs, err = flagset.Build(config.Ignoring("config"), "myApp", flag.PanicOnError)
    if err != nil {
        panic(err)
    }
    // here all the config is then populated
}
Hardcoded config file

If you don't need to set the config file from command line (only using your default values), it is a bit simpler.

var level int = 3

var config = puzzle.NewConfig()

func init() {
    puzzle.DefineVar[int](config, "level", &level)
    puzzle.DefineConfigFile(config, "config", []string{"conf.json"})
}

func main() {
    // generally we first read the config file
    if err := config.ReadJSON(); err != nil {
        panic(err)
    }
    // and then we override the values with the defined flags
    fs, err := flagset.Build(config.Ignoring("config"), "myApp", flag.PanicOnError)
    if err != nil {
        panic(err)
    }

    if err := fs.Parse(os.Args); err != nil {
        panic(err)
    }
    // here all the config is then populated
}

Separation of concerns

Sometimes, we do not have a single config, it is rather a puzzle. Obviously, everything can be put in the same puzzle.Config structure but we can also manage several configs.

var (
	ConfigFrontend = puzzle.NewConfig()
	ConfigBackend = puzzle.NewConfig()
)

It may be relevant when they are likely to be populated at a different moment, or from a different frontend.

Supported types

[!CAUTION] puzzle panics if we try to define an unsupported type variable

Type Supported
bool
time.Duration
float32
float64
int
int8
int16
int32
int64
string
uint
uint8
uint16
uint32
uint64
[]byte
[]string
net.IP

Developer

This library is built around boilerplate code so all the contributions are welcome! Naturally, we may need to support either new types or new frontends.

Supporting a new type

To support a new types, several steps must be performed. First a new xxxx.go file must be created at the root of the project where xxxx is the type.

This file must define a converter specific to the new type.

// xxxx.go
package puzzle

var XxxxConverter = newConverter(xxxxConverter)

func xxxxConverter(entry *Entry[xxxx], stringValue string) error {
	value, err := xxxxFromString(stringValue) // here is the paramount step where we must be able to parse it from string
	if err != nil {
		return err
	}
	*entry.ValueP = value
	entry.Value = value
	return nil
}

Then this converter must be bound to the related entry. It is done in the wire() method in entry.go.

// Wire performs all the plumbing
func (e *Entry[T]) wire() {
	switch z := any(e).(type) {
	case *Entry[bool]:
		z.converter = BoolConverter
	case *Entry[time.Duration]:
		z.converter = DurationConverter
	// ...
    case *Entry[xxxx]: // <- new entry type
        z.converter = XxxxConverter
    // ...
    }
}

Finally, this support should be propagated to all the frontends (look at the sub-packages).

Supporting a new frontend

To create a new frontend to bind the puzzle config to, a new sub-package must be created.

├─ README.md
├─ go.mod
├─ go.sum
├─ ...*.go
├─ zzzzzzz/ <- new folder
│  └─ frontend.go
└─ ...

Documentation

Index

Constants

View Source
const DEFAULT_NESTING_SEPARATOR = "."

Variables

View Source
var BoolConverter = newConverter(boolFromString)
View Source
var BytesConverter = newConverter(bytesFromString)
View Source
var Float32Converter = newConverter(float32FromString)
View Source
var Float64Converter = newConverter(float64FromString)
View Source
var IPConverter = newConverter(ipFromString)
View Source
var Int16Converter = newConverter(int16FromString)
View Source
var Int32Converter = newConverter(int32FromString)
View Source
var Int64Converter = newConverter(int64FromString)
View Source
var Int8Converter = newConverter(int8FromString)
View Source
var IntConverter = newConverter(intFromString)
View Source
var StringConverter = newConverter(
	func(entry *Entry[string], stringValue string) error {
		*entry.ValueP = stringValue
		entry.Value = stringValue
		return nil
	},
)
View Source
var StringSliceConverter = newConverter(stringSliceFromString)
View Source
var Uint16Converter = newConverter(uint16FromString)
View Source
var Uint32Converter = newConverter(uint32FromString)
View Source
var Uint64Converter = newConverter(uint64FromString)
View Source
var Uint8Converter = newConverter(uint8FromString)
View Source
var UintConverter = newConverter(uintFromString)

Functions

func Define

func Define[T any](config *Config, key string, defaultValue T, options ...MetadataOption) error

Define lets the config object store the Value

func DefineConfigFile

func DefineConfigFile(config *Config, key string, defaultValue []string, options ...MetadataOption) error

func DefineVar

func DefineVar[T any](config *Config, key string, boundVariable *T, options ...MetadataOption) error

DefineVar lets the user store the Value

func GenerateEnvName

func GenerateEnvName(key string) string

func GenerateFlagName

func GenerateFlagName(key string) string

func Get

func Get[T any](config *Config, key string) (T, error)

Get retrieves the Value from the config object

func ReadEnv

func ReadEnv(c *Config) error

ReadEnv reads environment variables and updates the Config entries accordingly. It looks for each entry's EnvName metadata and tries to find the corresponding environment variable. If found, it converts the value using the string converter (Set method).

func ReadForm

func ReadForm(c *Config, form url.Values) error

ReadForm reads the form values from the provided url.Values and updates them in the Config. It relies on the string frontend to convert the values (what the Set method does).

Types

type Config

type Config struct {
	NestingSeparator string
	// contains filtered or unexported fields
}

Config is a structure that plays the role of a namespace if you need to manage several configurations

func NewConfig

func NewConfig() *Config

NewConfig inits a new config object

func (*Config) Accept

func (config *Config) Accept(key string) bool

Accept checks if the key is accepted by all filters

func (*Config) Entries

func (config *Config) Entries() <-chan EntryInterface

Entries iterates over the entries of the config, applying the filters This is the single way to access the entrie of the config

func (*Config) GetEntry

func (config *Config) GetEntry(key string) (EntryInterface, bool)

func (*Config) Ignoring

func (config *Config) Ignoring(keys ...string) *Config

Ignoring creates a new view of the same config, ignoring some keys

func (*Config) Only

func (config *Config) Only(keys ...string) *Config

Only creates a new view of the same config, accepting only some keys

func (*Config) Sort

func (config *Config) Sort()

Sort sorts the entries of the config by their keys in alphabetical order

func (*Config) SortFunc

func (config *Config) SortFunc(fun func([]string) []string)

SortFunc allows to sort the entries of the config using a custom function The function should take a slice of strings (the keys) and return a sorted slice of strings even if it is sorted in-place. This function can be run after a previous sort

func (*Config) ToFlags

func (config *Config) ToFlags(useShort bool) []string

ToFlags converts the config entries to a slice of command line flags If useShort is true, it will use the short flag names if available, otherwise it will use the long flag names If the entry is a boolean, it will only add the flag if the value is true

type ConfigFilter

type ConfigFilter func(key string) bool

type ConvertCallback

type ConvertCallback[T any] func(entry *Entry[T], args ...interface{}) error

func ConvertCallbackFactory1

func ConvertCallbackFactory1[T any, A any](fun func(entry *Entry[T], arg A) error) ConvertCallback[T]

ConvertCallbackFactory1 is a function that turns a specific 1-argument function into a generic ConvertCallback

func ConvertCallbackFactory2

func ConvertCallbackFactory2[T any, A any, B any](fun func(entry *Entry[T], arg0 A, arg1 B) error) ConvertCallback[T]

ConvertCallbackFactory2 is a function that turns a specific 2-argument function into a generic ConvertCallback

type Converter

type Converter[T any] interface {
	Register(name Frontend, callback ConvertCallback[T]) error
	Convert(frontend Frontend, entry *Entry[T], args ...any) error
}

A Converter is an object attached to a type. It may supports several frontends that are able to convert the entry to their own language

var DurationConverter Converter[time.Duration] = newConverter(durationFromString)

type Entry

type Entry[T any] struct {
	Metadata *EntryMetadata
	Key      string
	ValueP   *T // to bind the Value if not stored by the entry
	Value    T
	// contains filtered or unexported fields
}

Entry is a structure that represents an item in the configuration

func NewEntry

func NewEntry[T any](Key string) *Entry[T]

func (*Entry[T]) Convert

func (e *Entry[T]) Convert(frontend Frontend, args ...any) error

Convert converts the entry value using the provided frontend and arguments. It updates the value of the entry.

func (*Entry[T]) Get

func (e *Entry[T]) Get() interface{}

Method to be compatible with urfave/cli.Value interface

func (*Entry[T]) GetKey

func (e *Entry[T]) GetKey() string

GetKey returns the key of the entry

func (*Entry[T]) GetMetadata

func (e *Entry[T]) GetMetadata() *EntryMetadata

GetMetadata returns the metadata of the entry

func (*Entry[T]) GetValue

func (e *Entry[T]) GetValue() interface{}

GetValue returns the value of the entry as an interface{}.

func (*Entry[T]) IsBoolFlag

func (e *Entry[T]) IsBoolFlag() bool

For flag package compatibility see https://pkg.go.dev/flag#Value

func (*Entry[T]) Set

func (e *Entry[T]) Set(value string) error

Method to be compatible with flag.Value interface (and spf13/pflag.Value interface)

func (*Entry[T]) String

func (e *Entry[T]) String() string

Method to be compatible with flag.Value interface (and spf13/pflag.Value interface)

func (*Entry[T]) Type

func (e *Entry[T]) Type() string

Method to be compatible with spf13/pflag.Value interface

type EntryInterface

type EntryInterface interface {
	GetKey() string
	GetValue() interface{}
	GetMetadata() *EntryMetadata

	Set(string) error
	String() string
	Convert(frontend Frontend, args ...any) error
}

EntryInterface is the object that unifies all the provided entries (that have different types)

type EntryMetadata

type EntryMetadata struct {
	Description    string
	FlagName       string
	ShortFlagName  string
	EnvName        string
	Format         string
	SliceSeparator rune
	IsConfigFile   bool
	IsBool         bool
}

type Frontend

type Frontend string
const (
	StringFrontend Frontend = "string"
)

type MetadataOption

type MetadataOption = func(*EntryMetadata)

func WithDescription

func WithDescription(description string) MetadataOption

func WithEnvName

func WithEnvName(name string) MetadataOption

func WithFlagName

func WithFlagName(name string) MetadataOption

func WithFormat

func WithFormat(format string) MetadataOption

func WithShortFlagName

func WithShortFlagName(name string) MetadataOption

func WithSliceSeparator

func WithSliceSeparator(sep rune) MetadataOption

func WithoutEnv

func WithoutEnv() MetadataOption

func WithoutFlagName

func WithoutFlagName() MetadataOption

Directories

Path Synopsis
examples
urfave3 command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL