Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
49
pkg/format/config_props.go
Normal file
49
pkg/format/config_props.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
var ErrNotConfigProp = errors.New("struct field cannot be used as a prop")
|
||||
|
||||
// GetConfigPropFromString deserializes a config property from a string representation using the ConfigProp interface.
|
||||
func GetConfigPropFromString(structType reflect.Type, value string) (reflect.Value, error) {
|
||||
valuePtr := reflect.New(structType)
|
||||
|
||||
configProp, ok := valuePtr.Interface().(types.ConfigProp)
|
||||
if !ok {
|
||||
return reflect.Value{}, ErrNotConfigProp
|
||||
}
|
||||
|
||||
if err := configProp.SetFromProp(value); err != nil {
|
||||
return reflect.Value{}, fmt.Errorf("failed to set config prop from string: %w", err)
|
||||
}
|
||||
|
||||
return valuePtr, nil
|
||||
}
|
||||
|
||||
// GetConfigPropString serializes a config property to a string representation using the ConfigProp interface.
|
||||
func GetConfigPropString(propPtr reflect.Value) (string, error) {
|
||||
if propPtr.Kind() != reflect.Ptr {
|
||||
propVal := propPtr
|
||||
propPtr = reflect.New(propVal.Type())
|
||||
propPtr.Elem().Set(propVal)
|
||||
}
|
||||
|
||||
if propPtr.CanInterface() {
|
||||
if configProp, ok := propPtr.Interface().(types.ConfigProp); ok {
|
||||
s, err := configProp.GetPropValue()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get config prop string: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNotConfigProp
|
||||
}
|
71
pkg/format/enum_formatter.go
Normal file
71
pkg/format/enum_formatter.go
Normal file
|
@ -0,0 +1,71 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// EnumInvalid is the constant value that an enum gets assigned when it could not be parsed.
|
||||
const EnumInvalid = -1
|
||||
|
||||
// EnumFormatter is the helper methods for enum-like types.
|
||||
type EnumFormatter struct {
|
||||
names []string
|
||||
firstOffset int
|
||||
aliases map[string]int
|
||||
}
|
||||
|
||||
// Names is the list of the valid Enum string values.
|
||||
func (ef EnumFormatter) Names() []string {
|
||||
return ef.names[ef.firstOffset:]
|
||||
}
|
||||
|
||||
// Print takes a enum mapped int and returns it's string representation or "Invalid".
|
||||
func (ef EnumFormatter) Print(e int) string {
|
||||
if e >= len(ef.names) || e < 0 {
|
||||
return "Invalid"
|
||||
}
|
||||
|
||||
return ef.names[e]
|
||||
}
|
||||
|
||||
// Parse takes an enum mapped string and returns it's int representation or EnumInvalid (-1).
|
||||
func (ef EnumFormatter) Parse(mappedString string) int {
|
||||
target := strings.ToLower(mappedString)
|
||||
for index, name := range ef.names {
|
||||
if target == strings.ToLower(name) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
if index, found := ef.aliases[mappedString]; found {
|
||||
return index
|
||||
}
|
||||
|
||||
return EnumInvalid
|
||||
}
|
||||
|
||||
// CreateEnumFormatter creates a EnumFormatter struct.
|
||||
func CreateEnumFormatter(names []string, optAliases ...map[string]int) types.EnumFormatter {
|
||||
aliases := map[string]int{}
|
||||
if len(optAliases) > 0 {
|
||||
aliases = optAliases[0]
|
||||
}
|
||||
|
||||
firstOffset := 0
|
||||
|
||||
for i, name := range names {
|
||||
if name != "" {
|
||||
firstOffset = i
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &EnumFormatter{
|
||||
names,
|
||||
firstOffset,
|
||||
aliases,
|
||||
}
|
||||
}
|
134
pkg/format/field_info.go
Normal file
134
pkg/format/field_info.go
Normal file
|
@ -0,0 +1,134 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/util"
|
||||
)
|
||||
|
||||
// DefaultBase represents the default numeric base (decimal) for fields.
|
||||
const DefaultBase = 10
|
||||
|
||||
// FieldInfo is the meta data about a config field.
|
||||
type FieldInfo struct {
|
||||
Name string
|
||||
Type reflect.Type
|
||||
EnumFormatter types.EnumFormatter
|
||||
Description string
|
||||
DefaultValue string
|
||||
Template string
|
||||
Required bool
|
||||
URLParts []URLPart
|
||||
Title bool
|
||||
Base int
|
||||
Keys []string
|
||||
ItemSeparator rune
|
||||
}
|
||||
|
||||
// IsEnum returns whether a EnumFormatter has been assigned to the field and that it is of a suitable type.
|
||||
func (fi *FieldInfo) IsEnum() bool {
|
||||
return fi.EnumFormatter != nil && fi.Type.Kind() == reflect.Int
|
||||
}
|
||||
|
||||
// IsURLPart returns whether the field is serialized as the specified part of an URL.
|
||||
func (fi *FieldInfo) IsURLPart(part URLPart) bool {
|
||||
for _, up := range fi.URLParts {
|
||||
if up == part {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getStructFieldInfo(structType reflect.Type, enums map[string]types.EnumFormatter) []FieldInfo {
|
||||
numFields := structType.NumField()
|
||||
fields := make([]FieldInfo, 0, numFields)
|
||||
maxKeyLen := 0
|
||||
|
||||
for i := range numFields {
|
||||
fieldDef := structType.Field(i)
|
||||
|
||||
if isHiddenField(fieldDef) {
|
||||
// This is an embedded or private field, which should not be part of the Config output
|
||||
continue
|
||||
}
|
||||
|
||||
info := FieldInfo{
|
||||
Name: fieldDef.Name,
|
||||
Type: fieldDef.Type,
|
||||
Required: true,
|
||||
Title: false,
|
||||
ItemSeparator: ',',
|
||||
}
|
||||
|
||||
if util.IsNumeric(fieldDef.Type.Kind()) {
|
||||
info.Base = getFieldBase(fieldDef)
|
||||
}
|
||||
|
||||
if tag, ok := fieldDef.Tag.Lookup("desc"); ok {
|
||||
info.Description = tag
|
||||
}
|
||||
|
||||
if tag, ok := fieldDef.Tag.Lookup("tpl"); ok {
|
||||
info.Template = tag
|
||||
}
|
||||
|
||||
if tag, ok := fieldDef.Tag.Lookup("default"); ok {
|
||||
info.Required = false
|
||||
info.DefaultValue = tag
|
||||
}
|
||||
|
||||
if _, ok := fieldDef.Tag.Lookup("optional"); ok {
|
||||
info.Required = false
|
||||
}
|
||||
|
||||
if _, ok := fieldDef.Tag.Lookup("title"); ok {
|
||||
info.Title = true
|
||||
}
|
||||
|
||||
if tag, ok := fieldDef.Tag.Lookup("url"); ok {
|
||||
info.URLParts = ParseURLParts(tag)
|
||||
}
|
||||
|
||||
if tag, ok := fieldDef.Tag.Lookup("key"); ok {
|
||||
tag := strings.ToLower(tag)
|
||||
info.Keys = strings.Split(tag, ",")
|
||||
}
|
||||
|
||||
if tag, ok := fieldDef.Tag.Lookup("sep"); ok {
|
||||
info.ItemSeparator = rune(tag[0])
|
||||
}
|
||||
|
||||
if ef, isEnum := enums[fieldDef.Name]; isEnum {
|
||||
info.EnumFormatter = ef
|
||||
}
|
||||
|
||||
fields = append(fields, info)
|
||||
|
||||
keyLen := len(fieldDef.Name)
|
||||
if keyLen > maxKeyLen {
|
||||
maxKeyLen = keyLen
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func isHiddenField(field reflect.StructField) bool {
|
||||
return field.Anonymous || strings.ToUpper(field.Name[0:1]) != field.Name[0:1]
|
||||
}
|
||||
|
||||
func getFieldBase(field reflect.StructField) int {
|
||||
if tag, ok := field.Tag.Lookup("base"); ok {
|
||||
if base, err := strconv.ParseUint(tag, 10, 8); err == nil {
|
||||
return int(base)
|
||||
}
|
||||
}
|
||||
|
||||
// Default to base 10 if not tagged
|
||||
return DefaultBase
|
||||
}
|
34
pkg/format/format.go
Normal file
34
pkg/format/format.go
Normal file
|
@ -0,0 +1,34 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseBool returns true for "1","true","yes" or false for "0","false","no" or defaultValue for any other value.
|
||||
func ParseBool(value string, defaultValue bool) (bool, bool) {
|
||||
switch strings.ToLower(value) {
|
||||
case "true", "1", "yes", "y":
|
||||
return true, true
|
||||
case "false", "0", "no", "n":
|
||||
return false, true
|
||||
default:
|
||||
return defaultValue, false
|
||||
}
|
||||
}
|
||||
|
||||
// PrintBool returns "Yes" if value is true, otherwise returns "No".
|
||||
func PrintBool(value bool) string {
|
||||
if value {
|
||||
return "Yes"
|
||||
}
|
||||
|
||||
return "No"
|
||||
}
|
||||
|
||||
// IsNumber returns whether the specified string is number-like.
|
||||
func IsNumber(value string) bool {
|
||||
_, err := strconv.ParseFloat(value, 64)
|
||||
|
||||
return err == nil
|
||||
}
|
80
pkg/format/format_colorize.go
Normal file
80
pkg/format/format_colorize.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
package format
|
||||
|
||||
import "github.com/fatih/color"
|
||||
|
||||
// ColorizeDesc colorizes the input string as "Description".
|
||||
var ColorizeDesc = color.New(color.FgHiBlack).SprintFunc()
|
||||
|
||||
// ColorizeTrue colorizes the input string as "True".
|
||||
var ColorizeTrue = color.New(color.FgHiGreen).SprintFunc()
|
||||
|
||||
// ColorizeFalse colorizes the input string as "False".
|
||||
var ColorizeFalse = color.New(color.FgHiRed).SprintFunc()
|
||||
|
||||
// ColorizeNumber colorizes the input string as "Number".
|
||||
var ColorizeNumber = color.New(color.FgHiBlue).SprintFunc()
|
||||
|
||||
// ColorizeString colorizes the input string as "String".
|
||||
var ColorizeString = color.New(color.FgHiYellow).SprintFunc()
|
||||
|
||||
// ColorizeEnum colorizes the input string as "Enum".
|
||||
var ColorizeEnum = color.New(color.FgHiCyan).SprintFunc()
|
||||
|
||||
// ColorizeProp colorizes the input string as "Prop".
|
||||
var ColorizeProp = color.New(color.FgHiMagenta).SprintFunc()
|
||||
|
||||
// ColorizeError colorizes the input string as "Error".
|
||||
var ColorizeError = ColorizeFalse
|
||||
|
||||
// ColorizeContainer colorizes the input string as "Container".
|
||||
var ColorizeContainer = ColorizeDesc
|
||||
|
||||
// ColorizeLink colorizes the input string as "Link".
|
||||
var ColorizeLink = color.New(color.FgHiBlue).SprintFunc()
|
||||
|
||||
// ColorizeValue colorizes the input string according to what type appears to be.
|
||||
func ColorizeValue(value string, isEnum bool) string {
|
||||
if isEnum {
|
||||
return ColorizeEnum(value)
|
||||
}
|
||||
|
||||
if isTrue, isType := ParseBool(value, false); isType {
|
||||
if isTrue {
|
||||
return ColorizeTrue(value)
|
||||
}
|
||||
|
||||
return ColorizeFalse(value)
|
||||
}
|
||||
|
||||
if IsNumber(value) {
|
||||
return ColorizeNumber(value)
|
||||
}
|
||||
|
||||
return ColorizeString(value)
|
||||
}
|
||||
|
||||
// ColorizeToken colorizes the value according to the tokenType.
|
||||
func ColorizeToken(value string, tokenType NodeTokenType) string {
|
||||
switch tokenType {
|
||||
case NumberToken:
|
||||
return ColorizeNumber(value)
|
||||
case EnumToken:
|
||||
return ColorizeEnum(value)
|
||||
case TrueToken:
|
||||
return ColorizeTrue(value)
|
||||
case FalseToken:
|
||||
return ColorizeFalse(value)
|
||||
case PropToken:
|
||||
return ColorizeProp(value)
|
||||
case ErrorToken:
|
||||
return ColorizeError(value)
|
||||
case ContainerToken:
|
||||
return ColorizeContainer(value)
|
||||
case StringToken:
|
||||
return ColorizeString(value)
|
||||
case UnknownToken:
|
||||
default:
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
95
pkg/format/format_query.go
Normal file
95
pkg/format/format_query.go
Normal file
|
@ -0,0 +1,95 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// BuildQuery converts the fields of a config object to a delimited query string.
|
||||
func BuildQuery(cqr types.ConfigQueryResolver) string {
|
||||
return BuildQueryWithCustomFields(cqr, url.Values{}).Encode()
|
||||
}
|
||||
|
||||
// escaping any custom fields that share the same key as a config prop using a "__" prefix.
|
||||
func BuildQueryWithCustomFields(cqr types.ConfigQueryResolver, query url.Values) url.Values {
|
||||
fields := cqr.QueryFields()
|
||||
skipEscape := len(query) < 1
|
||||
|
||||
pkr, isPkr := cqr.(*PropKeyResolver)
|
||||
|
||||
for _, key := range fields {
|
||||
if !skipEscape {
|
||||
// Escape any webhook query keys using the same name as service props
|
||||
escValues := query[key]
|
||||
if len(escValues) > 0 {
|
||||
query.Del(key)
|
||||
query[EscapeKey(key)] = escValues
|
||||
}
|
||||
}
|
||||
|
||||
if isPkr && !pkr.KeyIsPrimary(key) {
|
||||
continue
|
||||
}
|
||||
|
||||
value, err := cqr.Get(key)
|
||||
|
||||
if err != nil || isPkr && pkr.IsDefault(key, value) {
|
||||
continue
|
||||
}
|
||||
|
||||
query.Set(key, value)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// SetConfigPropsFromQuery iterates over all the config prop keys and sets the config prop to the corresponding
|
||||
// query value based on the key.
|
||||
// SetConfigPropsFromQuery returns a non-nil url.Values query with all config prop keys removed, even if any of
|
||||
// them could not be used to set a config field, and with any escaped keys unescaped.
|
||||
// The error returned is the first error that occurred, subsequent errors are just discarded.
|
||||
func SetConfigPropsFromQuery(cqr types.ConfigQueryResolver, query url.Values) (url.Values, error) {
|
||||
var firstError error
|
||||
|
||||
if query == nil {
|
||||
return url.Values{}, nil
|
||||
}
|
||||
|
||||
for _, key := range cqr.QueryFields() {
|
||||
// Retrieve the service-related prop value
|
||||
values := query[key]
|
||||
if len(values) > 0 {
|
||||
if err := cqr.Set(key, values[0]); err != nil && firstError == nil {
|
||||
firstError = err
|
||||
}
|
||||
}
|
||||
// Remove it from the query Values
|
||||
query.Del(key)
|
||||
|
||||
// If an escaped version of the key exist, unescape it
|
||||
escKey := EscapeKey(key)
|
||||
escValues := query[escKey]
|
||||
|
||||
if len(escValues) > 0 {
|
||||
query.Del(escKey)
|
||||
query[key] = escValues
|
||||
}
|
||||
}
|
||||
|
||||
return query, firstError
|
||||
}
|
||||
|
||||
// EscapeKey adds the KeyPrefix to custom URL query keys that conflict with service config prop keys.
|
||||
func EscapeKey(key string) string {
|
||||
return KeyPrefix + key
|
||||
}
|
||||
|
||||
// UnescapeKey removes the KeyPrefix from custom URL query keys that conflict with service config prop keys.
|
||||
func UnescapeKey(key string) string {
|
||||
return strings.TrimPrefix(key, KeyPrefix)
|
||||
}
|
||||
|
||||
// consisting of two underscore characters ("__").
|
||||
const KeyPrefix = "__"
|
50
pkg/format/format_query_test.go
Normal file
50
pkg/format/format_query_test.go
Normal file
|
@ -0,0 +1,50 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("Query Formatter", func() {
|
||||
var pkr PropKeyResolver
|
||||
ginkgo.BeforeEach(func() {
|
||||
ts = &testStruct{}
|
||||
pkr = NewPropKeyResolver(ts)
|
||||
_ = pkr.SetDefaultProps(ts)
|
||||
})
|
||||
ginkgo.Describe("Creating a service URL query from a config", func() {
|
||||
ginkgo.When("a config property has been changed from default", func() {
|
||||
ginkgo.It("should be included in the query string", func() {
|
||||
ts.Str = "test"
|
||||
query := BuildQuery(&pkr)
|
||||
// (pkr, )
|
||||
gomega.Expect(query).To(gomega.Equal("str=test"))
|
||||
})
|
||||
})
|
||||
ginkgo.When("a custom query key conflicts with a config property key", func() {
|
||||
ginkgo.It("should include both values, with the custom escaped", func() {
|
||||
ts.Str = "service"
|
||||
customQuery := url.Values{"str": {"custom"}}
|
||||
query := BuildQueryWithCustomFields(&pkr, customQuery)
|
||||
gomega.Expect(query.Encode()).To(gomega.Equal("__str=custom&str=service"))
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.Describe("Setting prop values from query", func() {
|
||||
ginkgo.When("a custom query key conflicts with a config property key", func() {
|
||||
ginkgo.It(
|
||||
"should set the config prop from the regular and return the custom one unescaped",
|
||||
func() {
|
||||
ts.Str = "service"
|
||||
serviceQuery := url.Values{"__str": {"custom"}, "str": {"service"}}
|
||||
query, err := SetConfigPropsFromQuery(&pkr, serviceQuery)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(ts.Str).To(gomega.Equal("service"))
|
||||
gomega.Expect(query.Get("str")).To(gomega.Equal("custom"))
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
157
pkg/format/format_test.go
Normal file
157
pkg/format/format_test.go
Normal file
|
@ -0,0 +1,157 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Shoutrrr Format Suite")
|
||||
}
|
||||
|
||||
var _ = ginkgo.BeforeSuite(func() {
|
||||
// Disable color output for tests to have them match the string format rather than the colors
|
||||
color.NoColor = true
|
||||
})
|
||||
|
||||
var _ = ginkgo.Describe("the format package", func() {
|
||||
ginkgo.Describe("Generic Format Utils", func() {
|
||||
ginkgo.When("parsing a bool", func() {
|
||||
testParseValidBool := func(raw string, expected bool) {
|
||||
parsed, ok := ParseBool(raw, !expected)
|
||||
gomega.Expect(parsed).To(gomega.Equal(expected))
|
||||
gomega.Expect(ok).To(gomega.BeTrue())
|
||||
}
|
||||
ginkgo.It("should parse truthy values as true", func() {
|
||||
testParseValidBool("true", true)
|
||||
testParseValidBool("1", true)
|
||||
testParseValidBool("yes", true)
|
||||
})
|
||||
ginkgo.It("should parse falsy values as false", func() {
|
||||
testParseValidBool("false", false)
|
||||
testParseValidBool("0", false)
|
||||
testParseValidBool("no", false)
|
||||
})
|
||||
ginkgo.It("should match regardless of case", func() {
|
||||
testParseValidBool("trUE", true)
|
||||
})
|
||||
ginkgo.It("should return the default if no value matches", func() {
|
||||
parsed, ok := ParseBool("bad", true)
|
||||
gomega.Expect(parsed).To(gomega.BeTrue())
|
||||
gomega.Expect(ok).To(gomega.BeFalse())
|
||||
parsed, ok = ParseBool("values", false)
|
||||
gomega.Expect(parsed).To(gomega.BeFalse())
|
||||
gomega.Expect(ok).To(gomega.BeFalse())
|
||||
})
|
||||
})
|
||||
ginkgo.When("printing a bool", func() {
|
||||
ginkgo.It("should return yes or no", func() {
|
||||
gomega.Expect(PrintBool(true)).To(gomega.Equal("Yes"))
|
||||
gomega.Expect(PrintBool(false)).To(gomega.Equal("No"))
|
||||
})
|
||||
})
|
||||
ginkgo.When("checking for number-like strings", func() {
|
||||
ginkgo.It("should be true for numbers", func() {
|
||||
gomega.Expect(IsNumber("1.5")).To(gomega.BeTrue())
|
||||
gomega.Expect(IsNumber("0")).To(gomega.BeTrue())
|
||||
gomega.Expect(IsNumber("NaN")).To(gomega.BeTrue())
|
||||
})
|
||||
ginkgo.It("should be false for non-numbers", func() {
|
||||
gomega.Expect(IsNumber("baNaNa")).To(gomega.BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.Describe("Enum Formatter", func() {
|
||||
ginkgo.It("should return all enum values on listing", func() {
|
||||
gomega.Expect(testEnum.Names()).To(gomega.ConsistOf("None", "Foo", "Bar"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type testStruct struct {
|
||||
Signed int `default:"0" key:"signed"`
|
||||
Unsigned uint
|
||||
Str string `default:"notempty" key:"str"`
|
||||
StrSlice []string
|
||||
StrArray [3]string
|
||||
Sub subStruct
|
||||
TestEnum int `default:"None" key:"testenum"`
|
||||
SubProp subPropStruct
|
||||
SubSlice []subStruct
|
||||
SubPropSlice []subPropStruct
|
||||
SubPropPtrSlice []*subPropStruct
|
||||
StrMap map[string]string
|
||||
IntMap map[string]int
|
||||
Int8Map map[string]int8
|
||||
Int16Map map[string]int16
|
||||
Int32Map map[string]int32
|
||||
Int64Map map[string]int64
|
||||
UintMap map[string]uint
|
||||
Uint8Map map[string]int8
|
||||
Uint16Map map[string]int16
|
||||
Uint32Map map[string]int32
|
||||
Uint64Map map[string]int64
|
||||
}
|
||||
|
||||
func (t *testStruct) GetURL() *url.URL {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (t *testStruct) SetURL(_ *url.URL) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (t *testStruct) Enums() map[string]types.EnumFormatter {
|
||||
return enums
|
||||
}
|
||||
|
||||
type subStruct struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
type subPropStruct struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func (s *subPropStruct) SetFromProp(propValue string) error {
|
||||
if len(propValue) < 1 || propValue[0] != '@' {
|
||||
return errors.New("invalid value")
|
||||
}
|
||||
|
||||
s.Value = propValue[1:]
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *subPropStruct) GetPropValue() (string, error) {
|
||||
return "@" + s.Value, nil
|
||||
}
|
||||
|
||||
var (
|
||||
testEnum = CreateEnumFormatter([]string{"None", "Foo", "Bar"})
|
||||
enums = map[string]types.EnumFormatter{
|
||||
"TestEnum": testEnum,
|
||||
}
|
||||
)
|
||||
|
||||
type testStructBadDefault struct {
|
||||
standard.EnumlessConfig
|
||||
Value int `default:"NaN" key:"value"`
|
||||
}
|
||||
|
||||
func (t *testStructBadDefault) GetURL() *url.URL {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (t *testStructBadDefault) SetURL(_ *url.URL) error {
|
||||
panic("not implemented")
|
||||
}
|
509
pkg/format/formatter.go
Normal file
509
pkg/format/formatter.go
Normal file
|
@ -0,0 +1,509 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/util"
|
||||
)
|
||||
|
||||
// Constants for map parsing and type sizes.
|
||||
const (
|
||||
KeyValuePairSize = 2 // Number of elements in a key:value pair
|
||||
Int32BitSize = 32 // Bit size for 32-bit integers
|
||||
Int64BitSize = 64 // Bit size for 64-bit integers
|
||||
)
|
||||
|
||||
// Errors defined as static variables for better error handling.
|
||||
var (
|
||||
ErrInvalidEnumValue = errors.New("not a valid enum value")
|
||||
ErrInvalidBoolValue = errors.New("accepted values are 1, true, yes or 0, false, no")
|
||||
ErrUnsupportedFieldKey = errors.New("field key format is not supported")
|
||||
ErrInvalidFieldValue = errors.New("invalid field value format")
|
||||
ErrUnsupportedField = errors.New("field format is not supported")
|
||||
ErrInvalidFieldCount = errors.New("invalid field value count")
|
||||
ErrInvalidFieldKind = errors.New("invalid field kind")
|
||||
ErrUnsupportedMapValue = errors.New("map value format is not supported")
|
||||
ErrInvalidFieldValueData = errors.New("invalid field value")
|
||||
ErrFailedToSetEnumValue = errors.New("failed to set enum value")
|
||||
ErrUnexpectedUintKind = errors.New("unexpected uint kind")
|
||||
ErrUnexpectedIntKind = errors.New("unexpected int kind")
|
||||
ErrParseIntFailed = errors.New("failed to parse integer")
|
||||
ErrParseUintFailed = errors.New("failed to parse unsigned integer")
|
||||
)
|
||||
|
||||
// GetServiceConfig extracts the inner config from a service.
|
||||
func GetServiceConfig(service types.Service) types.ServiceConfig {
|
||||
serviceValue := reflect.Indirect(reflect.ValueOf(service))
|
||||
|
||||
configField, ok := serviceValue.Type().FieldByName("Config")
|
||||
if !ok {
|
||||
panic("service does not have a Config field")
|
||||
}
|
||||
|
||||
configRef := serviceValue.FieldByIndex(configField.Index)
|
||||
if configRef.IsNil() {
|
||||
configType := configField.Type
|
||||
if configType.Kind() == reflect.Ptr {
|
||||
configType = configType.Elem()
|
||||
}
|
||||
|
||||
newConfig := reflect.New(configType).Interface()
|
||||
if config, ok := newConfig.(types.ServiceConfig); ok {
|
||||
return config
|
||||
}
|
||||
|
||||
panic("failed to create new config instance")
|
||||
}
|
||||
|
||||
if config, ok := configRef.Interface().(types.ServiceConfig); ok {
|
||||
return config
|
||||
}
|
||||
|
||||
panic("config reference is not a ServiceConfig")
|
||||
}
|
||||
|
||||
// ColorFormatTree generates a color-highlighted string representation of a node tree.
|
||||
func ColorFormatTree(rootNode *ContainerNode, withValues bool) string {
|
||||
return ConsoleTreeRenderer{WithValues: withValues}.RenderTree(rootNode, "")
|
||||
}
|
||||
|
||||
// GetServiceConfigFormat retrieves type and field information from a service's config.
|
||||
func GetServiceConfigFormat(service types.Service) *ContainerNode {
|
||||
return GetConfigFormat(GetServiceConfig(service))
|
||||
}
|
||||
|
||||
// GetConfigFormat retrieves type and field information from a ServiceConfig.
|
||||
func GetConfigFormat(config types.ServiceConfig) *ContainerNode {
|
||||
return getRootNode(config)
|
||||
}
|
||||
|
||||
// SetConfigField updates a config field with a deserialized value from a string.
|
||||
func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (bool, error) {
|
||||
configField := config.FieldByName(field.Name)
|
||||
if field.EnumFormatter != nil {
|
||||
return setEnumField(configField, field, inputValue)
|
||||
}
|
||||
|
||||
switch field.Type.Kind() {
|
||||
case reflect.String:
|
||||
configField.SetString(inputValue)
|
||||
|
||||
return true, nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return setIntField(configField, field, inputValue)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return setUintField(configField, field, inputValue)
|
||||
case reflect.Bool:
|
||||
return setBoolField(configField, inputValue)
|
||||
case reflect.Map:
|
||||
return setMapField(configField, field, inputValue)
|
||||
case reflect.Struct:
|
||||
return setStructField(configField, field, inputValue)
|
||||
case reflect.Slice, reflect.Array:
|
||||
return setSliceOrArrayField(configField, field, inputValue)
|
||||
case reflect.Invalid,
|
||||
reflect.Uintptr,
|
||||
reflect.Float32,
|
||||
reflect.Float64,
|
||||
reflect.Complex64,
|
||||
reflect.Complex128,
|
||||
reflect.Chan,
|
||||
reflect.Func,
|
||||
reflect.Interface,
|
||||
reflect.Pointer,
|
||||
reflect.UnsafePointer:
|
||||
return false, fmt.Errorf("%w: %v", ErrInvalidFieldKind, field.Type.Kind())
|
||||
default:
|
||||
return false, fmt.Errorf("%w: %v", ErrInvalidFieldKind, field.Type.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
// setIntField handles integer field setting.
|
||||
func setIntField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
|
||||
number, base := util.StripNumberPrefix(inputValue)
|
||||
|
||||
value, err := strconv.ParseInt(number, base, field.Type.Bits())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: %w", ErrParseIntFailed, err)
|
||||
}
|
||||
|
||||
configField.SetInt(value)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// setUintField handles unsigned integer field setting.
|
||||
func setUintField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
|
||||
number, base := util.StripNumberPrefix(inputValue)
|
||||
|
||||
value, err := strconv.ParseUint(number, base, field.Type.Bits())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: %w", ErrParseUintFailed, err)
|
||||
}
|
||||
|
||||
configField.SetUint(value)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// setBoolField handles boolean field setting.
|
||||
func setBoolField(configField reflect.Value, inputValue string) (bool, error) {
|
||||
value, ok := ParseBool(inputValue, false)
|
||||
if !ok {
|
||||
return false, ErrInvalidBoolValue
|
||||
}
|
||||
|
||||
configField.SetBool(value)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// setMapField handles map field setting.
|
||||
func setMapField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
|
||||
if field.Type.Key().Kind() != reflect.String {
|
||||
return false, ErrUnsupportedFieldKey
|
||||
}
|
||||
|
||||
mapValue := reflect.MakeMap(field.Type)
|
||||
|
||||
pairs := strings.Split(inputValue, ",")
|
||||
for _, pair := range pairs {
|
||||
elems := strings.Split(pair, ":")
|
||||
if len(elems) != KeyValuePairSize {
|
||||
return false, ErrInvalidFieldValue
|
||||
}
|
||||
|
||||
key, valueRaw := elems[0], elems[1]
|
||||
|
||||
value, err := getMapValue(field.Type.Elem(), valueRaw)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
mapValue.SetMapIndex(reflect.ValueOf(key), value)
|
||||
}
|
||||
|
||||
configField.Set(mapValue)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// setStructField handles struct field setting.
|
||||
func setStructField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
|
||||
valuePtr, err := GetConfigPropFromString(field.Type, inputValue)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
configField.Set(valuePtr.Elem())
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// setSliceOrArrayField handles slice or array field setting.
|
||||
func setSliceOrArrayField(
|
||||
configField reflect.Value,
|
||||
field FieldInfo,
|
||||
inputValue string,
|
||||
) (bool, error) {
|
||||
elemType := field.Type.Elem()
|
||||
elemKind := elemType.Kind()
|
||||
|
||||
if elemKind == reflect.Ptr {
|
||||
elemKind = elemType.Elem().Kind()
|
||||
}
|
||||
|
||||
if elemKind != reflect.Struct && elemKind != reflect.String {
|
||||
return false, ErrUnsupportedField
|
||||
}
|
||||
|
||||
values := strings.Split(inputValue, string(field.ItemSeparator))
|
||||
if field.Type.Kind() == reflect.Array && len(values) != field.Type.Len() {
|
||||
return false, fmt.Errorf("%w: needs to be %d", ErrInvalidFieldCount, field.Type.Len())
|
||||
}
|
||||
|
||||
return setSliceOrArrayValues(configField, field, elemType, values)
|
||||
}
|
||||
|
||||
// setSliceOrArrayValues sets the actual values for slice or array fields.
|
||||
func setSliceOrArrayValues(
|
||||
configField reflect.Value,
|
||||
field FieldInfo,
|
||||
elemType reflect.Type,
|
||||
values []string,
|
||||
) (bool, error) {
|
||||
isPtrSlice := elemType.Kind() == reflect.Ptr
|
||||
baseType := elemType
|
||||
|
||||
if isPtrSlice {
|
||||
baseType = elemType.Elem()
|
||||
}
|
||||
|
||||
if baseType.Kind() == reflect.Struct {
|
||||
slice := reflect.MakeSlice(reflect.SliceOf(elemType), 0, len(values))
|
||||
|
||||
for _, v := range values {
|
||||
propPtr, err := GetConfigPropFromString(baseType, v)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if isPtrSlice {
|
||||
slice = reflect.Append(slice, propPtr)
|
||||
} else {
|
||||
slice = reflect.Append(slice, propPtr.Elem())
|
||||
}
|
||||
}
|
||||
|
||||
configField.Set(slice)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Handle string slice/array
|
||||
value := reflect.ValueOf(values)
|
||||
|
||||
if field.Type.Kind() == reflect.Array {
|
||||
arr := reflect.Indirect(reflect.New(field.Type))
|
||||
reflect.Copy(arr, value)
|
||||
configField.Set(arr)
|
||||
} else {
|
||||
configField.Set(value)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// setEnumField handles enum field setting.
|
||||
func setEnumField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
|
||||
value := field.EnumFormatter.Parse(inputValue)
|
||||
if value == EnumInvalid {
|
||||
return false, fmt.Errorf(
|
||||
"%w: accepted values are %v",
|
||||
ErrInvalidEnumValue,
|
||||
field.EnumFormatter.Names(),
|
||||
)
|
||||
}
|
||||
|
||||
configField.SetInt(int64(value))
|
||||
|
||||
if actual := int(configField.Int()); actual != value {
|
||||
return false, fmt.Errorf(
|
||||
"%w: expected %d, got %d (canSet: %v)",
|
||||
ErrFailedToSetEnumValue,
|
||||
value,
|
||||
actual,
|
||||
configField.CanSet(),
|
||||
)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// getMapValue converts a raw string to a map value based on type.
|
||||
func getMapValue(valueType reflect.Type, valueRaw string) (reflect.Value, error) {
|
||||
switch valueType.Kind() {
|
||||
case reflect.String:
|
||||
return reflect.ValueOf(valueRaw), nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return getMapUintValue(valueRaw, valueType)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return getMapIntValue(valueRaw, valueType)
|
||||
case reflect.Invalid,
|
||||
reflect.Bool,
|
||||
reflect.Uintptr,
|
||||
reflect.Float32,
|
||||
reflect.Float64,
|
||||
reflect.Complex64,
|
||||
reflect.Complex128,
|
||||
reflect.Array,
|
||||
reflect.Chan,
|
||||
reflect.Func,
|
||||
reflect.Interface,
|
||||
reflect.Map,
|
||||
reflect.Pointer,
|
||||
reflect.Slice,
|
||||
reflect.Struct,
|
||||
reflect.UnsafePointer:
|
||||
return reflect.Value{}, ErrUnsupportedMapValue
|
||||
default:
|
||||
return reflect.Value{}, ErrUnsupportedMapValue
|
||||
}
|
||||
}
|
||||
|
||||
// getMapUintValue converts a string to an unsigned integer map value.
|
||||
func getMapUintValue(valueRaw string, valueType reflect.Type) (reflect.Value, error) {
|
||||
number, base := util.StripNumberPrefix(valueRaw)
|
||||
|
||||
numValue, err := strconv.ParseUint(number, base, valueType.Bits())
|
||||
if err != nil {
|
||||
return reflect.Value{}, fmt.Errorf("%w: %w", ErrParseUintFailed, err)
|
||||
}
|
||||
|
||||
switch valueType.Kind() {
|
||||
case reflect.Uint:
|
||||
return reflect.ValueOf(uint(numValue)), nil
|
||||
case reflect.Uint8:
|
||||
if numValue > math.MaxUint8 {
|
||||
return reflect.Value{}, fmt.Errorf(
|
||||
"%w: value %d exceeds uint8 range",
|
||||
ErrParseUintFailed,
|
||||
numValue,
|
||||
)
|
||||
}
|
||||
|
||||
return reflect.ValueOf(uint8(numValue)), nil
|
||||
case reflect.Uint16:
|
||||
if numValue > math.MaxUint16 {
|
||||
return reflect.Value{}, fmt.Errorf(
|
||||
"%w: value %d exceeds uint16 range",
|
||||
ErrParseUintFailed,
|
||||
numValue,
|
||||
)
|
||||
}
|
||||
|
||||
return reflect.ValueOf(uint16(numValue)), nil
|
||||
case reflect.Uint32:
|
||||
if numValue > math.MaxUint32 {
|
||||
return reflect.Value{}, fmt.Errorf(
|
||||
"%w: value %d exceeds uint32 range",
|
||||
ErrParseUintFailed,
|
||||
numValue,
|
||||
)
|
||||
}
|
||||
|
||||
return reflect.ValueOf(uint32(numValue)), nil
|
||||
case reflect.Uint64:
|
||||
return reflect.ValueOf(numValue), nil
|
||||
case reflect.Invalid,
|
||||
reflect.Bool,
|
||||
reflect.Int,
|
||||
reflect.Int8,
|
||||
reflect.Int16,
|
||||
reflect.Int32,
|
||||
reflect.Int64,
|
||||
reflect.Uintptr,
|
||||
reflect.Float32,
|
||||
reflect.Float64,
|
||||
reflect.Complex64,
|
||||
reflect.Complex128,
|
||||
reflect.Array,
|
||||
reflect.Chan,
|
||||
reflect.Func,
|
||||
reflect.Interface,
|
||||
reflect.Map,
|
||||
reflect.Pointer,
|
||||
reflect.Slice,
|
||||
reflect.String,
|
||||
reflect.Struct,
|
||||
reflect.UnsafePointer:
|
||||
return reflect.Value{}, ErrUnexpectedUintKind
|
||||
default:
|
||||
return reflect.Value{}, ErrUnexpectedUintKind
|
||||
}
|
||||
}
|
||||
|
||||
// getMapIntValue converts a string to a signed integer map value.
|
||||
func getMapIntValue(valueRaw string, valueType reflect.Type) (reflect.Value, error) {
|
||||
number, base := util.StripNumberPrefix(valueRaw)
|
||||
|
||||
numValue, err := strconv.ParseInt(number, base, valueType.Bits())
|
||||
if err != nil {
|
||||
return reflect.Value{}, fmt.Errorf("%w: %w", ErrParseIntFailed, err)
|
||||
}
|
||||
|
||||
switch valueType.Kind() {
|
||||
case reflect.Int:
|
||||
bits := valueType.Bits()
|
||||
if bits == Int32BitSize {
|
||||
if numValue < math.MinInt32 || numValue > math.MaxInt32 {
|
||||
return reflect.Value{}, fmt.Errorf(
|
||||
"%w: value %d exceeds int%d range",
|
||||
ErrParseIntFailed,
|
||||
numValue,
|
||||
bits,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return reflect.ValueOf(int(numValue)), nil
|
||||
case reflect.Int8:
|
||||
if numValue < math.MinInt8 || numValue > math.MaxInt8 {
|
||||
return reflect.Value{}, fmt.Errorf(
|
||||
"%w: value %d exceeds int8 range",
|
||||
ErrParseIntFailed,
|
||||
numValue,
|
||||
)
|
||||
}
|
||||
|
||||
return reflect.ValueOf(int8(numValue)), nil
|
||||
case reflect.Int16:
|
||||
if numValue < math.MinInt16 || numValue > math.MaxInt16 {
|
||||
return reflect.Value{}, fmt.Errorf(
|
||||
"%w: value %d exceeds int16 range",
|
||||
ErrParseIntFailed,
|
||||
numValue,
|
||||
)
|
||||
}
|
||||
|
||||
return reflect.ValueOf(int16(numValue)), nil
|
||||
case reflect.Int32:
|
||||
if numValue < math.MinInt32 || numValue > math.MaxInt32 {
|
||||
return reflect.Value{}, fmt.Errorf(
|
||||
"%w: value %d exceeds int32 range",
|
||||
ErrParseIntFailed,
|
||||
numValue,
|
||||
)
|
||||
}
|
||||
|
||||
return reflect.ValueOf(int32(numValue)), nil
|
||||
case reflect.Int64:
|
||||
return reflect.ValueOf(numValue), nil
|
||||
case reflect.Invalid,
|
||||
reflect.Bool,
|
||||
reflect.Uint,
|
||||
reflect.Uint8,
|
||||
reflect.Uint16,
|
||||
reflect.Uint32,
|
||||
reflect.Uint64,
|
||||
reflect.Uintptr,
|
||||
reflect.Float32,
|
||||
reflect.Float64,
|
||||
reflect.Complex64,
|
||||
reflect.Complex128,
|
||||
reflect.Array,
|
||||
reflect.Chan,
|
||||
reflect.Func,
|
||||
reflect.Interface,
|
||||
reflect.Map,
|
||||
reflect.Pointer,
|
||||
reflect.Slice,
|
||||
reflect.String,
|
||||
reflect.Struct,
|
||||
reflect.UnsafePointer:
|
||||
return reflect.Value{}, ErrUnexpectedIntKind
|
||||
default:
|
||||
return reflect.Value{}, ErrUnexpectedIntKind
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigFieldString converts a config field value to its string representation.
|
||||
func GetConfigFieldString(config reflect.Value, field FieldInfo) (string, error) {
|
||||
configField := config.FieldByName(field.Name)
|
||||
if field.IsEnum() {
|
||||
return field.EnumFormatter.Print(int(configField.Int())), nil
|
||||
}
|
||||
|
||||
strVal, token := getValueNodeValue(configField, &field)
|
||||
if token == ErrorToken {
|
||||
return "", ErrInvalidFieldValueData
|
||||
}
|
||||
|
||||
return strVal, nil
|
||||
}
|
288
pkg/format/formatter_test.go
Normal file
288
pkg/format/formatter_test.go
Normal file
|
@ -0,0 +1,288 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// logger *log.Logger.
|
||||
var (
|
||||
ts *testStruct
|
||||
tv reflect.Value
|
||||
nodeMap map[string]Node
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("SetConfigField", func() {
|
||||
testConfig := testStruct{}
|
||||
tt := reflect.TypeOf(testConfig)
|
||||
|
||||
ginkgo.When("updating a struct", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
tsPtr := reflect.New(tt)
|
||||
tv = tsPtr.Elem()
|
||||
ts = tsPtr.Interface().(*testStruct)
|
||||
gomega.Expect(tv.CanSet()).To(gomega.BeTrue())
|
||||
gomega.Expect(tv.FieldByName("TestEnum").CanSet()).To(gomega.BeTrue())
|
||||
rootNode := getRootNode(ts)
|
||||
nodeMap = make(map[string]Node, len(rootNode.Items))
|
||||
for _, item := range rootNode.Items {
|
||||
field := item.Field()
|
||||
nodeMap[field.Name] = item
|
||||
}
|
||||
gomega.Expect(int(tv.FieldByName("TestEnum").Int())).
|
||||
To(gomega.Equal(0), "TestEnum initial value")
|
||||
})
|
||||
ginkgo.When("setting an integer value", func() {
|
||||
ginkgo.When("the value is valid", func() {
|
||||
ginkgo.It("should set it", func() {
|
||||
valid, err := SetConfigField(tv, *nodeMap["Signed"].Field(), "3")
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeTrue())
|
||||
gomega.Expect(ts.Signed).To(gomega.Equal(3))
|
||||
})
|
||||
})
|
||||
ginkgo.When("the value is invalid", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
ts.Signed = 2
|
||||
valid, err := SetConfigField(tv, *nodeMap["Signed"].Field(), "z7")
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeFalse())
|
||||
gomega.Expect(ts.Signed).To(gomega.Equal(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.When("setting an unsigned integer value", func() {
|
||||
ginkgo.When("the value is valid", func() {
|
||||
ginkgo.It("should set it", func() {
|
||||
valid, err := SetConfigField(tv, *nodeMap["Unsigned"].Field(), "6")
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeTrue())
|
||||
gomega.Expect(ts.Unsigned).To(gomega.Equal(uint(6)))
|
||||
})
|
||||
})
|
||||
ginkgo.When("the value is invalid", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
ts.Unsigned = 2
|
||||
valid, err := SetConfigField(tv, *nodeMap["Unsigned"].Field(), "-3")
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeFalse())
|
||||
gomega.Expect(ts.Unsigned).To(gomega.Equal(uint(2)))
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.When("setting a string slice value", func() {
|
||||
ginkgo.When("the value is valid", func() {
|
||||
ginkgo.It("should set it", func() {
|
||||
valid, err := SetConfigField(
|
||||
tv,
|
||||
*nodeMap["StrSlice"].Field(),
|
||||
"meawannowalkalitabitalleh,meawannofeelalitabitstrongah",
|
||||
)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeTrue())
|
||||
gomega.Expect(ts.StrSlice).To(gomega.HaveLen(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.When("setting a string array value", func() {
|
||||
ginkgo.When("the value is valid", func() {
|
||||
ginkgo.It("should set it", func() {
|
||||
valid, err := SetConfigField(
|
||||
tv,
|
||||
*nodeMap["StrArray"].Field(),
|
||||
"meawannowalkalitabitalleh,meawannofeelalitabitstrongah,meawannothinkalitabitsmartah",
|
||||
)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeTrue())
|
||||
})
|
||||
})
|
||||
ginkgo.When("the value has too many elements", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
valid, err := SetConfigField(
|
||||
tv,
|
||||
*nodeMap["StrArray"].Field(),
|
||||
"one,two,three,four?",
|
||||
)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeFalse())
|
||||
})
|
||||
})
|
||||
ginkgo.When("the value has too few elements", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
valid, err := SetConfigField(tv, *nodeMap["StrArray"].Field(), "oneassis,two")
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.When("setting a struct value", func() {
|
||||
ginkgo.When("it doesn't implement ConfigProp", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
valid, err := SetConfigField(tv, *nodeMap["Sub"].Field(), "@awol")
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).NotTo(gomega.BeTrue())
|
||||
})
|
||||
})
|
||||
ginkgo.When("it implements ConfigProp", func() {
|
||||
ginkgo.When("the value is valid", func() {
|
||||
ginkgo.It("should set it", func() {
|
||||
valid, err := SetConfigField(tv, *nodeMap["SubProp"].Field(), "@awol")
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeTrue())
|
||||
gomega.Expect(ts.SubProp.Value).To(gomega.Equal("awol"))
|
||||
})
|
||||
})
|
||||
ginkgo.When("the value is invalid", func() {
|
||||
ginkgo.It("should return an error", func() {
|
||||
valid, err := SetConfigField(
|
||||
tv,
|
||||
*nodeMap["SubProp"].Field(),
|
||||
"missing initial at symbol",
|
||||
)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).NotTo(gomega.BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.When("setting a struct slice value", func() {
|
||||
ginkgo.When("the value is valid", func() {
|
||||
ginkgo.It("should set it", func() {
|
||||
valid, err := SetConfigField(
|
||||
tv,
|
||||
*nodeMap["SubPropSlice"].Field(),
|
||||
"@alice,@merton",
|
||||
)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeTrue())
|
||||
gomega.Expect(ts.SubPropSlice).To(gomega.HaveLen(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.When("setting a struct pointer slice value", func() {
|
||||
ginkgo.When("the value is valid", func() {
|
||||
ginkgo.It("should set it", func() {
|
||||
valid, err := SetConfigField(
|
||||
tv,
|
||||
*nodeMap["SubPropPtrSlice"].Field(),
|
||||
"@the,@best",
|
||||
)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(valid).To(gomega.BeTrue())
|
||||
gomega.Expect(ts.SubPropPtrSlice).To(gomega.HaveLen(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.When("formatting stuct values", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
tsPtr := reflect.New(tt)
|
||||
tv = tsPtr.Elem()
|
||||
ts = tsPtr.Interface().(*testStruct)
|
||||
gomega.Expect(tv.CanSet()).To(gomega.BeTrue())
|
||||
gomega.Expect(tv.FieldByName("TestEnum").CanSet()).To(gomega.BeTrue())
|
||||
rootNode := getRootNode(ts)
|
||||
nodeMap = make(map[string]Node, len(rootNode.Items))
|
||||
for _, item := range rootNode.Items {
|
||||
field := item.Field()
|
||||
nodeMap[field.Name] = item
|
||||
}
|
||||
gomega.Expect(int(tv.FieldByName("TestEnum").Int())).
|
||||
To(gomega.Equal(0), "TestEnum initial value")
|
||||
})
|
||||
ginkgo.When("setting and formatting", func() {
|
||||
ginkgo.It("should format signed integers identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Signed"], "-45", "-45")
|
||||
})
|
||||
ginkgo.It("should format unsigned integers identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Unsigned"], "5", "5")
|
||||
})
|
||||
ginkgo.It("should format structs identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["SubProp"], "@whoa", "@whoa")
|
||||
})
|
||||
ginkgo.It("should format enums identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["TestEnum"], "Foo", "Foo")
|
||||
})
|
||||
ginkgo.It("should format string slices identical to input", func() {
|
||||
testSetAndFormat(
|
||||
tv,
|
||||
nodeMap["StrSlice"],
|
||||
"one,two,three,four",
|
||||
"[ one, two, three, four ]",
|
||||
)
|
||||
})
|
||||
ginkgo.It("should format string arrays identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["StrArray"], "one,two,three", "[ one, two, three ]")
|
||||
})
|
||||
ginkgo.It("should format prop struct slices identical to input", func() {
|
||||
testSetAndFormat(
|
||||
tv,
|
||||
nodeMap["SubPropSlice"],
|
||||
"@be,@the,@best",
|
||||
"[ @be, @the, @best ]",
|
||||
)
|
||||
})
|
||||
ginkgo.It("should format prop struct pointer slices identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["SubPropPtrSlice"], "@diet,@glue", "[ @diet, @glue ]")
|
||||
})
|
||||
ginkgo.It("should format string maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["StrMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format int maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["IntMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format int8 maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Int8Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format int16 maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Int16Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format int32 maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Int32Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format int64 maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Int64Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format uint maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["UintMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format uint8 maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Uint8Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format uint16 maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Uint16Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format uint32 maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Uint32Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
ginkgo.It("should format uint64 maps identical to input", func() {
|
||||
testSetAndFormat(tv, nodeMap["Uint64Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func testSetAndFormat(tv reflect.Value, node Node, value string, prettyFormat string) {
|
||||
field := node.Field()
|
||||
|
||||
valid, err := SetConfigField(tv, *field, value)
|
||||
if !valid {
|
||||
gomega.Expect(err).To(gomega.HaveOccurred(), "SetConfigField returned false but no error")
|
||||
}
|
||||
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "SetConfigField error: %v", err)
|
||||
gomega.Expect(valid).To(gomega.BeTrue(), "SetConfigField failed")
|
||||
|
||||
formatted, err := GetConfigFieldString(tv, *field)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "GetConfigFieldString error: %v", err)
|
||||
gomega.Expect(formatted).To(gomega.Equal(value), "Expected %q, got %q", value, formatted)
|
||||
node.Update(tv.FieldByName(field.Name))
|
||||
|
||||
sb := strings.Builder{}
|
||||
renderer := ConsoleTreeRenderer{}
|
||||
renderer.writeNodeValue(&sb, node)
|
||||
gomega.Expect(sb.String()).To(gomega.Equal(prettyFormat))
|
||||
}
|
390
pkg/format/node.go
Normal file
390
pkg/format/node.go
Normal file
|
@ -0,0 +1,390 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/util"
|
||||
)
|
||||
|
||||
// NodeTokenType is used to represent the type of value that a node has for syntax highlighting.
|
||||
type NodeTokenType int
|
||||
|
||||
const (
|
||||
// UnknownToken represents all unknown/unspecified tokens.
|
||||
UnknownToken NodeTokenType = iota
|
||||
// NumberToken represents all numbers.
|
||||
NumberToken
|
||||
// StringToken represents strings and keys.
|
||||
StringToken
|
||||
// EnumToken represents enum values.
|
||||
EnumToken
|
||||
// TrueToken represent boolean true.
|
||||
TrueToken
|
||||
// FalseToken represent boolean false.
|
||||
FalseToken
|
||||
// PropToken represent a serializable struct prop.
|
||||
PropToken
|
||||
// ErrorToken represent a value that was not serializable or otherwise invalid.
|
||||
ErrorToken
|
||||
// ContainerToken is used for Array/Slice and Map tokens.
|
||||
ContainerToken
|
||||
)
|
||||
|
||||
// Constants for number bases.
|
||||
const (
|
||||
BaseDecimalLen = 10
|
||||
BaseHexLen = 16
|
||||
)
|
||||
|
||||
// Node is the generic config tree item.
|
||||
type Node interface {
|
||||
Field() *FieldInfo
|
||||
TokenType() NodeTokenType
|
||||
Update(tv reflect.Value)
|
||||
}
|
||||
|
||||
// ValueNode is a Node without any child items.
|
||||
type ValueNode struct {
|
||||
*FieldInfo
|
||||
Value string
|
||||
tokenType NodeTokenType
|
||||
}
|
||||
|
||||
// Field returns the inner FieldInfo.
|
||||
func (n *ValueNode) Field() *FieldInfo {
|
||||
return n.FieldInfo
|
||||
}
|
||||
|
||||
// TokenType returns a NodeTokenType that matches the value.
|
||||
func (n *ValueNode) TokenType() NodeTokenType {
|
||||
return n.tokenType
|
||||
}
|
||||
|
||||
// Update updates the value string from the provided value.
|
||||
func (n *ValueNode) Update(tv reflect.Value) {
|
||||
value, token := getValueNodeValue(tv, n.FieldInfo)
|
||||
n.Value = value
|
||||
n.tokenType = token
|
||||
}
|
||||
|
||||
// ContainerNode is a Node with child items.
|
||||
type ContainerNode struct {
|
||||
*FieldInfo
|
||||
Items []Node
|
||||
MaxKeyLength int
|
||||
}
|
||||
|
||||
// Field returns the inner FieldInfo.
|
||||
func (n *ContainerNode) Field() *FieldInfo {
|
||||
return n.FieldInfo
|
||||
}
|
||||
|
||||
// TokenType always returns ContainerToken for ContainerNode.
|
||||
func (n *ContainerNode) TokenType() NodeTokenType {
|
||||
return ContainerToken
|
||||
}
|
||||
|
||||
// Update updates the items to match the provided value.
|
||||
func (n *ContainerNode) Update(tv reflect.Value) {
|
||||
switch n.Type.Kind() {
|
||||
case reflect.Array, reflect.Slice:
|
||||
n.updateArrayNode(tv)
|
||||
case reflect.Map:
|
||||
n.updateMapNode(tv)
|
||||
case reflect.Invalid,
|
||||
reflect.Bool,
|
||||
reflect.Int,
|
||||
reflect.Int8,
|
||||
reflect.Int16,
|
||||
reflect.Int32,
|
||||
reflect.Int64,
|
||||
reflect.Uint,
|
||||
reflect.Uint8,
|
||||
reflect.Uint16,
|
||||
reflect.Uint32,
|
||||
reflect.Uint64,
|
||||
reflect.Uintptr,
|
||||
reflect.Float32,
|
||||
reflect.Float64,
|
||||
reflect.Complex64,
|
||||
reflect.Complex128,
|
||||
reflect.Chan,
|
||||
reflect.Func,
|
||||
reflect.Interface,
|
||||
reflect.Pointer,
|
||||
reflect.String,
|
||||
reflect.Struct,
|
||||
reflect.UnsafePointer:
|
||||
// No-op for unsupported kinds
|
||||
default:
|
||||
// No-op for any remaining kinds
|
||||
}
|
||||
}
|
||||
|
||||
func (n *ContainerNode) updateArrayNode(arrayValue reflect.Value) {
|
||||
itemCount := arrayValue.Len()
|
||||
n.Items = make([]Node, 0, itemCount)
|
||||
|
||||
elemType := arrayValue.Type().Elem()
|
||||
|
||||
for i := range itemCount {
|
||||
key := strconv.Itoa(i)
|
||||
val := arrayValue.Index(i)
|
||||
n.Items = append(n.Items, getValueNode(val, &FieldInfo{
|
||||
Name: key,
|
||||
Type: elemType,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func getArrayNode(arrayValue reflect.Value, fieldInfo *FieldInfo) *ContainerNode {
|
||||
node := &ContainerNode{
|
||||
FieldInfo: fieldInfo,
|
||||
MaxKeyLength: 0,
|
||||
}
|
||||
node.updateArrayNode(arrayValue)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
func sortNodeItems(nodeItems []Node) {
|
||||
sort.Slice(nodeItems, func(i, j int) bool {
|
||||
return nodeItems[i].Field().Name < nodeItems[j].Field().Name
|
||||
})
|
||||
}
|
||||
|
||||
func (n *ContainerNode) updateMapNode(mapValue reflect.Value) {
|
||||
base := n.Base
|
||||
if base == 0 {
|
||||
base = BaseDecimalLen
|
||||
}
|
||||
|
||||
elemType := mapValue.Type().Elem()
|
||||
mapKeys := mapValue.MapKeys()
|
||||
nodeItems := make([]Node, len(mapKeys))
|
||||
maxKeyLength := 0
|
||||
|
||||
for i, keyVal := range mapKeys {
|
||||
// The keys will always be strings
|
||||
key := keyVal.String()
|
||||
val := mapValue.MapIndex(keyVal)
|
||||
nodeItems[i] = getValueNode(val, &FieldInfo{
|
||||
Name: key,
|
||||
Type: elemType,
|
||||
Base: base,
|
||||
})
|
||||
maxKeyLength = util.Max(len(key), maxKeyLength)
|
||||
}
|
||||
|
||||
sortNodeItems(nodeItems)
|
||||
|
||||
n.Items = nodeItems
|
||||
n.MaxKeyLength = maxKeyLength
|
||||
}
|
||||
|
||||
func getMapNode(mapValue reflect.Value, fieldInfo *FieldInfo) *ContainerNode {
|
||||
if mapValue.Kind() == reflect.Ptr {
|
||||
mapValue = mapValue.Elem()
|
||||
}
|
||||
|
||||
node := &ContainerNode{
|
||||
FieldInfo: fieldInfo,
|
||||
}
|
||||
node.updateMapNode(mapValue)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
func getNode(fieldVal reflect.Value, fieldInfo *FieldInfo) Node {
|
||||
switch fieldInfo.Type.Kind() {
|
||||
case reflect.Array, reflect.Slice:
|
||||
return getArrayNode(fieldVal, fieldInfo)
|
||||
case reflect.Map:
|
||||
return getMapNode(fieldVal, fieldInfo)
|
||||
case reflect.Invalid,
|
||||
reflect.Bool,
|
||||
reflect.Int,
|
||||
reflect.Int8,
|
||||
reflect.Int16,
|
||||
reflect.Int32,
|
||||
reflect.Int64,
|
||||
reflect.Uint,
|
||||
reflect.Uint8,
|
||||
reflect.Uint16,
|
||||
reflect.Uint32,
|
||||
reflect.Uint64,
|
||||
reflect.Uintptr,
|
||||
reflect.Float32,
|
||||
reflect.Float64,
|
||||
reflect.Complex64,
|
||||
reflect.Complex128,
|
||||
reflect.Chan,
|
||||
reflect.Func,
|
||||
reflect.Interface,
|
||||
reflect.Pointer,
|
||||
reflect.String,
|
||||
reflect.Struct,
|
||||
reflect.UnsafePointer:
|
||||
return getValueNode(fieldVal, fieldInfo)
|
||||
default:
|
||||
return getValueNode(fieldVal, fieldInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func getRootNode(value any) *ContainerNode {
|
||||
structValue := reflect.ValueOf(value)
|
||||
if structValue.Kind() == reflect.Ptr {
|
||||
structValue = structValue.Elem()
|
||||
}
|
||||
|
||||
structType := structValue.Type()
|
||||
|
||||
enums := map[string]types.EnumFormatter{}
|
||||
if enummer, isEnummer := value.(types.Enummer); isEnummer {
|
||||
enums = enummer.Enums()
|
||||
}
|
||||
|
||||
infoFields := getStructFieldInfo(structType, enums)
|
||||
nodeItems := make([]Node, 0, len(infoFields))
|
||||
maxKeyLength := 0
|
||||
|
||||
for _, fieldInfo := range infoFields {
|
||||
fieldValue := structValue.FieldByName(fieldInfo.Name)
|
||||
if !fieldValue.IsValid() {
|
||||
fieldValue = reflect.Zero(fieldInfo.Type)
|
||||
}
|
||||
|
||||
nodeItems = append(nodeItems, getNode(fieldValue, &fieldInfo))
|
||||
maxKeyLength = util.Max(len(fieldInfo.Name), maxKeyLength)
|
||||
}
|
||||
|
||||
sortNodeItems(nodeItems)
|
||||
|
||||
return &ContainerNode{
|
||||
FieldInfo: &FieldInfo{Type: structType},
|
||||
Items: nodeItems,
|
||||
MaxKeyLength: maxKeyLength,
|
||||
}
|
||||
}
|
||||
|
||||
func getValueNode(fieldVal reflect.Value, fieldInfo *FieldInfo) *ValueNode {
|
||||
value, tokenType := getValueNodeValue(fieldVal, fieldInfo)
|
||||
|
||||
return &ValueNode{
|
||||
FieldInfo: fieldInfo,
|
||||
Value: value,
|
||||
tokenType: tokenType,
|
||||
}
|
||||
}
|
||||
|
||||
func getValueNodeValue(fieldValue reflect.Value, fieldInfo *FieldInfo) (string, NodeTokenType) {
|
||||
kind := fieldValue.Kind()
|
||||
|
||||
base := fieldInfo.Base
|
||||
if base == 0 {
|
||||
base = BaseDecimalLen
|
||||
}
|
||||
|
||||
if fieldInfo.IsEnum() {
|
||||
return fieldInfo.EnumFormatter.Print(int(fieldValue.Int())), EnumToken
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
val := strconv.FormatUint(fieldValue.Uint(), base)
|
||||
if base == BaseHexLen {
|
||||
val = "0x" + val
|
||||
}
|
||||
|
||||
return val, NumberToken
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return strconv.FormatInt(fieldValue.Int(), base), NumberToken
|
||||
case reflect.String:
|
||||
return fieldValue.String(), StringToken
|
||||
case reflect.Bool:
|
||||
val := fieldValue.Bool()
|
||||
if val {
|
||||
return PrintBool(val), TrueToken
|
||||
}
|
||||
|
||||
return PrintBool(val), FalseToken
|
||||
case reflect.Array, reflect.Slice, reflect.Map:
|
||||
return getContainerValueString(fieldValue, fieldInfo), UnknownToken
|
||||
case reflect.Ptr, reflect.Struct:
|
||||
if val, err := GetConfigPropString(fieldValue); err == nil {
|
||||
return val, PropToken
|
||||
}
|
||||
|
||||
return "<ERR>", ErrorToken
|
||||
case reflect.Invalid,
|
||||
reflect.Uintptr,
|
||||
reflect.Float32,
|
||||
reflect.Float64,
|
||||
reflect.Complex64,
|
||||
reflect.Complex128,
|
||||
reflect.Chan,
|
||||
reflect.Func,
|
||||
reflect.Interface,
|
||||
reflect.UnsafePointer:
|
||||
return fmt.Sprintf("<?%s>", kind.String()), UnknownToken
|
||||
default:
|
||||
return fmt.Sprintf("<?%s>", kind.String()), UnknownToken
|
||||
}
|
||||
}
|
||||
|
||||
func getContainerValueString(fieldValue reflect.Value, fieldInfo *FieldInfo) string {
|
||||
itemSeparator := fieldInfo.ItemSeparator
|
||||
sliceLength := fieldValue.Len()
|
||||
|
||||
var mapKeys []reflect.Value
|
||||
if fieldInfo.Type.Kind() == reflect.Map {
|
||||
mapKeys = fieldValue.MapKeys()
|
||||
sort.Slice(mapKeys, func(a, b int) bool {
|
||||
return mapKeys[a].String() < mapKeys[b].String()
|
||||
})
|
||||
}
|
||||
|
||||
stringBuilder := strings.Builder{}
|
||||
|
||||
var itemFieldInfo *FieldInfo
|
||||
|
||||
for i := range sliceLength {
|
||||
var itemValue reflect.Value
|
||||
|
||||
if i > 0 {
|
||||
stringBuilder.WriteRune(itemSeparator)
|
||||
}
|
||||
|
||||
if mapKeys != nil {
|
||||
mapKey := mapKeys[i]
|
||||
stringBuilder.WriteString(mapKey.String())
|
||||
stringBuilder.WriteRune(':')
|
||||
|
||||
itemValue = fieldValue.MapIndex(mapKey)
|
||||
} else {
|
||||
itemValue = fieldValue.Index(i)
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
itemFieldInfo = &FieldInfo{
|
||||
Type: itemValue.Type(),
|
||||
// Inherit the base from the container
|
||||
Base: fieldInfo.Base,
|
||||
}
|
||||
|
||||
if itemFieldInfo.Base == 0 {
|
||||
itemFieldInfo.Base = BaseDecimalLen
|
||||
}
|
||||
}
|
||||
|
||||
strVal, _ := getValueNodeValue(itemValue, itemFieldInfo)
|
||||
stringBuilder.WriteString(strVal)
|
||||
}
|
||||
|
||||
return stringBuilder.String()
|
||||
}
|
176
pkg/format/prop_key_resolver.go
Normal file
176
pkg/format/prop_key_resolver.go
Normal file
|
@ -0,0 +1,176 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidConfigKey = errors.New("not a valid config key")
|
||||
ErrInvalidValueForType = errors.New("invalid value for type")
|
||||
)
|
||||
|
||||
// PropKeyResolver implements the ConfigQueryResolver interface for services that uses key tags for query props.
|
||||
type PropKeyResolver struct {
|
||||
confValue reflect.Value
|
||||
keyFields map[string]FieldInfo
|
||||
keys []string
|
||||
}
|
||||
|
||||
// NewPropKeyResolver creates a new PropKeyResolver and initializes it using the provided config.
|
||||
func NewPropKeyResolver(config types.ServiceConfig) PropKeyResolver {
|
||||
configNode := GetConfigFormat(config)
|
||||
items := configNode.Items
|
||||
|
||||
keyFields := make(map[string]FieldInfo, len(items))
|
||||
keys := make([]string, 0, len(items))
|
||||
|
||||
for _, item := range items {
|
||||
field := *item.Field()
|
||||
if len(field.Keys) == 0 {
|
||||
continue // Skip fields without explicit 'key' tags
|
||||
}
|
||||
|
||||
for _, key := range field.Keys {
|
||||
key = strings.ToLower(key)
|
||||
if key != "" {
|
||||
keys = append(keys, key)
|
||||
keyFields[key] = field
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
confValue := reflect.ValueOf(config)
|
||||
if confValue.Kind() == reflect.Ptr {
|
||||
confValue = confValue.Elem()
|
||||
}
|
||||
|
||||
return PropKeyResolver{
|
||||
keyFields: keyFields,
|
||||
confValue: confValue,
|
||||
keys: keys,
|
||||
}
|
||||
}
|
||||
|
||||
// QueryFields returns a list of tagged keys.
|
||||
func (pkr *PropKeyResolver) QueryFields() []string {
|
||||
return pkr.keys
|
||||
}
|
||||
|
||||
// Get returns the value of a config property tagged with the corresponding key.
|
||||
func (pkr *PropKeyResolver) Get(key string) (string, error) {
|
||||
if field, found := pkr.keyFields[strings.ToLower(key)]; found {
|
||||
return GetConfigFieldString(pkr.confValue, field)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidConfigKey, key)
|
||||
}
|
||||
|
||||
// Set sets the value of its bound struct's property, tagged with the corresponding key.
|
||||
func (pkr *PropKeyResolver) Set(key string, value string) error {
|
||||
return pkr.set(pkr.confValue, key, value)
|
||||
}
|
||||
|
||||
// set sets the value of a target struct tagged with the corresponding key.
|
||||
func (pkr *PropKeyResolver) set(target reflect.Value, key string, value string) error {
|
||||
if field, found := pkr.keyFields[strings.ToLower(key)]; found {
|
||||
valid, err := SetConfigField(target, field, value)
|
||||
if !valid && err == nil {
|
||||
return ErrInvalidValueForType
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %v (valid keys: %v)", ErrInvalidConfigKey, key, pkr.keys)
|
||||
}
|
||||
|
||||
// UpdateConfigFromParams mutates the provided config, updating the values from its corresponding params.
|
||||
// If the provided config is nil, the internal config will be updated instead.
|
||||
// The error returned is the first error that occurred; subsequent errors are discarded.
|
||||
func (pkr *PropKeyResolver) UpdateConfigFromParams(
|
||||
config types.ServiceConfig,
|
||||
params *types.Params,
|
||||
) error {
|
||||
var firstError error
|
||||
|
||||
confValue := pkr.configValueOrInternal(config)
|
||||
|
||||
if params != nil {
|
||||
for key, val := range *params {
|
||||
if err := pkr.set(confValue, key, val); err != nil && firstError == nil {
|
||||
firstError = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstError
|
||||
}
|
||||
|
||||
// SetDefaultProps mutates the provided config, setting the tagged fields with their default values.
|
||||
// If the provided config is nil, the internal config will be updated instead.
|
||||
// The error returned is the first error that occurred; subsequent errors are discarded.
|
||||
func (pkr *PropKeyResolver) SetDefaultProps(config types.ServiceConfig) error {
|
||||
var firstError error
|
||||
|
||||
confValue := pkr.configValueOrInternal(config)
|
||||
for key, info := range pkr.keyFields {
|
||||
if err := pkr.set(confValue, key, info.DefaultValue); err != nil && firstError == nil {
|
||||
firstError = err
|
||||
}
|
||||
}
|
||||
|
||||
return firstError
|
||||
}
|
||||
|
||||
// Bind creates a new instance of the PropKeyResolver with the internal config reference
|
||||
// set to the provided config. This should only be used for configs of the same type.
|
||||
func (pkr *PropKeyResolver) Bind(config types.ServiceConfig) PropKeyResolver {
|
||||
bound := *pkr
|
||||
bound.confValue = configValue(config)
|
||||
|
||||
return bound
|
||||
}
|
||||
|
||||
// GetConfigQueryResolver returns the ConfigQueryResolver interface for the config if it implements it,
|
||||
// otherwise it creates and returns a PropKeyResolver that implements it.
|
||||
func GetConfigQueryResolver(config types.ServiceConfig) types.ConfigQueryResolver {
|
||||
var resolver types.ConfigQueryResolver
|
||||
|
||||
var ok bool
|
||||
if resolver, ok = config.(types.ConfigQueryResolver); !ok {
|
||||
pkr := NewPropKeyResolver(config)
|
||||
resolver = &pkr
|
||||
}
|
||||
|
||||
return resolver
|
||||
}
|
||||
|
||||
// KeyIsPrimary returns whether the key is the primary (and not an alias).
|
||||
func (pkr *PropKeyResolver) KeyIsPrimary(key string) bool {
|
||||
return pkr.keyFields[key].Keys[0] == key
|
||||
}
|
||||
|
||||
func (pkr *PropKeyResolver) configValueOrInternal(config types.ServiceConfig) reflect.Value {
|
||||
if config != nil {
|
||||
return configValue(config)
|
||||
}
|
||||
|
||||
return pkr.confValue
|
||||
}
|
||||
|
||||
func configValue(config types.ServiceConfig) reflect.Value {
|
||||
return reflect.Indirect(reflect.ValueOf(config))
|
||||
}
|
||||
|
||||
// IsDefault returns whether the specified key value is the default value.
|
||||
func (pkr *PropKeyResolver) IsDefault(key string, value string) bool {
|
||||
return pkr.keyFields[key].DefaultValue == value
|
||||
}
|
58
pkg/format/prop_key_resolver_test.go
Normal file
58
pkg/format/prop_key_resolver_test.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("Prop Key Resolver", func() {
|
||||
var (
|
||||
ts *testStruct
|
||||
pkr PropKeyResolver
|
||||
)
|
||||
ginkgo.BeforeEach(func() {
|
||||
ts = &testStruct{}
|
||||
pkr = NewPropKeyResolver(ts)
|
||||
_ = pkr.SetDefaultProps(ts)
|
||||
})
|
||||
ginkgo.Describe("Updating config props from params", func() {
|
||||
ginkgo.When("a param matches a prop key", func() {
|
||||
ginkgo.It("should be updated in the config", func() {
|
||||
err := pkr.UpdateConfigFromParams(nil, &types.Params{"str": "newValue"})
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(ts.Str).To(gomega.Equal("newValue"))
|
||||
})
|
||||
})
|
||||
ginkgo.When("a param does not match a prop key", func() {
|
||||
ginkgo.It("should report the first error", func() {
|
||||
err := pkr.UpdateConfigFromParams(nil, &types.Params{"a": "z"})
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
})
|
||||
ginkgo.It("should process the other keys", func() {
|
||||
_ = pkr.UpdateConfigFromParams(
|
||||
nil,
|
||||
&types.Params{"signed": "1", "b": "c", "str": "val"},
|
||||
)
|
||||
gomega.Expect(ts.Signed).To(gomega.Equal(1))
|
||||
gomega.Expect(ts.Str).To(gomega.Equal("val"))
|
||||
})
|
||||
})
|
||||
})
|
||||
ginkgo.Describe("Setting default props", func() {
|
||||
ginkgo.When("a default tag are set for a field", func() {
|
||||
ginkgo.It("should have that value as default", func() {
|
||||
gomega.Expect(ts.Str).To(gomega.Equal("notempty"))
|
||||
})
|
||||
})
|
||||
ginkgo.When("a default tag have an invalid value", func() {
|
||||
ginkgo.It("should have that value as default", func() {
|
||||
tsb := &testStructBadDefault{}
|
||||
pkr = NewPropKeyResolver(tsb)
|
||||
err := pkr.SetDefaultProps(tsb)
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
201
pkg/format/render_console.go
Normal file
201
pkg/format/render_console.go
Normal file
|
@ -0,0 +1,201 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/util"
|
||||
)
|
||||
|
||||
// Constants for console rendering.
|
||||
const (
|
||||
DescriptionColumnWidth = 60 // Width of the description column in console output
|
||||
ItemSeparatorLength = 2 // Length of the ", " separator between container items
|
||||
DefaultValueOffset = 16 // Minimum offset before description when no values are shown
|
||||
ValueOffset = 30 // Offset before description when values are shown
|
||||
ContainerBracketLength = 4 // Length of container delimiters (e.g., "{ }" or "[ ]")
|
||||
KeySeparatorLength = 2 // Length of the ": " separator after a key in containers
|
||||
)
|
||||
|
||||
// ConsoleTreeRenderer renders a ContainerNode tree into an ansi-colored console string.
|
||||
type ConsoleTreeRenderer struct {
|
||||
WithValues bool
|
||||
}
|
||||
|
||||
// RenderTree renders a ContainerNode tree into an ansi-colored console string.
|
||||
func (r ConsoleTreeRenderer) RenderTree(root *ContainerNode, _ string) string {
|
||||
stringBuilder := strings.Builder{}
|
||||
|
||||
for _, node := range root.Items {
|
||||
fieldKey := node.Field().Name
|
||||
stringBuilder.WriteString(fieldKey)
|
||||
|
||||
for i := len(fieldKey); i <= root.MaxKeyLength; i++ {
|
||||
stringBuilder.WriteRune(' ')
|
||||
}
|
||||
|
||||
var valueLen int // Initialize without assignment; set later
|
||||
|
||||
preLen := DefaultValueOffset // Default spacing before the description when no values are rendered
|
||||
|
||||
field := node.Field()
|
||||
|
||||
if r.WithValues {
|
||||
preLen = ValueOffset // Adjusts the spacing when values are included
|
||||
valueLen = r.writeNodeValue(&stringBuilder, node)
|
||||
} else {
|
||||
// Since no values were supplied, substitute the value with the type
|
||||
typeName := field.Type.String()
|
||||
|
||||
// If the value is an enum type, providing the name is a bit pointless
|
||||
// Instead, use a common string "option" to signify the type
|
||||
if field.EnumFormatter != nil {
|
||||
typeName = "option"
|
||||
}
|
||||
|
||||
valueLen = len(typeName)
|
||||
stringBuilder.WriteString(color.CyanString(typeName))
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(strings.Repeat(" ", util.Max(preLen-valueLen, 1)))
|
||||
stringBuilder.WriteString(ColorizeDesc(field.Description))
|
||||
stringBuilder.WriteString(
|
||||
strings.Repeat(" ", util.Max(DescriptionColumnWidth-len(field.Description), 1)),
|
||||
)
|
||||
|
||||
if len(field.URLParts) > 0 && field.URLParts[0] != URLQuery {
|
||||
stringBuilder.WriteString(" <URL: ")
|
||||
|
||||
for i, part := range field.URLParts {
|
||||
if i > 0 {
|
||||
stringBuilder.WriteString(", ")
|
||||
}
|
||||
|
||||
if part > URLPath {
|
||||
part = URLPath
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(ColorizeEnum(part))
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(">")
|
||||
}
|
||||
|
||||
if len(field.Template) > 0 {
|
||||
stringBuilder.WriteString(
|
||||
fmt.Sprintf(" <Template: %s>", ColorizeString(field.Template)),
|
||||
)
|
||||
}
|
||||
|
||||
if len(field.DefaultValue) > 0 {
|
||||
stringBuilder.WriteString(
|
||||
fmt.Sprintf(
|
||||
" <Default: %s>",
|
||||
ColorizeValue(field.DefaultValue, field.EnumFormatter != nil),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if field.Required {
|
||||
stringBuilder.WriteString(fmt.Sprintf(" <%s>", ColorizeFalse("Required")))
|
||||
}
|
||||
|
||||
if len(field.Keys) > 1 {
|
||||
stringBuilder.WriteString(" <Aliases: ")
|
||||
|
||||
for i, key := range field.Keys {
|
||||
if i == 0 {
|
||||
// Skip primary alias (as it's the same as the field name)
|
||||
continue
|
||||
}
|
||||
|
||||
if i > 1 {
|
||||
stringBuilder.WriteString(", ")
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(ColorizeString(key))
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(">")
|
||||
}
|
||||
|
||||
if field.EnumFormatter != nil {
|
||||
stringBuilder.WriteString(ColorizeContainer(" ["))
|
||||
|
||||
for i, name := range field.EnumFormatter.Names() {
|
||||
if i != 0 {
|
||||
stringBuilder.WriteString(", ")
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(ColorizeEnum(name))
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(ColorizeContainer("]"))
|
||||
}
|
||||
|
||||
stringBuilder.WriteRune('\n')
|
||||
}
|
||||
|
||||
return stringBuilder.String()
|
||||
}
|
||||
|
||||
func (r ConsoleTreeRenderer) writeNodeValue(stringBuilder *strings.Builder, node Node) int {
|
||||
if contNode, isContainer := node.(*ContainerNode); isContainer {
|
||||
return r.writeContainer(stringBuilder, contNode)
|
||||
}
|
||||
|
||||
if valNode, isValue := node.(*ValueNode); isValue {
|
||||
stringBuilder.WriteString(ColorizeToken(valNode.Value, valNode.tokenType))
|
||||
|
||||
return len(valNode.Value)
|
||||
}
|
||||
|
||||
stringBuilder.WriteRune('?')
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
func (r ConsoleTreeRenderer) writeContainer(
|
||||
stringBuilder *strings.Builder,
|
||||
node *ContainerNode,
|
||||
) int {
|
||||
kind := node.Type.Kind()
|
||||
hasKeys := !util.IsCollection(kind)
|
||||
|
||||
totalLen := ContainerBracketLength // Length of the opening and closing brackets ({ } or [ ])
|
||||
|
||||
if hasKeys {
|
||||
stringBuilder.WriteString("{ ")
|
||||
} else {
|
||||
stringBuilder.WriteString("[ ")
|
||||
}
|
||||
|
||||
for i, itemNode := range node.Items {
|
||||
if i != 0 {
|
||||
stringBuilder.WriteString(", ")
|
||||
|
||||
totalLen += KeySeparatorLength // Accounts for the : separator between keys and values in containers
|
||||
}
|
||||
|
||||
if hasKeys {
|
||||
itemKey := itemNode.Field().Name
|
||||
stringBuilder.WriteString(itemKey)
|
||||
stringBuilder.WriteString(": ")
|
||||
|
||||
totalLen += len(itemKey) + ItemSeparatorLength
|
||||
}
|
||||
|
||||
valLen := r.writeNodeValue(stringBuilder, itemNode)
|
||||
totalLen += valLen
|
||||
}
|
||||
|
||||
if hasKeys {
|
||||
stringBuilder.WriteString(" }")
|
||||
} else {
|
||||
stringBuilder.WriteString(" ]")
|
||||
}
|
||||
|
||||
return totalLen
|
||||
}
|
54
pkg/format/render_console_test.go
Normal file
54
pkg/format/render_console_test.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
"github.com/onsi/gomega/format"
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("RenderConsole", func() {
|
||||
format.CharactersAroundMismatchToInclude = 30
|
||||
renderer := ConsoleTreeRenderer{WithValues: false}
|
||||
|
||||
ginkgo.It("should render the expected output based on config reflection/tags", func() {
|
||||
actual := testRenderTree(renderer, &struct {
|
||||
Name string `default:"notempty"`
|
||||
Host string `url:"host"`
|
||||
}{})
|
||||
|
||||
expected := `
|
||||
Host string <URL: Host> <Required>
|
||||
Name string <Default: notempty>
|
||||
`[1:]
|
||||
|
||||
gomega.Expect(actual).To(gomega.Equal(expected))
|
||||
})
|
||||
|
||||
ginkgo.It(`should render enum types as "option"`, func() {
|
||||
actual := testRenderTree(renderer, &testEnummer{})
|
||||
|
||||
expected := `
|
||||
Choice option <Default: Maybe> [Yes, No, Maybe]
|
||||
`[1:]
|
||||
|
||||
gomega.Expect(actual).To(gomega.Equal(expected))
|
||||
})
|
||||
|
||||
ginkgo.It("should render url paths in sorted order", func() {
|
||||
actual := testRenderTree(renderer, &struct {
|
||||
Host string `url:"host"`
|
||||
Path1 string `url:"path1"`
|
||||
Path3 string `url:"path3"`
|
||||
Path2 string `url:"path2"`
|
||||
}{})
|
||||
|
||||
expected := `
|
||||
Host string <URL: Host> <Required>
|
||||
Path1 string <URL: Path> <Required>
|
||||
Path2 string <URL: Path> <Required>
|
||||
Path3 string <URL: Path> <Required>
|
||||
`[1:]
|
||||
|
||||
gomega.Expect(actual).To(gomega.Equal(expected))
|
||||
})
|
||||
})
|
259
pkg/format/render_markdown.go
Normal file
259
pkg/format/render_markdown.go
Normal file
|
@ -0,0 +1,259 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MarkdownTreeRenderer renders a ContainerNode tree into a markdown documentation string.
|
||||
type MarkdownTreeRenderer struct {
|
||||
HeaderPrefix string
|
||||
PropsDescription string
|
||||
PropsEmptyMessage string
|
||||
}
|
||||
|
||||
// Constants for dynamic path segment offsets.
|
||||
const (
|
||||
PathOffset1 = 1
|
||||
PathOffset2 = 2
|
||||
PathOffset3 = 3
|
||||
)
|
||||
|
||||
// RenderTree renders a ContainerNode tree into a markdown documentation string.
|
||||
func (r MarkdownTreeRenderer) RenderTree(root *ContainerNode, scheme string) string {
|
||||
stringBuilder := strings.Builder{}
|
||||
|
||||
queryFields := make([]*FieldInfo, 0, len(root.Items))
|
||||
urlFields := make([]*FieldInfo, 0, len(root.Items)) // Zero length, capacity for all fields
|
||||
dynamicURLFields := make([]*FieldInfo, 0, len(root.Items))
|
||||
|
||||
for _, node := range root.Items {
|
||||
field := node.Field()
|
||||
for _, urlPart := range field.URLParts {
|
||||
switch urlPart {
|
||||
case URLQuery:
|
||||
queryFields = append(queryFields, field)
|
||||
case URLPath + PathOffset1,
|
||||
URLPath + PathOffset2,
|
||||
URLPath + PathOffset3:
|
||||
dynamicURLFields = append(dynamicURLFields, field)
|
||||
case URLUser, URLPassword, URLHost, URLPort, URLPath:
|
||||
urlFields = append(urlFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
if len(field.URLParts) < 1 {
|
||||
queryFields = append(queryFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
// Append dynamic fields to urlFields
|
||||
urlFields = append(urlFields, dynamicURLFields...)
|
||||
|
||||
// Sort by primary URLPart
|
||||
sort.SliceStable(urlFields, func(i, j int) bool {
|
||||
urlPartA := URLQuery
|
||||
if len(urlFields[i].URLParts) > 0 {
|
||||
urlPartA = urlFields[i].URLParts[0]
|
||||
}
|
||||
|
||||
urlPartB := URLQuery
|
||||
if len(urlFields[j].URLParts) > 0 {
|
||||
urlPartB = urlFields[j].URLParts[0]
|
||||
}
|
||||
|
||||
return urlPartA < urlPartB
|
||||
})
|
||||
|
||||
r.writeURLFields(&stringBuilder, urlFields, scheme)
|
||||
|
||||
sort.SliceStable(queryFields, func(i, j int) bool {
|
||||
return queryFields[i].Required && !queryFields[j].Required
|
||||
})
|
||||
|
||||
r.writeHeader(&stringBuilder, "Query/Param Props")
|
||||
|
||||
if len(queryFields) > 0 {
|
||||
stringBuilder.WriteString(r.PropsDescription)
|
||||
} else {
|
||||
stringBuilder.WriteString(r.PropsEmptyMessage)
|
||||
}
|
||||
|
||||
stringBuilder.WriteRune('\n')
|
||||
|
||||
for _, field := range queryFields {
|
||||
r.writeFieldPrimary(&stringBuilder, field)
|
||||
r.writeFieldExtras(&stringBuilder, field)
|
||||
stringBuilder.WriteRune('\n')
|
||||
}
|
||||
|
||||
return stringBuilder.String()
|
||||
}
|
||||
|
||||
func (r MarkdownTreeRenderer) writeURLFields(
|
||||
stringBuilder *strings.Builder,
|
||||
urlFields []*FieldInfo,
|
||||
scheme string,
|
||||
) {
|
||||
fieldsPrinted := make(map[string]bool)
|
||||
|
||||
r.writeHeader(stringBuilder, "URL Fields")
|
||||
|
||||
for _, field := range urlFields {
|
||||
if field == nil || fieldsPrinted[field.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
r.writeFieldPrimary(stringBuilder, field)
|
||||
|
||||
stringBuilder.WriteString(" URL part: <code class=\"service-url\">")
|
||||
stringBuilder.WriteString(scheme)
|
||||
stringBuilder.WriteString("://")
|
||||
|
||||
// Check for presence of URLUser or URLPassword
|
||||
hasUser := false
|
||||
hasPassword := false
|
||||
maxPart := URLUser // Track the highest URLPart used
|
||||
|
||||
for _, f := range urlFields {
|
||||
if f != nil {
|
||||
for _, part := range f.URLParts {
|
||||
switch part {
|
||||
case URLQuery, URLHost, URLPort, URLPath: // No-op for these cases
|
||||
case URLUser:
|
||||
hasUser = true
|
||||
case URLPassword:
|
||||
hasPassword = true
|
||||
}
|
||||
|
||||
if part > maxPart {
|
||||
maxPart = part
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build URL with this field highlighted
|
||||
for i := URLUser; i <= URLPath+PathOffset3; i++ {
|
||||
urlPart := i
|
||||
for _, fieldInfo := range urlFields {
|
||||
if fieldInfo != nil && fieldInfo.IsURLPart(urlPart) {
|
||||
if i > URLUser {
|
||||
lastPart := i - 1
|
||||
if lastPart == URLPassword && (hasUser || hasPassword) {
|
||||
stringBuilder.WriteRune(
|
||||
lastPart.Suffix(),
|
||||
) // ':' only if credentials present
|
||||
} else if lastPart != URLPassword {
|
||||
stringBuilder.WriteRune(lastPart.Suffix()) // '/' or '@'
|
||||
}
|
||||
}
|
||||
|
||||
slug := strings.ToLower(fieldInfo.Name)
|
||||
if slug == "host" && urlPart == URLPort {
|
||||
slug = "port"
|
||||
}
|
||||
|
||||
if fieldInfo == field {
|
||||
stringBuilder.WriteString("<strong>")
|
||||
stringBuilder.WriteString(slug)
|
||||
stringBuilder.WriteString("</strong>")
|
||||
} else {
|
||||
stringBuilder.WriteString(slug)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add trailing '/' if no dynamic path segments follow
|
||||
if maxPart < URLPath+PathOffset1 {
|
||||
stringBuilder.WriteRune('/')
|
||||
}
|
||||
|
||||
stringBuilder.WriteString("</code> \n")
|
||||
|
||||
fieldsPrinted[field.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (MarkdownTreeRenderer) writeFieldExtras(stringBuilder *strings.Builder, field *FieldInfo) {
|
||||
if len(field.Keys) > 1 {
|
||||
stringBuilder.WriteString(" Aliases: `")
|
||||
|
||||
for i, key := range field.Keys {
|
||||
if i == 0 {
|
||||
// Skip primary alias (as it's the same as the field name)
|
||||
continue
|
||||
}
|
||||
|
||||
if i > 1 {
|
||||
stringBuilder.WriteString("`, `")
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(key)
|
||||
}
|
||||
|
||||
stringBuilder.WriteString("` \n")
|
||||
}
|
||||
|
||||
if field.EnumFormatter != nil {
|
||||
stringBuilder.WriteString(" Possible values: `")
|
||||
|
||||
for i, name := range field.EnumFormatter.Names() {
|
||||
if i != 0 {
|
||||
stringBuilder.WriteString("`, `")
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(name)
|
||||
}
|
||||
|
||||
stringBuilder.WriteString("` \n")
|
||||
}
|
||||
}
|
||||
|
||||
func (MarkdownTreeRenderer) writeFieldPrimary(stringBuilder *strings.Builder, field *FieldInfo) {
|
||||
fieldKey := field.Name
|
||||
|
||||
stringBuilder.WriteString("* __")
|
||||
stringBuilder.WriteString(fieldKey)
|
||||
stringBuilder.WriteString("__")
|
||||
|
||||
if field.Description != "" {
|
||||
stringBuilder.WriteString(" - ")
|
||||
stringBuilder.WriteString(field.Description)
|
||||
}
|
||||
|
||||
if field.Required {
|
||||
stringBuilder.WriteString(" (**Required**) \n")
|
||||
} else {
|
||||
stringBuilder.WriteString(" \n Default: ")
|
||||
|
||||
if field.DefaultValue == "" {
|
||||
stringBuilder.WriteString("*empty*")
|
||||
} else {
|
||||
if field.Type.Kind() == reflect.Bool {
|
||||
defaultValue, _ := ParseBool(field.DefaultValue, false)
|
||||
if defaultValue {
|
||||
stringBuilder.WriteString("✔ ")
|
||||
} else {
|
||||
stringBuilder.WriteString("❌ ")
|
||||
}
|
||||
}
|
||||
|
||||
stringBuilder.WriteRune('`')
|
||||
stringBuilder.WriteString(field.DefaultValue)
|
||||
stringBuilder.WriteRune('`')
|
||||
}
|
||||
|
||||
stringBuilder.WriteString(" \n")
|
||||
}
|
||||
}
|
||||
|
||||
func (r MarkdownTreeRenderer) writeHeader(stringBuilder *strings.Builder, text string) {
|
||||
stringBuilder.WriteString(r.HeaderPrefix)
|
||||
stringBuilder.WriteString(text)
|
||||
stringBuilder.WriteString("\n\n")
|
||||
}
|
149
pkg/format/render_markdown_test.go
Normal file
149
pkg/format/render_markdown_test.go
Normal file
|
@ -0,0 +1,149 @@
|
|||
package format
|
||||
|
||||
import (
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
"github.com/onsi/gomega/format"
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("RenderMarkdown", func() {
|
||||
format.CharactersAroundMismatchToInclude = 10
|
||||
|
||||
ginkgo.It("should render the expected output based on config reflection/tags", func() {
|
||||
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct {
|
||||
Name string `default:"notempty"`
|
||||
Host string `url:"host"`
|
||||
}{})
|
||||
|
||||
expected := `
|
||||
### URL Fields
|
||||
|
||||
* __Host__ (**Required**)
|
||||
URL part: <code class="service-url">mock://<strong>host</strong>/</code>
|
||||
### Query/Param Props
|
||||
|
||||
|
||||
* __Name__
|
||||
Default: `[1:] + "`notempty`" + `
|
||||
|
||||
`
|
||||
|
||||
gomega.Expect(actual).To(gomega.Equal(expected))
|
||||
})
|
||||
|
||||
ginkgo.It("should render url paths in sorted order", func() {
|
||||
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct {
|
||||
Host string `url:"host"`
|
||||
Path1 string `url:"path1"`
|
||||
Path3 string `url:"path3"`
|
||||
Path2 string `url:"path2"`
|
||||
}{})
|
||||
|
||||
expected := `
|
||||
### URL Fields
|
||||
|
||||
* __Host__ (**Required**)
|
||||
URL part: <code class="service-url">mock://<strong>host</strong>/path1/path2/path3</code>
|
||||
* __Path1__ (**Required**)
|
||||
URL part: <code class="service-url">mock://host/<strong>path1</strong>/path2/path3</code>
|
||||
* __Path2__ (**Required**)
|
||||
URL part: <code class="service-url">mock://host/path1/<strong>path2</strong>/path3</code>
|
||||
* __Path3__ (**Required**)
|
||||
URL part: <code class="service-url">mock://host/path1/path2/<strong>path3</strong></code>
|
||||
### Query/Param Props
|
||||
|
||||
|
||||
`[1:] // Remove initial newline
|
||||
|
||||
gomega.Expect(actual).To(gomega.Equal(expected))
|
||||
})
|
||||
|
||||
ginkgo.It("should render prop aliases", func() {
|
||||
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct {
|
||||
Name string `key:"name,handle,title,target"`
|
||||
}{})
|
||||
|
||||
expected := `
|
||||
### URL Fields
|
||||
|
||||
### Query/Param Props
|
||||
|
||||
|
||||
* __Name__ (**Required**)
|
||||
Aliases: `[1:] + "`handle`, `title`, `target`" + `
|
||||
|
||||
`
|
||||
|
||||
gomega.Expect(actual).To(gomega.Equal(expected))
|
||||
})
|
||||
|
||||
ginkgo.It("should render possible enum values", func() {
|
||||
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &testEnummer{})
|
||||
|
||||
expected := `
|
||||
### URL Fields
|
||||
|
||||
### Query/Param Props
|
||||
|
||||
|
||||
* __Choice__
|
||||
Default: `[1:] + "`Maybe`" + `
|
||||
Possible values: ` + "`Yes`, `No`, `Maybe`" + `
|
||||
|
||||
`
|
||||
|
||||
gomega.Expect(actual).To(gomega.Equal(expected))
|
||||
})
|
||||
|
||||
ginkgo.When("there are no query props", func() {
|
||||
ginkgo.It("should prepend an empty-message instead of props description", func() {
|
||||
actual := testRenderTree(MarkdownTreeRenderer{
|
||||
HeaderPrefix: `### `,
|
||||
PropsDescription: "Feel free to set these:",
|
||||
PropsEmptyMessage: "There is nothing to set!",
|
||||
}, &struct {
|
||||
Host string `url:"host"`
|
||||
}{})
|
||||
|
||||
expected := `
|
||||
### URL Fields
|
||||
|
||||
* __Host__ (**Required**)
|
||||
URL part: <code class="service-url">mock://<strong>host</strong>/</code>
|
||||
### Query/Param Props
|
||||
|
||||
There is nothing to set!
|
||||
`[1:] // Remove initial newline
|
||||
|
||||
gomega.Expect(actual).To(gomega.Equal(expected))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("there are query props", func() {
|
||||
ginkgo.It("should prepend the props description", func() {
|
||||
actual := testRenderTree(MarkdownTreeRenderer{
|
||||
HeaderPrefix: `### `,
|
||||
PropsDescription: "Feel free to set these:",
|
||||
PropsEmptyMessage: "There is nothing to set!",
|
||||
}, &struct {
|
||||
Host string `url:"host"`
|
||||
CoolMode bool `key:"coolmode" optional:""`
|
||||
}{})
|
||||
|
||||
expected := `
|
||||
### URL Fields
|
||||
|
||||
* __Host__ (**Required**)
|
||||
URL part: <code class="service-url">mock://<strong>host</strong>/</code>
|
||||
### Query/Param Props
|
||||
|
||||
Feel free to set these:
|
||||
* __CoolMode__
|
||||
Default: *empty*
|
||||
|
||||
`[1:] // Remove initial newline
|
||||
|
||||
gomega.Expect(actual).To(gomega.Equal(expected))
|
||||
})
|
||||
})
|
||||
})
|
17
pkg/format/render_test.go
Normal file
17
pkg/format/render_test.go
Normal file
|
@ -0,0 +1,17 @@
|
|||
package format
|
||||
|
||||
import t "github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
|
||||
type testEnummer struct {
|
||||
Choice int `default:"Maybe" key:"choice"`
|
||||
}
|
||||
|
||||
func (testEnummer) Enums() map[string]t.EnumFormatter {
|
||||
return map[string]t.EnumFormatter{
|
||||
"Choice": CreateEnumFormatter([]string{"Yes", "No", "Maybe"}),
|
||||
}
|
||||
}
|
||||
|
||||
func testRenderTree(r TreeRenderer, v any) string {
|
||||
return r.RenderTree(getRootNode(v), "mock")
|
||||
}
|
6
pkg/format/tree_renderer.go
Normal file
6
pkg/format/tree_renderer.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package format
|
||||
|
||||
// TreeRenderer renders a ContainerNode tree into a string.
|
||||
type TreeRenderer interface {
|
||||
RenderTree(root *ContainerNode, scheme string) string
|
||||
}
|
84
pkg/format/urlpart.go
Normal file
84
pkg/format/urlpart.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
//go:generate stringer -type=URLPart -trimprefix URL
|
||||
|
||||
package format
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// URLPart is an indicator as to what part of an URL a field is serialized to.
|
||||
type URLPart int
|
||||
|
||||
// Suffix returns the separator between the URLPart and its subsequent part.
|
||||
func (u URLPart) Suffix() rune {
|
||||
switch u {
|
||||
case URLQuery:
|
||||
return '/'
|
||||
case URLUser:
|
||||
return ':'
|
||||
case URLPassword:
|
||||
return '@'
|
||||
case URLHost:
|
||||
return ':'
|
||||
case URLPort:
|
||||
return '/'
|
||||
case URLPath:
|
||||
return '/'
|
||||
default:
|
||||
return '/'
|
||||
}
|
||||
}
|
||||
|
||||
// indicator as to what part of an URL a field is serialized to.
|
||||
const (
|
||||
URLQuery URLPart = iota
|
||||
URLUser
|
||||
URLPassword
|
||||
URLHost
|
||||
URLPort
|
||||
URLPath // Base path; additional paths are URLPath + N
|
||||
)
|
||||
|
||||
// ParseURLPart returns the URLPart that matches the supplied string.
|
||||
func ParseURLPart(inputString string) URLPart {
|
||||
lowerString := strings.ToLower(inputString)
|
||||
switch lowerString {
|
||||
case "user":
|
||||
return URLUser
|
||||
case "pass", "password":
|
||||
return URLPassword
|
||||
case "host":
|
||||
return URLHost
|
||||
case "port":
|
||||
return URLPort
|
||||
case "path", "path1":
|
||||
return URLPath
|
||||
case "query", "":
|
||||
return URLQuery
|
||||
}
|
||||
|
||||
// Handle dynamic path segments (e.g., "path2", "path3", etc.).
|
||||
if strings.HasPrefix(lowerString, "path") && len(lowerString) > 4 {
|
||||
if num, err := strconv.Atoi(lowerString[4:]); err == nil && num >= 2 {
|
||||
return URLPath + URLPart(num-1) // Offset from URLPath; "path2" -> URLPath+1
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("invalid URLPart: %s, defaulting to URLQuery", lowerString)
|
||||
|
||||
return URLQuery
|
||||
}
|
||||
|
||||
// ParseURLParts returns the URLParts that matches the supplied string.
|
||||
func ParseURLParts(s string) []URLPart {
|
||||
rawParts := strings.Split(s, ",")
|
||||
urlParts := make([]URLPart, len(rawParts))
|
||||
|
||||
for i, raw := range rawParts {
|
||||
urlParts[i] = ParseURLPart(raw)
|
||||
}
|
||||
|
||||
return urlParts
|
||||
}
|
28
pkg/format/urlpart_string.go
Normal file
28
pkg/format/urlpart_string.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
// Code generated by "stringer -type=URLPart -trimprefix URL"; DO NOT EDIT.
|
||||
|
||||
package format
|
||||
|
||||
import "strconv"
|
||||
|
||||
func _() {
|
||||
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||
// Re-run the stringer command to generate them again.
|
||||
var x [1]struct{}
|
||||
_ = x[URLQuery-0]
|
||||
_ = x[URLUser-1]
|
||||
_ = x[URLPassword-2]
|
||||
_ = x[URLHost-3]
|
||||
_ = x[URLPort-4]
|
||||
_ = x[URLPath-5]
|
||||
}
|
||||
|
||||
const _URLPart_name = "QueryUserPasswordHostPortPath"
|
||||
|
||||
var _URLPart_index = [...]uint8{0, 5, 9, 17, 21, 25, 29}
|
||||
|
||||
func (i URLPart) String() string {
|
||||
if i < 0 || i >= URLPart(len(_URLPart_index)-1) {
|
||||
return "URLPart(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||
}
|
||||
return _URLPart_name[_URLPart_index[i]:_URLPart_index[i+1]]
|
||||
}
|
32
pkg/format/urlpart_test.go
Normal file
32
pkg/format/urlpart_test.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
package format_test
|
||||
|
||||
import (
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/format"
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("URLPart", func() {
|
||||
ginkgo.It("should return the expected URL part for each lookup key", func() {
|
||||
gomega.Expect(format.ParseURLPart("user")).To(gomega.Equal(format.URLUser))
|
||||
gomega.Expect(format.ParseURLPart("pass")).To(gomega.Equal(format.URLPassword))
|
||||
gomega.Expect(format.ParseURLPart("password")).To(gomega.Equal(format.URLPassword))
|
||||
gomega.Expect(format.ParseURLPart("host")).To(gomega.Equal(format.URLHost))
|
||||
gomega.Expect(format.ParseURLPart("port")).To(gomega.Equal(format.URLPort))
|
||||
gomega.Expect(format.ParseURLPart("path")).To(gomega.Equal(format.URLPath))
|
||||
gomega.Expect(format.ParseURLPart("path1")).To(gomega.Equal(format.URLPath))
|
||||
gomega.Expect(format.ParseURLPart("path2")).To(gomega.Equal(format.URLPath + 1))
|
||||
gomega.Expect(format.ParseURLPart("path3")).To(gomega.Equal(format.URLPath + 2))
|
||||
gomega.Expect(format.ParseURLPart("path4")).To(gomega.Equal(format.URLPath + 3))
|
||||
gomega.Expect(format.ParseURLPart("query")).To(gomega.Equal(format.URLQuery))
|
||||
gomega.Expect(format.ParseURLPart("")).To(gomega.Equal(format.URLQuery))
|
||||
})
|
||||
ginkgo.It("should return the expected suffix for each URL part", func() {
|
||||
gomega.Expect(format.URLUser.Suffix()).To(gomega.Equal(':'))
|
||||
gomega.Expect(format.URLPassword.Suffix()).To(gomega.Equal('@'))
|
||||
gomega.Expect(format.URLHost.Suffix()).To(gomega.Equal(':'))
|
||||
gomega.Expect(format.URLPort.Suffix()).To(gomega.Equal('/'))
|
||||
gomega.Expect(format.URLPath.Suffix()).To(gomega.Equal('/'))
|
||||
})
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue