201 lines
7.3 KiB
Go
201 lines
7.3 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
)
|
|
|
|
// ConfigField is the manifest key a module declares its config values under.
|
|
const ConfigField = "config"
|
|
|
|
// TakesConfigField is the key a mergeable file names the config values it takes by.
|
|
const TakesConfigField = "takes-config"
|
|
|
|
// SettingsField is the old name of ConfigField, read as its alias.
|
|
const SettingsField = "settings"
|
|
|
|
// configRef is how a file asks for a config value: ${config:<key>}, the same keys as settingRef.
|
|
var configRef = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`)
|
|
|
|
// takesConfigKey and takesSettingsKey are the two spellings as a key of a JSON object. A string value is never
|
|
// followed by a colon, and a quote inside a string is escaped, so these match only keys.
|
|
var (
|
|
takesConfigKey = regexp.MustCompile(`"` + TakesConfigField + `"(\s*):`)
|
|
takesSettingsKey = regexp.MustCompile(`"` + TakesSettingsField + `"(\s*):`)
|
|
)
|
|
|
|
// The types a config value may have (ADR 0348 §4).
|
|
const (
|
|
ConfigNumber = "number"
|
|
ConfigString = "string"
|
|
ConfigBoolean = "boolean"
|
|
ConfigObject = "object"
|
|
ConfigList = "list"
|
|
)
|
|
|
|
// accountFacts are what the mesh places for a module's account, never config values (ADR 0348 §11).
|
|
var accountFacts = map[string]bool{"puid": true, "pgid": true}
|
|
|
|
// configForms is which of the two forms a manifest was written in, by the words of each it uses.
|
|
type configForms struct {
|
|
config, settings []string
|
|
}
|
|
|
|
// DeclaresConfig is whether the manifest is written in the config form: `config`, `takes-config` or
|
|
// `${config:<key>}` (ADR 0348 §1, §11).
|
|
func (m Manifest) DeclaresConfig() bool { return len(m.forms.config) > 0 }
|
|
|
|
// toSettingsForm is raw with the config form's placeholders and takes-config read as the settings form's, and
|
|
// which words of each form it used. `config` itself is a top-level key, renamed where the keys are read
|
|
// (UnmarshalJSON).
|
|
func toSettingsForm(raw []byte) ([]byte, configForms) {
|
|
var f configForms
|
|
if configRef.Match(raw) {
|
|
f.config = append(f.config, "${config:…}")
|
|
}
|
|
if takesConfigKey.Match(raw) {
|
|
f.config = append(f.config, TakesConfigField)
|
|
}
|
|
if settingRef.Match(raw) {
|
|
f.settings = append(f.settings, "${setting:…}")
|
|
}
|
|
if takesSettingsKey.Match(raw) {
|
|
f.settings = append(f.settings, TakesSettingsField)
|
|
}
|
|
if len(f.config) == 0 {
|
|
return raw, f
|
|
}
|
|
raw = configRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
|
|
return []byte("${setting:" + string(configRef.FindSubmatch(ref)[1]) + "}")
|
|
})
|
|
raw = takesConfigKey.ReplaceAllFunc(raw, func(key []byte) []byte {
|
|
return []byte(`"` + TakesSettingsField + `"` + string(takesConfigKey.FindSubmatch(key)[1]) + ":")
|
|
})
|
|
return raw, f
|
|
}
|
|
|
|
// toConfigForm is a manifest written in the settings form's words, as JSON, in the config form's: what
|
|
// toSettingsForm and UnmarshalJSON read, written back.
|
|
func toConfigForm(raw []byte) ([]byte, error) {
|
|
var keys map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &keys); err != nil {
|
|
return nil, err
|
|
}
|
|
if settings, has := keys[SettingsField]; has {
|
|
keys[ConfigField] = settings
|
|
delete(keys, SettingsField)
|
|
var err error
|
|
if raw, err = json.Marshal(keys); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
raw = settingRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
|
|
return []byte("${config:" + string(settingRef.FindSubmatch(ref)[1]) + "}")
|
|
})
|
|
raw = takesSettingsKey.ReplaceAllFunc(raw, func(key []byte) []byte {
|
|
return []byte(`"` + TakesConfigField + `"` + string(takesSettingsKey.FindSubmatch(key)[1]) + ":")
|
|
})
|
|
return raw, nil
|
|
}
|
|
|
|
// configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing
|
|
// unsaid.
|
|
type configDeclaration struct {
|
|
Type string `json:"type,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Enum []any `json:"enum,omitempty"`
|
|
Minimum *float64 `json:"minimum,omitempty"`
|
|
Maximum *float64 `json:"maximum,omitempty"`
|
|
Pattern string `json:"pattern,omitempty"`
|
|
Trusted bool `json:"trusted,omitempty"`
|
|
Predefined string `json:"predefined,omitempty"`
|
|
Default any `json:"default,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
Runtime bool `json:"runtime,omitempty"`
|
|
}
|
|
|
|
// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings
|
|
// form's kind and why, and without a default it does not have.
|
|
func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
|
|
type plain SettingDeclaration
|
|
if d.Kind != "" || d.Why != "" {
|
|
return json.Marshal(plain(d))
|
|
}
|
|
return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum,
|
|
Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined,
|
|
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
|
|
}
|
|
|
|
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
|
func validationProblems(d SettingDeclaration) []string {
|
|
var problems []string
|
|
isNumber := d.Type == ConfigNumber
|
|
if len(d.Enum) > 0 && d.Type != ConfigNumber && d.Type != ConfigString {
|
|
problems = append(problems, fmt.Sprintf("an enum is for a number or a string, and this is a %s", d.Type))
|
|
}
|
|
if (d.Minimum != nil || d.Maximum != nil) && !isNumber {
|
|
problems = append(problems, fmt.Sprintf("a minimum or maximum is for a number, and this is a %s", d.Type))
|
|
}
|
|
if d.Minimum != nil && d.Maximum != nil && *d.Minimum > *d.Maximum {
|
|
problems = append(problems, fmt.Sprintf("its minimum %v is above its maximum %v", *d.Minimum, *d.Maximum))
|
|
}
|
|
var pattern *regexp.Regexp
|
|
if d.Pattern != "" {
|
|
if d.Type != ConfigString {
|
|
problems = append(problems, fmt.Sprintf("a pattern is for a string, and this is a %s", d.Type))
|
|
}
|
|
var err error
|
|
if pattern, err = regexp.Compile(`^(?:` + d.Pattern + `)$`); err != nil {
|
|
problems = append(problems, fmt.Sprintf("its pattern %q is not a regular expression: %v", d.Pattern, err))
|
|
}
|
|
}
|
|
for _, v := range d.Enum {
|
|
if !ofType(v, d.Type) {
|
|
problems = append(problems, fmt.Sprintf("its enum holds %v, which is not a %s", v, d.Type))
|
|
}
|
|
}
|
|
if d.Default == nil {
|
|
return problems
|
|
}
|
|
if !ofType(d.Default, d.Type) {
|
|
if d.Type != "" {
|
|
problems = append(problems, fmt.Sprintf("its default %v is not a %s", d.Default, d.Type))
|
|
}
|
|
return problems
|
|
}
|
|
if len(d.Enum) > 0 && !slices.ContainsFunc(d.Enum, func(v any) bool { return v == d.Default }) {
|
|
problems = append(problems, fmt.Sprintf("its default %v is not one of %v", d.Default, d.Enum))
|
|
}
|
|
if n, ok := d.Default.(float64); ok {
|
|
if d.Minimum != nil && n < *d.Minimum {
|
|
problems = append(problems, fmt.Sprintf("its default %v is below its minimum %v", n, *d.Minimum))
|
|
}
|
|
if d.Maximum != nil && n > *d.Maximum {
|
|
problems = append(problems, fmt.Sprintf("its default %v is above its maximum %v", n, *d.Maximum))
|
|
}
|
|
}
|
|
if s, ok := d.Default.(string); ok && pattern != nil && !pattern.MatchString(s) {
|
|
problems = append(problems, fmt.Sprintf("its default %q does not match its pattern %q", s, d.Pattern))
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// ofType is whether a value read from JSON is of a config type.
|
|
func ofType(v any, kind string) bool {
|
|
switch v.(type) {
|
|
case float64:
|
|
return kind == ConfigNumber
|
|
case string:
|
|
return kind == ConfigString
|
|
case bool:
|
|
return kind == ConfigBoolean
|
|
case map[string]any:
|
|
return kind == ConfigObject
|
|
case []any:
|
|
return kind == ConfigList
|
|
}
|
|
return false
|
|
}
|