feat(09-03): compile ordered Winter form schemas

- Strict config_form and fields documents keep source order and JSON scalar types
- Unknown keys, partials, path escape, and a mismatched modelClass fail activation with plugin context
- List-only controllers still activate when config_form.yaml is absent
This commit is contained in:
Jakub Zych
2026-09-24 18:17:45 +02:00
parent ea3f0705f4
commit 4c814e5f63
4 changed files with 532 additions and 12 deletions

View File

@@ -78,6 +78,7 @@ type CompiledController struct {
PluginID string PluginID string
Controller pact.AdminController Controller pact.AdminController
List *ListSchema List *ListSchema
Form *FormSchema
} }
// Registry is the immutable controller map keyed by controller ID. // Registry is the immutable controller map keyed by controller ID.

View File

@@ -1,28 +1,471 @@
package cabana package cabana
import ( import (
"bytes"
"encoding/json"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"path"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/pact"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
) )
// FormSchema is the boot-compiled form contract. The RED stub rejects every var (
// asset so the ADMIN-01 tests fail on the contract, not on a missing symbol. formFieldTypes = map[string]struct{}{
type FormSchema struct{} "text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
// CompileForm will compile config_form.yaml and fields.yaml. It does not yet.
func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
if pluginID == "" || ctl == nil || fsys == nil {
return nil, fmt.Errorf("cabana: form schema is not implemented")
} }
return nil, fmt.Errorf("cabana: form schema is not implemented") 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": {},
}
)
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"`
} }
// CanonicalJSON will emit the stable form contract. It does not yet. 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{}
}
return &FormSchema{
Name: doc.Name,
ModelClass: doc.ModelClass,
Fields: fields,
}, nil
}
// CanonicalJSON is the stable, locale-neutral form contract.
func (s *FormSchema) CanonicalJSON() ([]byte, error) { func (s *FormSchema) CanonicalJSON() ([]byte, error) {
if s == nil { if s == nil {
return nil, fmt.Errorf("cabana: form schema is not implemented") return nil, fmt.Errorf("cabana: nil form schema")
} }
return nil, fmt.Errorf("cabana: form schema is not implemented") out := *s
if out.Fields == nil {
out.Fields = []FormField{}
}
return json.Marshal(out)
}
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 {
node = unwrapNode(node)
if _, ok := node.(*ast.NullNode); ok || node == nil {
m.items = []FormField{}
return nil
}
mapping, ok := node.(*ast.MappingNode)
if !ok {
return 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 || !identifier(name) {
return fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key))
}
if _, dup := seen[name]; dup {
return fmt.Errorf("duplicate field %s", name)
}
seen[name] = struct{}{}
field, err := compileFieldNode(name, unwrapNode(entry.Value))
if err != nil {
return fmt.Errorf("field %s: %w", name, err)
}
items = append(items, field)
}
m.items = items
return 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 typ == "partial" {
return FormField{}, fmt.Errorf("type partial is not supported")
}
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 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
}
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)
} }

View File

@@ -49,10 +49,15 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, assets.AdminFS())
if err != nil {
return nil, err
}
byID[id] = &CompiledController{ byID[id] = &CompiledController{
PluginID: item.plugin.ID(), PluginID: item.plugin.ID(),
Controller: item.ctl, Controller: item.ctl,
List: list, List: list,
Form: form,
} }
} }
return &Registry{byID: byID}, nil return &Registry{byID: byID}, nil

71
cabana/schema_types.go Normal file
View File

@@ -0,0 +1,71 @@
package cabana
import "encoding/json"
// FormSchema is the locale-neutral form contract compiled once at boot.
// Display strings stay as source keys until a request localizes a copy.
type FormSchema struct {
Name string `json:"name,omitempty"`
ModelClass string `json:"modelClass,omitempty"`
Fields []FormField `json:"fields"`
}
// FormField is one Winter field in source order. JSON keys keep Winter spelling.
type FormField struct {
Name string `json:"name"`
Type string `json:"type"`
Label string `json:"label,omitempty"`
Comment string `json:"comment,omitempty"`
Span string `json:"span,omitempty"`
Tab string `json:"tab,omitempty"`
Size string `json:"size,omitempty"`
Context *fieldContext `json:"context,omitempty"`
NameFrom string `json:"nameFrom,omitempty"`
EmptyOption string `json:"emptyOption,omitempty"`
Relation string `json:"relation,omitempty"`
Required bool `json:"required,omitempty"`
Default *jsonScalar `json:"default,omitempty"`
Attributes map[string]jsonScalar `json:"attributes,omitempty"`
Options []FormOption `json:"options,omitempty"`
optionsMethod string
}
// FormOption is one dropdown choice. Value keeps the YAML scalar's JSON type.
type FormOption struct {
Value jsonScalar `json:"value"`
Label string `json:"label"`
}
// jsonScalar is a JSON scalar that still emits false, 0, and empty string.
type jsonScalar struct {
raw json.RawMessage
}
func (s jsonScalar) MarshalJSON() ([]byte, error) {
if len(s.raw) == 0 {
return []byte("null"), nil
}
return s.raw, nil
}
// fieldContext preserves a single Winter context string or a source-ordered list.
type fieldContext struct {
single bool
values []string
}
func (c fieldContext) MarshalJSON() ([]byte, error) {
if c.single {
text := ""
if len(c.values) > 0 {
text = c.values[0]
}
return json.Marshal(text)
}
values := c.values
if values == nil {
values = []string{}
}
return json.Marshal(values)
}