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:
@@ -78,6 +78,7 @@ type CompiledController struct {
|
||||
PluginID string
|
||||
Controller pact.AdminController
|
||||
List *ListSchema
|
||||
Form *FormSchema
|
||||
}
|
||||
|
||||
// Registry is the immutable controller map keyed by controller ID.
|
||||
|
||||
@@ -1,28 +1,471 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
// asset so the ADMIN-01 tests fail on the contract, not on a missing symbol.
|
||||
type FormSchema struct{}
|
||||
|
||||
// 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")
|
||||
var (
|
||||
formFieldTypes = map[string]struct{}{
|
||||
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
||||
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -49,10 +49,15 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, assets.AdminFS())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID[id] = &CompiledController{
|
||||
PluginID: item.plugin.ID(),
|
||||
Controller: item.ctl,
|
||||
List: list,
|
||||
Form: form,
|
||||
}
|
||||
}
|
||||
return &Registry{byID: byID}, nil
|
||||
|
||||
71
cabana/schema_types.go
Normal file
71
cabana/schema_types.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user