feat: unify generated configuration loading and extensible decoders
This commit is contained in:
175
config/loader.go
Normal file
175
config/loader.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxConfigBytes = 1 << 20
|
||||
const maxConfigDepth = 64
|
||||
const maxConfigNodes = 100000
|
||||
|
||||
// Decoder must not retain or mutate input and must support concurrent calls
|
||||
// when its Loader is shared. It is trusted application code, not a sandbox.
|
||||
type Decoder interface {
|
||||
Decode([]byte) (Document, error)
|
||||
}
|
||||
type DecoderFunc func([]byte) (Document, error)
|
||||
|
||||
func (f DecoderFunc) Decode(data []byte) (Document, error) { return f(data) }
|
||||
|
||||
// Loader owns an immutable per-instance decoder registry. No global registration.
|
||||
type Loader struct {
|
||||
decoders map[string]Decoder
|
||||
sealed bool
|
||||
}
|
||||
type LoaderOption func(*Loader) error
|
||||
|
||||
// WithDecoder adds a new extension; builtin/duplicate extensions cannot be replaced.
|
||||
func WithDecoder(extension string, decoder Decoder) LoaderOption {
|
||||
return func(l *Loader) error {
|
||||
if l == nil || l.decoders == nil || l.sealed {
|
||||
return errors.New("config: decoder registry cannot be changed after construction")
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimPrefix(extension, "."))
|
||||
if ext == "" || strings.ContainsAny(ext, "./\\ \t\r\n") {
|
||||
return errors.New("config: invalid decoder extension")
|
||||
}
|
||||
if decoder == nil {
|
||||
return errors.New("config: nil decoder")
|
||||
}
|
||||
v := reflect.ValueOf(decoder)
|
||||
if (v.Kind() == reflect.Pointer || v.Kind() == reflect.Func || v.Kind() == reflect.Map || v.Kind() == reflect.Slice || v.Kind() == reflect.Interface) && v.IsNil() {
|
||||
return errors.New("config: nil decoder")
|
||||
}
|
||||
if _, exists := l.decoders[ext]; exists {
|
||||
return errors.New("config: decoder extension already registered")
|
||||
}
|
||||
l.decoders[ext] = decoder
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewLoader(options ...LoaderOption) (*Loader, error) {
|
||||
l := &Loader{decoders: map[string]Decoder{"json": JSONDecoder{}, "yaml": YAMLDecoder{}, "yml": YAMLDecoder{}, "xml": XMLDecoder{}}}
|
||||
for _, o := range options {
|
||||
if o == nil {
|
||||
return nil, errors.New("config: nil loader option")
|
||||
}
|
||||
if err := o(l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
l.sealed = true
|
||||
return l, nil
|
||||
}
|
||||
|
||||
var defaultLoader, _ = NewLoader()
|
||||
|
||||
func selectLoader(loaders []*Loader) (*Loader, error) {
|
||||
if len(loaders) == 0 {
|
||||
return defaultLoader, nil
|
||||
}
|
||||
if len(loaders) != 1 || loaders[0] == nil {
|
||||
return nil, errors.New("config: expected one non-nil loader")
|
||||
}
|
||||
return loaders[0], nil
|
||||
}
|
||||
func (l *Loader) read(path string) (doc Document, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
var d *Error
|
||||
if errors.As(err, &d) {
|
||||
c := *d
|
||||
c.File = path
|
||||
err = &c
|
||||
} else {
|
||||
reason := "cannot read configuration file"
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
reason = "configuration file does not exist"
|
||||
} else if errors.Is(err, os.ErrPermission) {
|
||||
reason = "permission denied reading configuration file"
|
||||
}
|
||||
err = &Error{File: path, Reason: reason, cause: err}
|
||||
}
|
||||
}
|
||||
}()
|
||||
decoder := l.decoders[strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")]
|
||||
if decoder == nil {
|
||||
return doc, &Error{Reason: "unsupported configuration format"}
|
||||
}
|
||||
info, e := os.Stat(path)
|
||||
if e != nil {
|
||||
return doc, e
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return doc, &Error{Reason: "configuration must be a regular file"}
|
||||
}
|
||||
f, e := os.Open(path)
|
||||
if e != nil {
|
||||
return doc, e
|
||||
}
|
||||
defer f.Close()
|
||||
info, e = f.Stat()
|
||||
if e != nil {
|
||||
return doc, e
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return doc, &Error{Reason: "configuration must be a regular file"}
|
||||
}
|
||||
data, e := io.ReadAll(io.LimitReader(f, maxConfigBytes+1))
|
||||
if e != nil {
|
||||
return doc, e
|
||||
}
|
||||
if len(data) > maxConfigBytes {
|
||||
return doc, &Error{Reason: "file exceeds 1 MiB"}
|
||||
}
|
||||
doc, err = decodeSafely(decoder, data)
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
if _, ok := doc.Value.(map[string]any); !ok {
|
||||
return doc, doc.diagnostic("", "root must be an object", nil)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
func decodeSafely(decoder Decoder, data []byte) (doc Document, err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
err = &Error{Reason: "decoder panicked"}
|
||||
}
|
||||
}()
|
||||
doc, err = decoder.Decode(data)
|
||||
if err != nil {
|
||||
var d *Error
|
||||
if !errors.As(err, &d) {
|
||||
err = &Error{Reason: "invalid configuration syntax", cause: err}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DecodeFile overlays a bounded document onto independent defaults. With no
|
||||
// loader argument it accepts JSON/YAML/XML. It never opens dependencies.
|
||||
func DecodeFile[T any](path string, defaults T, loaders ...*Loader) (T, error) {
|
||||
var zero T
|
||||
l, err := selectLoader(loaders)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
doc, err := l.read(path)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
result, err := bindDocument(doc, defaults)
|
||||
if err != nil {
|
||||
d := validationError(err)
|
||||
d.File = path
|
||||
return zero, d
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user