feat: unify generated configuration loading and extensible decoders
This commit is contained in:
398
config/decoders.go
Normal file
398
config/decoders.go
Normal file
@@ -0,0 +1,398 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type JSONDecoder struct{}
|
||||
type YAMLDecoder struct{}
|
||||
type XMLDecoder struct{}
|
||||
|
||||
type parseState struct {
|
||||
doc Document
|
||||
nodes int
|
||||
}
|
||||
|
||||
func newParseState() *parseState { return &parseState{doc: Document{Positions: map[string]Position{}}} }
|
||||
func (p *parseState) enter(path string, pos Position, depth int) error {
|
||||
p.nodes++
|
||||
p.doc.Positions[path] = pos
|
||||
if depth > maxConfigDepth || p.nodes > maxConfigNodes {
|
||||
return p.doc.diagnostic(path, "configuration nesting or node limit exceeded", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type sourcePositions struct{ starts []int }
|
||||
|
||||
func newSourcePositions(data []byte) sourcePositions {
|
||||
p := sourcePositions{starts: []int{0}}
|
||||
for i, c := range data {
|
||||
if c == '\n' {
|
||||
p.starts = append(p.starts, i+1)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (p sourcePositions) at(offset int64) Position {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
i := sort.Search(len(p.starts), func(i int) bool { return int64(p.starts[i]) > offset }) - 1
|
||||
return Position{Line: i + 1, Column: int(offset) - p.starts[i] + 1}
|
||||
}
|
||||
|
||||
var yamlLine = regexp.MustCompile(`line ([0-9]+)`)
|
||||
|
||||
func yamlSyntax(err error) *Error {
|
||||
d := &Error{Reason: "invalid YAML syntax", cause: err}
|
||||
if m := yamlLine.FindStringSubmatch(err.Error()); len(m) == 2 {
|
||||
d.Line, _ = strconv.Atoi(m[1])
|
||||
d.Column = 1
|
||||
}
|
||||
return d
|
||||
}
|
||||
func (JSONDecoder) Decode(data []byte) (Document, error) {
|
||||
p := newParseState()
|
||||
if len(data) > maxConfigBytes {
|
||||
return p.doc, &Error{Reason: "file exceeds 1 MiB"}
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return p.doc, &Error{Reason: "configuration must be UTF-8"}
|
||||
}
|
||||
positions := newSourcePositions(data)
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.UseNumber()
|
||||
var parse func(string, int) (any, error)
|
||||
parse = func(path string, depth int) (any, error) {
|
||||
offset := d.InputOffset()
|
||||
for offset < int64(len(data)) && strings.ContainsRune(" \t\r\n:,", rune(data[offset])) {
|
||||
offset++
|
||||
}
|
||||
if err := p.enter(path, positions.at(offset), depth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := d.Token()
|
||||
if err != nil {
|
||||
pos := positions.at(d.InputOffset())
|
||||
var syntax *json.SyntaxError
|
||||
if errors.As(err, &syntax) {
|
||||
pos = positions.at(syntax.Offset - 1)
|
||||
}
|
||||
return nil, &Error{Field: path, Line: pos.Line, Column: pos.Column, Reason: "invalid JSON syntax", cause: err}
|
||||
}
|
||||
if delim, ok := token.(json.Delim); ok {
|
||||
switch delim {
|
||||
case '{':
|
||||
out := map[string]any{}
|
||||
for d.More() {
|
||||
k, e := d.Token()
|
||||
if e != nil {
|
||||
return nil, p.doc.diagnostic(path, "invalid object key", e)
|
||||
}
|
||||
key, ok := k.(string)
|
||||
if !ok {
|
||||
return nil, p.doc.diagnostic(path, "object key must be a string", nil)
|
||||
}
|
||||
child := joinField(path, key)
|
||||
if _, exists := out[key]; exists {
|
||||
return nil, p.doc.diagnostic(child, "duplicate field", nil)
|
||||
}
|
||||
out[key], e = parse(child, depth+1)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
if _, err = d.Token(); err != nil {
|
||||
return nil, p.doc.diagnostic(path, "unterminated object", err)
|
||||
}
|
||||
return out, nil
|
||||
case '[':
|
||||
out := []any{}
|
||||
for d.More() {
|
||||
v, e := parse(fmt.Sprintf("%s[%d]", path, len(out)), depth+1)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if _, err = d.Token(); err != nil {
|
||||
return nil, p.doc.diagnostic(path, "unterminated array", err)
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, p.doc.diagnostic(path, "unexpected JSON delimiter", nil)
|
||||
}
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
var err error
|
||||
p.doc.Value, err = parse("", 0)
|
||||
if err == nil {
|
||||
if _, e := d.Token(); e != io.EOF {
|
||||
err = p.doc.diagnostic("", "exactly one JSON document required", e)
|
||||
}
|
||||
}
|
||||
return p.doc, err
|
||||
}
|
||||
|
||||
func (YAMLDecoder) Decode(data []byte) (Document, error) {
|
||||
p := newParseState()
|
||||
if len(data) > maxConfigBytes {
|
||||
return p.doc, &Error{Reason: "file exceeds 1 MiB"}
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return p.doc, &Error{Reason: "configuration must be UTF-8"}
|
||||
}
|
||||
d := yaml.NewDecoder(bytes.NewReader(data))
|
||||
var root yaml.Node
|
||||
if err := d.Decode(&root); err != nil {
|
||||
return p.doc, yamlSyntax(err)
|
||||
}
|
||||
var extra yaml.Node
|
||||
if err := d.Decode(&extra); err != io.EOF {
|
||||
return p.doc, &Error{Reason: "exactly one YAML document required", cause: err}
|
||||
}
|
||||
var walk func(*yaml.Node, string, int) (any, error)
|
||||
walk = func(n *yaml.Node, path string, depth int) (any, error) {
|
||||
if err := p.enter(path, Position{n.Line, n.Column}, depth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch n.Kind {
|
||||
case yaml.MappingNode:
|
||||
out := map[string]any{}
|
||||
for i := 0; i < len(n.Content); i += 2 {
|
||||
k, v := n.Content[i], n.Content[i+1]
|
||||
if k.Tag != "!!str" {
|
||||
return nil, p.doc.diagnostic(path, "mapping keys must be strings; merge keys are not supported", nil)
|
||||
}
|
||||
child := joinField(path, k.Value)
|
||||
if _, ok := out[k.Value]; ok {
|
||||
return nil, &Error{Field: child, Line: k.Line, Column: k.Column, Reason: "duplicate field"}
|
||||
}
|
||||
x, err := walk(v, child, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k.Value] = x
|
||||
}
|
||||
return out, nil
|
||||
case yaml.SequenceNode:
|
||||
out := make([]any, len(n.Content))
|
||||
for i, c := range n.Content {
|
||||
var err error
|
||||
out[i], err = walk(c, fmt.Sprintf("%s[%d]", path, i), depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case yaml.AliasNode:
|
||||
return nil, p.doc.diagnostic(path, "YAML aliases are not supported", nil)
|
||||
case yaml.ScalarNode:
|
||||
switch n.Tag {
|
||||
case "!!str", "!!timestamp":
|
||||
return n.Value, nil
|
||||
case "!!null":
|
||||
return nil, nil
|
||||
case "!!bool", "!!int", "!!float":
|
||||
var v any
|
||||
if err := n.Decode(&v); err != nil {
|
||||
return nil, p.doc.diagnostic(path, "invalid YAML scalar", err)
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, p.doc.diagnostic(path, "number must be finite", err)
|
||||
}
|
||||
var out any
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
if err = dec.Decode(&out); err != nil {
|
||||
return nil, p.doc.diagnostic(path, "invalid YAML scalar", err)
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, p.doc.diagnostic(path, "unsupported YAML tag", nil)
|
||||
}
|
||||
}
|
||||
return nil, p.doc.diagnostic(path, "unsupported YAML node", nil)
|
||||
}
|
||||
if len(root.Content) != 1 {
|
||||
return p.doc, &Error{Reason: "one nonempty YAML document required"}
|
||||
}
|
||||
var err error
|
||||
p.doc.Value, err = walk(root.Content[0], "", 0)
|
||||
return p.doc, err
|
||||
}
|
||||
|
||||
// XML uses a config root, element field names, type="array" with item children,
|
||||
// type="object" for empty objects, and type="null" for explicit null. Scalar
|
||||
// text is bound using the destination type; optional number/bool/string types
|
||||
// let dynamic any values preserve their intended JSON type.
|
||||
func (XMLDecoder) Decode(data []byte) (Document, error) {
|
||||
p := newParseState()
|
||||
if len(data) > maxConfigBytes {
|
||||
return p.doc, &Error{Reason: "file exceeds 1 MiB"}
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return p.doc, &Error{Reason: "configuration must be UTF-8"}
|
||||
}
|
||||
d := xml.NewDecoder(bytes.NewReader(data))
|
||||
var parse func(xml.StartElement, string, int, Position) (any, error)
|
||||
parse = func(start xml.StartElement, path string, depth int, pos Position) (any, error) {
|
||||
if err := p.enter(path, pos, depth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if start.Name.Space != "" {
|
||||
return nil, p.doc.diagnostic(path, "XML namespaces are not supported", nil)
|
||||
}
|
||||
kind := ""
|
||||
attrSeen := false
|
||||
for _, a := range start.Attr {
|
||||
if a.Name.Space != "" || a.Name.Local != "type" || attrSeen {
|
||||
return nil, p.doc.diagnostic(path, "only one XML type attribute is supported", nil)
|
||||
}
|
||||
kind = a.Value
|
||||
attrSeen = true
|
||||
}
|
||||
if depth == 0 && kind == "" {
|
||||
kind = "object"
|
||||
}
|
||||
switch kind {
|
||||
case "", "object", "array", "null", "string", "number", "bool":
|
||||
default:
|
||||
return nil, p.doc.diagnostic(path, "unsupported XML type", nil)
|
||||
}
|
||||
children := map[string]any{}
|
||||
array := []any{}
|
||||
var text strings.Builder
|
||||
for {
|
||||
line, column := d.InputPos()
|
||||
token, err := d.Token()
|
||||
if err != nil {
|
||||
return nil, &Error{Field: path, Line: line, Column: column, Reason: "invalid XML syntax", cause: err}
|
||||
}
|
||||
switch x := token.(type) {
|
||||
case xml.StartElement:
|
||||
if kind != "" && kind != "object" && kind != "array" {
|
||||
return nil, p.doc.diagnostic(path, "scalar XML element cannot contain children", nil)
|
||||
}
|
||||
child := joinField(path, x.Name.Local)
|
||||
if kind == "array" {
|
||||
if x.Name.Local != "item" {
|
||||
return nil, p.doc.diagnostic(path, "XML array children must be item elements", nil)
|
||||
}
|
||||
child = fmt.Sprintf("%s[%d]", path, len(array))
|
||||
} else if _, ok := children[x.Name.Local]; ok {
|
||||
return nil, p.doc.diagnostic(child, "duplicate field; use type=\"array\" for lists", nil)
|
||||
}
|
||||
v, e := parse(x, child, depth+1, Position{line, column})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if kind == "array" {
|
||||
array = append(array, v)
|
||||
} else {
|
||||
children[x.Name.Local] = v
|
||||
}
|
||||
case xml.CharData:
|
||||
text.Write([]byte(x))
|
||||
case xml.Comment:
|
||||
case xml.EndElement:
|
||||
raw := text.String()
|
||||
if len(children) > 0 || kind == "array" || kind == "object" {
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
return nil, p.doc.diagnostic(path, "mixed XML text and elements are not supported", nil)
|
||||
}
|
||||
if kind == "array" {
|
||||
return array, nil
|
||||
}
|
||||
return children, nil
|
||||
}
|
||||
switch kind {
|
||||
case "null":
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
return nil, p.doc.diagnostic(path, "null element must be empty", nil)
|
||||
}
|
||||
return nil, nil
|
||||
case "string":
|
||||
return raw, nil
|
||||
case "number":
|
||||
var n any
|
||||
dec := json.NewDecoder(strings.NewReader(strings.TrimSpace(raw)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&n); err != nil {
|
||||
return nil, p.doc.diagnostic(path, "invalid number", err)
|
||||
}
|
||||
if _, ok := n.(json.Number); !ok {
|
||||
return nil, p.doc.diagnostic(path, "expected number", nil)
|
||||
}
|
||||
if _, e := dec.Token(); e != io.EOF {
|
||||
return nil, p.doc.diagnostic(path, "invalid number", e)
|
||||
}
|
||||
return n, nil
|
||||
case "bool":
|
||||
b, e := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if e != nil {
|
||||
return nil, p.doc.diagnostic(path, "expected boolean", e)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return Text(raw), nil
|
||||
default:
|
||||
return nil, p.doc.diagnostic(path, "XML directives and processing instructions are not supported", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
found := false
|
||||
prolog := false
|
||||
for {
|
||||
line, column := d.InputPos()
|
||||
token, err := d.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return p.doc, &Error{Line: line, Column: column, Reason: "invalid XML syntax", cause: err}
|
||||
}
|
||||
switch x := token.(type) {
|
||||
case xml.StartElement:
|
||||
if found || x.Name.Local != "config" {
|
||||
return p.doc, &Error{Line: line, Column: column, Reason: "exactly one config root required"}
|
||||
}
|
||||
found = true
|
||||
p.doc.Value, err = parse(x, "", 0, Position{line, column})
|
||||
if err != nil {
|
||||
return p.doc, err
|
||||
}
|
||||
case xml.CharData:
|
||||
if strings.TrimSpace(string(x)) != "" {
|
||||
return p.doc, &Error{Reason: "unexpected text outside XML root"}
|
||||
}
|
||||
case xml.Comment:
|
||||
case xml.ProcInst:
|
||||
if found || prolog || x.Target != "xml" {
|
||||
return p.doc, &Error{Reason: "XML processing instructions are not supported"}
|
||||
}
|
||||
prolog = true
|
||||
default:
|
||||
return p.doc, &Error{Reason: "XML directives are not supported"}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return p.doc, &Error{Reason: "config root required"}
|
||||
}
|
||||
return p.doc, nil
|
||||
}
|
||||
Reference in New Issue
Block a user