- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
745 lines
22 KiB
Go
745 lines
22 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"context"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"git.golem15.com/golem15/summercms/modules/towel"
|
|
"github.com/goccy/go-yaml"
|
|
"github.com/goccy/go-yaml/ast"
|
|
)
|
|
|
|
var (
|
|
formFieldTypes = map[string]struct{}{
|
|
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
|
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
|
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
|
}
|
|
formSpans = map[string]struct{}{
|
|
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
|
}
|
|
formSizes = map[string]struct{}{
|
|
"tiny": {}, "small": {}, "large": {}, "huge": {}, "giant": {},
|
|
}
|
|
formFieldKeys = map[string]struct{}{
|
|
"label": {}, "comment": {}, "span": {}, "type": {}, "required": {},
|
|
"tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {},
|
|
"nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {},
|
|
"widget": {}, "action": {}, "fill": {}, "path": {},
|
|
"mode": {}, "fileTypes": {}, "mimeTypes": {}, "maxFilesize": {},
|
|
"maxFiles": {}, "imageWidth": {}, "imageHeight": {},
|
|
"thumbOptions": {}, "useCaption": {}, "prompt": {},
|
|
"format": {}, "minDate": {}, "maxDate": {}, "yearRange": {},
|
|
"firstDay": {}, "twelveHour": {}, "ignoreTimezone": {},
|
|
}
|
|
// widgetKeys are valid only on `type: widget` (D-06).
|
|
widgetKeys = []string{"widget", "action", "fill"}
|
|
)
|
|
|
|
type formConfigDocument struct {
|
|
Name string `yaml:"name"`
|
|
Form string `yaml:"form"`
|
|
ModelClass string `yaml:"modelClass"`
|
|
DefaultRedirect string `yaml:"defaultRedirect"`
|
|
Create *formRedirects `yaml:"create"`
|
|
Update *formRedirects `yaml:"update"`
|
|
Messages *formMessageKeys `yaml:"messages"`
|
|
}
|
|
|
|
type formRedirects struct {
|
|
Redirect string `yaml:"redirect"`
|
|
RedirectClose string `yaml:"redirectClose"`
|
|
}
|
|
|
|
type formFieldsFile struct {
|
|
Fields fieldMap `yaml:"fields"`
|
|
}
|
|
|
|
type fieldMap struct {
|
|
items []FormField
|
|
}
|
|
|
|
// CompileForm compiles config_form.yaml and the model fields.yaml it names.
|
|
// The returned schema is safe to cache: labels stay as source keys.
|
|
func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
|
|
if ctl == nil {
|
|
return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID)
|
|
}
|
|
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
|
|
if dir == "." || strings.HasPrefix(dir, "..") {
|
|
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
|
|
}
|
|
cfgPath := dir + "/config_form.yaml"
|
|
raw, err := readAsset(fsys, cfgPath)
|
|
if err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
|
}
|
|
var doc formConfigDocument
|
|
if err := decodeStrict(raw, &doc); err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
|
}
|
|
if doc.ModelClass != ctl.ModelName() {
|
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
|
|
}
|
|
if strings.TrimSpace(doc.Form) == "" {
|
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("form file is empty"))
|
|
}
|
|
fieldsPath, err := assetPath(pluginID, doc.Form)
|
|
if err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
|
}
|
|
fieldsRaw, err := readAsset(fsys, fieldsPath)
|
|
if err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
|
}
|
|
fields, err := decodeFields(fieldsRaw)
|
|
if err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
|
}
|
|
if fields == nil {
|
|
fields = []FormField{}
|
|
}
|
|
if err := requireDropdownProvider(ctl, fields); err != nil {
|
|
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
|
}
|
|
schema := &FormSchema{
|
|
Name: doc.Name,
|
|
ModelClass: doc.ModelClass,
|
|
Fields: fields,
|
|
redirects: FormRedirects{Default: doc.DefaultRedirect},
|
|
fieldsPath: fieldsPath,
|
|
}
|
|
if doc.Messages != nil {
|
|
schema.messageKeys = *doc.Messages
|
|
}
|
|
if doc.Create != nil {
|
|
schema.redirects.Create = FormRedirect{Redirect: doc.Create.Redirect, RedirectClose: doc.Create.RedirectClose}
|
|
}
|
|
if doc.Update != nil {
|
|
schema.redirects.Update = FormRedirect{Redirect: doc.Update.Redirect, RedirectClose: doc.Update.RedirectClose}
|
|
}
|
|
return schema, nil
|
|
}
|
|
|
|
// Localize copies the cached schema and resolves display strings for one request.
|
|
// It does not mutate s. Option values keep the scalar captured at boot.
|
|
func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) (*FormView, error) {
|
|
if s == nil {
|
|
return nil, fmt.Errorf("cabana: nil form schema")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
locale := schemaLocale(ctx, tr)
|
|
ctx = towel.WithLocale(ctx, locale)
|
|
fields := make([]FormField, len(s.Fields))
|
|
for i, src := range s.Fields {
|
|
field := src
|
|
field.Label = translateKey(ctx, tr, src.Label)
|
|
field.Comment = translateKey(ctx, tr, src.Comment)
|
|
field.Tab = translateKey(ctx, tr, src.Tab)
|
|
field.EmptyOption = translateKey(ctx, tr, src.EmptyOption)
|
|
field.ActionLabel = translateKey(ctx, tr, src.ActionLabel)
|
|
field.Prompt = translateKey(ctx, tr, src.Prompt)
|
|
field.Fill = append([]string(nil), src.Fill...)
|
|
field.FileTypes = append([]string(nil), src.FileTypes...)
|
|
field.MimeTypes = append([]string(nil), src.MimeTypes...)
|
|
field.YearRange = append([]int(nil), src.YearRange...)
|
|
options, err := localizeOptions(ctx, tr, src, provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
field.Options = options
|
|
fields[i] = field
|
|
}
|
|
if fields == nil {
|
|
fields = []FormField{}
|
|
}
|
|
return &FormView{
|
|
Name: translateKey(ctx, tr, s.Name),
|
|
ModelClass: s.ModelClass,
|
|
Fields: fields,
|
|
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
|
|
Redirects: s.redirects,
|
|
Meta: FormMeta{Locale: locale},
|
|
}, nil
|
|
}
|
|
|
|
// CanonicalJSON is one localized schema response.
|
|
func (v *FormView) CanonicalJSON() ([]byte, error) {
|
|
if v == nil {
|
|
return nil, fmt.Errorf("cabana: nil form view")
|
|
}
|
|
out := *v
|
|
if out.Fields == nil {
|
|
out.Fields = []FormField{}
|
|
}
|
|
return json.Marshal(out)
|
|
}
|
|
|
|
// CanonicalJSON is the stable, locale-neutral form contract.
|
|
func (s *FormSchema) CanonicalJSON() ([]byte, error) {
|
|
if s == nil {
|
|
return nil, fmt.Errorf("cabana: nil form schema")
|
|
}
|
|
out := *s
|
|
if out.Fields == nil {
|
|
out.Fields = []FormField{}
|
|
}
|
|
return json.Marshal(out)
|
|
}
|
|
|
|
func requireDropdownProvider(ctl pact.AdminController, fields []FormField) error {
|
|
var method string
|
|
for _, field := range fields {
|
|
if field.optionsMethod != "" {
|
|
method = field.optionsMethod
|
|
break
|
|
}
|
|
}
|
|
if method == "" || dropdownProvider(ctl) != nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("dropdown method %s requires DropdownOptions", method)
|
|
}
|
|
|
|
func dropdownProvider(ctl pact.AdminController) pact.DropdownOptionsProvider {
|
|
if ctl == nil {
|
|
return nil
|
|
}
|
|
if p, ok := ctl.(pact.DropdownOptionsProvider); ok && p != nil {
|
|
return p
|
|
}
|
|
src, ok := ctl.(pact.AdminRecordSource)
|
|
if !ok || src == nil {
|
|
return nil
|
|
}
|
|
rec := src.NewRecord()
|
|
p, ok := rec.(pact.DropdownOptionsProvider)
|
|
if !ok || p == nil {
|
|
return nil
|
|
}
|
|
return p
|
|
}
|
|
|
|
func schemaLocale(ctx context.Context, tr *phrasebook.Translator) string {
|
|
if loc, ok := towel.Locale(ctx); ok {
|
|
if primary := primaryLanguage(loc); primary != "" {
|
|
return primary
|
|
}
|
|
}
|
|
if tr != nil {
|
|
if loc := strings.TrimSpace(tr.Locale()); loc != "" {
|
|
return loc
|
|
}
|
|
}
|
|
return "en"
|
|
}
|
|
|
|
func primaryLanguage(header string) string {
|
|
header = strings.TrimSpace(header)
|
|
if header == "" {
|
|
return ""
|
|
}
|
|
if i := strings.IndexAny(header, ",;"); i >= 0 {
|
|
header = strings.TrimSpace(header[:i])
|
|
}
|
|
return header
|
|
}
|
|
|
|
func translateKey(ctx context.Context, tr *phrasebook.Translator, key string) string {
|
|
if key == "" || tr == nil {
|
|
return key
|
|
}
|
|
return tr.Get(ctx, key, nil)
|
|
}
|
|
|
|
func localizeOptions(ctx context.Context, tr *phrasebook.Translator, src FormField, provider pact.DropdownOptionsProvider) ([]FormOption, error) {
|
|
if src.optionsMethod != "" {
|
|
if provider == nil {
|
|
return nil, fmt.Errorf("cabana: dropdown method %s requires DropdownOptions", src.optionsMethod)
|
|
}
|
|
provided := provider.DropdownOptions(src.Name)
|
|
opts := make([]FormOption, len(provided))
|
|
for i, opt := range provided {
|
|
raw, err := json.Marshal(opt.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
opts[i] = FormOption{Value: jsonScalar{raw: raw}, Label: translateKey(ctx, tr, opt.Label)}
|
|
}
|
|
return opts, nil
|
|
}
|
|
if len(src.Options) == 0 {
|
|
return nil, nil
|
|
}
|
|
opts := make([]FormOption, len(src.Options))
|
|
for i, opt := range src.Options {
|
|
opt.Label = translateKey(ctx, tr, opt.Label)
|
|
opts[i] = opt
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func decodeFields(raw []byte) ([]FormField, error) {
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
|
var doc formFieldsFile
|
|
if err := dec.Decode(&doc); err != nil {
|
|
return nil, normalizeYAMLError(err)
|
|
}
|
|
if doc.Fields.items == nil {
|
|
return []FormField{}, nil
|
|
}
|
|
return doc.Fields.items, nil
|
|
}
|
|
|
|
func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
|
|
items, err := decodeFieldMapping(node, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.items = items
|
|
return nil
|
|
}
|
|
|
|
// decodeFieldMapping compiles a fields mapping in source order. rename, when
|
|
// set, maps a raw field name (such as WinterCMS's pivot[role]) to the name
|
|
// that must be an identifier.
|
|
func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField, error) {
|
|
node = unwrapNode(node)
|
|
if _, ok := node.(*ast.NullNode); ok || node == nil {
|
|
return []FormField{}, nil
|
|
}
|
|
mapping, ok := node.(*ast.MappingNode)
|
|
if !ok {
|
|
return nil, fmt.Errorf("fields must be a mapping")
|
|
}
|
|
items := make([]FormField, 0, len(mapping.Values))
|
|
seen := map[string]struct{}{}
|
|
for _, entry := range mapping.Values {
|
|
name, err := nodeString(unwrapNode(entry.Key))
|
|
if err == nil && rename != nil {
|
|
name = rename(name)
|
|
}
|
|
if err != nil || !identifier(name) {
|
|
return nil, fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key))
|
|
}
|
|
if _, dup := seen[name]; dup {
|
|
return nil, fmt.Errorf("duplicate field %s", name)
|
|
}
|
|
seen[name] = struct{}{}
|
|
field, err := compileFieldNode(name, unwrapNode(entry.Value))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("field %s: %w", name, err)
|
|
}
|
|
items = append(items, field)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func compileFieldNode(name string, node ast.Node) (FormField, error) {
|
|
mapping, ok := node.(*ast.MappingNode)
|
|
if !ok {
|
|
return FormField{}, fmt.Errorf("must be a mapping")
|
|
}
|
|
values := map[string]ast.Node{}
|
|
for _, entry := range mapping.Values {
|
|
key, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
return FormField{}, err
|
|
}
|
|
if _, known := formFieldKeys[key]; !known {
|
|
return FormField{}, fmt.Errorf("unknown field %s", key)
|
|
}
|
|
if _, dup := values[key]; dup {
|
|
return FormField{}, fmt.Errorf("duplicate field %s", key)
|
|
}
|
|
values[key] = unwrapNode(entry.Value)
|
|
}
|
|
typ, err := nodeString(values["type"])
|
|
if err != nil || typ == "" {
|
|
return FormField{}, fmt.Errorf("type is required")
|
|
}
|
|
if _, ok := formFieldTypes[typ]; !ok {
|
|
return FormField{}, fmt.Errorf("unsupported type %s", typ)
|
|
}
|
|
field := FormField{Name: name, Type: typ}
|
|
if node, ok := values["label"]; ok {
|
|
field.Label, err = nodeString(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("label: %w", err)
|
|
}
|
|
}
|
|
if node, ok := values["comment"]; ok {
|
|
field.Comment, err = nodeString(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("comment: %w", err)
|
|
}
|
|
}
|
|
if node, ok := values["span"]; ok {
|
|
field.Span, err = nodeString(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("span: %w", err)
|
|
}
|
|
if _, ok := formSpans[field.Span]; !ok {
|
|
return FormField{}, fmt.Errorf("unsupported span %s", field.Span)
|
|
}
|
|
}
|
|
if node, ok := values["tab"]; ok {
|
|
field.Tab, err = nodeString(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("tab: %w", err)
|
|
}
|
|
}
|
|
if node, ok := values["size"]; ok {
|
|
field.Size, err = nodeString(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("size: %w", err)
|
|
}
|
|
if _, ok := formSizes[field.Size]; !ok {
|
|
return FormField{}, fmt.Errorf("unsupported size %s", field.Size)
|
|
}
|
|
}
|
|
if node, ok := values["context"]; ok {
|
|
ctx, err := compileContext(node)
|
|
if err != nil {
|
|
return FormField{}, err
|
|
}
|
|
field.Context = ctx
|
|
}
|
|
if node, ok := values["nameFrom"]; ok {
|
|
field.NameFrom, err = nodeString(node)
|
|
if err != nil || !identifier(field.NameFrom) {
|
|
return FormField{}, fmt.Errorf("nameFrom %q is not an identifier", field.NameFrom)
|
|
}
|
|
}
|
|
if node, ok := values["emptyOption"]; ok {
|
|
field.EmptyOption, err = nodeString(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("emptyOption: %w", err)
|
|
}
|
|
}
|
|
if node, ok := values["relation"]; ok {
|
|
field.Relation, err = nodeString(node)
|
|
if err != nil || !identifier(field.Relation) {
|
|
return FormField{}, fmt.Errorf("relation %q is not an identifier", field.Relation)
|
|
}
|
|
}
|
|
if typ == "relation-manager" && field.Relation == "" {
|
|
return FormField{}, fmt.Errorf("relation is required")
|
|
}
|
|
if err := compileWidgetKeys(typ, values, &field); err != nil {
|
|
return FormField{}, err
|
|
}
|
|
if err := compilePartialPath(typ, values, &field); err != nil {
|
|
return FormField{}, err
|
|
}
|
|
if err := compileFileuploadKeys(typ, values, &field); err != nil {
|
|
return FormField{}, err
|
|
}
|
|
if err := compileDatepickerKeys(typ, values, &field); err != nil {
|
|
return FormField{}, err
|
|
}
|
|
if node, ok := values["required"]; ok {
|
|
field.Required, err = nodeBool(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("required: %w", err)
|
|
}
|
|
}
|
|
if node, ok := values["default"]; ok {
|
|
scalar, err := nodeScalar(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("default: %w", err)
|
|
}
|
|
field.Default = &scalar
|
|
}
|
|
if node, ok := values["attributes"]; ok {
|
|
attrs, err := compileAttributes(node)
|
|
if err != nil {
|
|
return FormField{}, fmt.Errorf("attributes: %w", err)
|
|
}
|
|
field.Attributes = attrs
|
|
}
|
|
if node, ok := values["options"]; ok {
|
|
if err := compileOptions(node, &field); err != nil {
|
|
return FormField{}, fmt.Errorf("options: %w", err)
|
|
}
|
|
}
|
|
return field, nil
|
|
}
|
|
|
|
// compileWidgetKeys decodes the D-06 widget keys. They are refused on any
|
|
// other type; a widget needs a custom-element tag and an action name, and may
|
|
// list the fields its action writes back. The tag prefix, the registered
|
|
// action and the fill fields are checked against the controller in
|
|
// compileExtension.
|
|
func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) error {
|
|
if typ != "widget" {
|
|
for _, key := range widgetKeys {
|
|
if _, ok := values[key]; ok {
|
|
return fmt.Errorf("%s is only valid on type: widget", key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
tag, err := nodeString(values["widget"])
|
|
if err != nil || strings.TrimSpace(tag) == "" {
|
|
return fmt.Errorf("widget (the custom-element tag) is required on type: widget")
|
|
}
|
|
field.Widget = tag
|
|
action, err := nodeString(values["action"])
|
|
if err != nil || !identifier(action) {
|
|
return fmt.Errorf("action %q is not an identifier (type: widget needs a registered action)", nodeText(values["action"]))
|
|
}
|
|
field.Action = action
|
|
if node, ok := values["fill"]; ok {
|
|
seq, ok := node.(*ast.SequenceNode)
|
|
if !ok {
|
|
return fmt.Errorf("fill must be a list of field names")
|
|
}
|
|
items := sequenceValues(seq)
|
|
fill := make([]string, 0, len(items))
|
|
seen := map[string]struct{}{}
|
|
for _, item := range items {
|
|
name, err := nodeString(unwrapNode(item))
|
|
if err != nil || !identifier(name) {
|
|
return fmt.Errorf("fill %q is not a field name", nodeText(item))
|
|
}
|
|
if _, dup := seen[name]; dup {
|
|
return fmt.Errorf("fill: duplicate field %s", name)
|
|
}
|
|
seen[name] = struct{}{}
|
|
fill = append(fill, name)
|
|
}
|
|
field.Fill = fill
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// partialPathHint is the D-11 path rule shared by every partial path error.
|
|
const partialPathHint = "path must be a partial name such as summary (resolves to CONFIG_DIR/_summary.htm); Winter $/ and ~/ paths are not supported"
|
|
|
|
// compilePartialPath decodes the path of a `type: partial` field (D-09). It
|
|
// is refused on any other type and must be a bare partial name, so no
|
|
// free-form path ever reaches the plugin's file tree. The template itself is
|
|
// read and parsed in compileExtension.
|
|
func compilePartialPath(typ string, values map[string]ast.Node, field *FormField) error {
|
|
node, ok := values["path"]
|
|
if typ != "partial" {
|
|
if ok {
|
|
return fmt.Errorf("path is only valid on type: partial")
|
|
}
|
|
return nil
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("type partial needs a path: %s", partialPathHint)
|
|
}
|
|
name, err := nodeString(node)
|
|
if err != nil || !identifier(name) {
|
|
return fmt.Errorf("partial %q: %s", nodeText(node), partialPathHint)
|
|
}
|
|
field.Path = name
|
|
return nil
|
|
}
|
|
|
|
func compileContext(node ast.Node) (*fieldContext, error) {
|
|
switch n := node.(type) {
|
|
case *ast.StringNode:
|
|
if !identifier(n.Value) {
|
|
return nil, fmt.Errorf("context %q is not an identifier", n.Value)
|
|
}
|
|
return &fieldContext{single: true, values: []string{n.Value}}, nil
|
|
case *ast.SequenceNode:
|
|
values := sequenceValues(n)
|
|
out := make([]string, 0, len(values))
|
|
for _, item := range values {
|
|
text, err := nodeString(unwrapNode(item))
|
|
if err != nil || !identifier(text) {
|
|
return nil, fmt.Errorf("context %q is not an identifier", nodeText(item))
|
|
}
|
|
out = append(out, text)
|
|
}
|
|
return &fieldContext{values: out}, nil
|
|
default:
|
|
return nil, fmt.Errorf("context must be a string or a list")
|
|
}
|
|
}
|
|
|
|
func compileAttributes(node ast.Node) (map[string]jsonScalar, error) {
|
|
mapping, ok := node.(*ast.MappingNode)
|
|
if !ok {
|
|
return nil, fmt.Errorf("must be a mapping")
|
|
}
|
|
out := make(map[string]jsonScalar, len(mapping.Values))
|
|
for _, entry := range mapping.Values {
|
|
key, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil || !identifier(key) {
|
|
return nil, fmt.Errorf("key %q is not an identifier", nodeText(entry.Key))
|
|
}
|
|
scalar, err := nodeScalar(unwrapNode(entry.Value))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
out[key] = scalar
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func compileOptions(node ast.Node, field *FormField) error {
|
|
switch n := node.(type) {
|
|
case *ast.StringNode:
|
|
if !identifier(n.Value) {
|
|
return fmt.Errorf("method %q is not an identifier", n.Value)
|
|
}
|
|
field.optionsMethod = n.Value
|
|
return nil
|
|
case *ast.MappingNode:
|
|
options := make([]FormOption, 0, len(n.Values))
|
|
for _, entry := range n.Values {
|
|
value, err := nodeScalar(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
return fmt.Errorf("value: %w", err)
|
|
}
|
|
label, err := nodeString(unwrapNode(entry.Value))
|
|
if err != nil {
|
|
return fmt.Errorf("label: %w", err)
|
|
}
|
|
options = append(options, FormOption{Value: value, Label: label})
|
|
}
|
|
field.Options = options
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("must be a map or a method name")
|
|
}
|
|
}
|
|
|
|
func sequenceValues(n *ast.SequenceNode) []ast.Node {
|
|
if len(n.Values) > 0 {
|
|
return n.Values
|
|
}
|
|
out := make([]ast.Node, 0, len(n.Entries))
|
|
for _, entry := range n.Entries {
|
|
if entry != nil && entry.Value != nil {
|
|
out = append(out, entry.Value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func unwrapNode(node ast.Node) ast.Node {
|
|
for node != nil {
|
|
switch n := node.(type) {
|
|
case *ast.MappingKeyNode:
|
|
node = n.Value
|
|
case *ast.TagNode:
|
|
node = n.Value
|
|
case *ast.AnchorNode:
|
|
node = n.Value
|
|
default:
|
|
return node
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nodeString(node ast.Node) (string, error) {
|
|
switch n := unwrapNode(node).(type) {
|
|
case *ast.StringNode:
|
|
return n.Value, nil
|
|
case nil:
|
|
return "", fmt.Errorf("missing string")
|
|
default:
|
|
return "", fmt.Errorf("want a string, got %T", n)
|
|
}
|
|
}
|
|
|
|
func nodeBool(node ast.Node) (bool, error) {
|
|
n, ok := unwrapNode(node).(*ast.BoolNode)
|
|
if !ok {
|
|
return false, fmt.Errorf("want a boolean")
|
|
}
|
|
return n.Value, nil
|
|
}
|
|
|
|
func nodeScalar(node ast.Node) (jsonScalar, error) {
|
|
switch n := unwrapNode(node).(type) {
|
|
case *ast.StringNode:
|
|
raw, err := json.Marshal(n.Value)
|
|
return jsonScalar{raw: raw}, err
|
|
case *ast.BoolNode:
|
|
if n.Value {
|
|
return jsonScalar{raw: []byte("true")}, nil
|
|
}
|
|
return jsonScalar{raw: []byte("false")}, nil
|
|
case *ast.IntegerNode:
|
|
return integerScalar(n.Value)
|
|
case *ast.FloatNode:
|
|
if n.Value == float64(int64(n.Value)) {
|
|
return jsonScalar{raw: []byte(strconv.FormatInt(int64(n.Value), 10))}, nil
|
|
}
|
|
raw, err := json.Marshal(n.Value)
|
|
return jsonScalar{raw: raw}, err
|
|
default:
|
|
return jsonScalar{}, fmt.Errorf("want a scalar, got %T", n)
|
|
}
|
|
}
|
|
|
|
func integerScalar(v any) (jsonScalar, error) {
|
|
switch n := v.(type) {
|
|
case int64:
|
|
return jsonScalar{raw: []byte(strconv.FormatInt(n, 10))}, nil
|
|
case uint64:
|
|
return jsonScalar{raw: []byte(strconv.FormatUint(n, 10))}, nil
|
|
case int:
|
|
return jsonScalar{raw: []byte(strconv.Itoa(n))}, nil
|
|
default:
|
|
return jsonScalar{}, fmt.Errorf("want an integer, got %T", v)
|
|
}
|
|
}
|
|
|
|
func nodeText(node ast.Node) string {
|
|
if node == nil {
|
|
return ""
|
|
}
|
|
if token := node.GetToken(); token != nil {
|
|
return token.Value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeYAMLError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "already defined") {
|
|
return fmt.Errorf("duplicate field: %w", err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
|
|
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
|
|
if dir == "." || strings.HasPrefix(dir, "..") {
|
|
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
|
|
}
|
|
cfgPath := path.Join(dir, "config_form.yaml")
|
|
if _, err := fs.Stat(fsys, cfgPath); err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
|
}
|
|
return CompileForm(pluginID, ctl, fsys)
|
|
}
|