Feat v0.9.5 typed forms sdk (#79)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 3m6s
CI/CD / build-and-deploy (push) Successful in 29s
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 3m6s
CI/CD / build-and-deploy (push) Successful in 29s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #79.
This commit is contained in:
347
server/forms/forms.go
Normal file
347
server/forms/forms.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package forms
|
||||
|
||||
// forms.go
|
||||
//
|
||||
// Standalone typed form system extracted from models/workflow.go (v0.9.5).
|
||||
// Any package can declare and validate forms — not just workflow stages.
|
||||
//
|
||||
// Schema:
|
||||
// TypedFormTemplate → []FormField | []FormFieldset
|
||||
// FormField → key, type, label, validation, condition, …
|
||||
// FormValidation → type-specific rules (length, pattern, range, date, file)
|
||||
// FieldCondition → conditional visibility (eq, neq, in, exists)
|
||||
// FormHooks → Starlark validate / on_submit entry points
|
||||
//
|
||||
// Functions:
|
||||
// ParseTypedFormTemplate(raw) → *TypedFormTemplate
|
||||
// ValidateFormData(tpl, data) → []FieldError
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Types ────────────────────────────────────
|
||||
|
||||
// TypedFormTemplate is the structured form_template schema.
|
||||
type TypedFormTemplate struct {
|
||||
Fields []FormField `json:"fields"`
|
||||
Fieldsets []FormFieldset `json:"fieldsets,omitempty"`
|
||||
Hooks *FormHooks `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
// FormFieldset groups fields into a labelled step for progressive forms.
|
||||
// When fieldsets is present, top-level fields is ignored.
|
||||
type FormFieldset struct {
|
||||
Label string `json:"label"`
|
||||
Fields []FormField `json:"fields"`
|
||||
}
|
||||
|
||||
// FieldCondition controls conditional visibility of a form field.
|
||||
// When set, the field is shown/validated only if the condition is met.
|
||||
type FieldCondition struct {
|
||||
When string `json:"when"` // key of the field to check
|
||||
Op string `json:"op,omitempty"` // eq (default) | neq | in | exists
|
||||
Value any `json:"value,omitempty"` // value to compare against
|
||||
}
|
||||
|
||||
// FormField is a single field in a typed form template.
|
||||
type FormField struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"` // text | email | select | number | date | textarea | checkbox | file
|
||||
Label string `json:"label"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []FormOption `json:"options,omitempty"`
|
||||
Validation *FormValidation `json:"validation,omitempty"`
|
||||
Condition *FieldCondition `json:"condition,omitempty"`
|
||||
}
|
||||
|
||||
// FormOption is a choice for select fields.
|
||||
type FormOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// FormValidation holds type-specific validation rules.
|
||||
type FormValidation struct {
|
||||
MinLength *int `json:"min_length,omitempty"` // text, textarea, email
|
||||
MaxLength *int `json:"max_length,omitempty"` // text, textarea, email
|
||||
Pattern string `json:"pattern,omitempty"` // text, email (regex)
|
||||
Min *float64 `json:"min,omitempty"` // number
|
||||
Max *float64 `json:"max,omitempty"` // number
|
||||
Step *float64 `json:"step,omitempty"` // number
|
||||
MinDate string `json:"min_date,omitempty"` // date (YYYY-MM-DD)
|
||||
MaxDate string `json:"max_date,omitempty"` // date (YYYY-MM-DD)
|
||||
Accept string `json:"accept,omitempty"` // file (MIME types)
|
||||
MaxSize *int64 `json:"max_size_bytes,omitempty"` // file
|
||||
}
|
||||
|
||||
// FormHooks wires Starlark validation/submission hooks for a form.
|
||||
type FormHooks struct {
|
||||
PackageID string `json:"package_id"`
|
||||
Validate string `json:"validate,omitempty"` // entry point name
|
||||
OnSubmit string `json:"on_submit,omitempty"` // entry point name
|
||||
}
|
||||
|
||||
// ValidFormFieldTypes is the set of recognized field types.
|
||||
var ValidFormFieldTypes = map[string]bool{
|
||||
"text": true, "email": true, "select": true, "number": true,
|
||||
"date": true, "textarea": true, "checkbox": true, "file": true,
|
||||
}
|
||||
|
||||
// ── Parsing ──────────────────────────────────
|
||||
|
||||
// ParseTypedFormTemplate parses a raw JSON form_template into the typed schema.
|
||||
// Returns nil if the template is empty, legacy format, or not a typed template.
|
||||
func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
|
||||
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
var tpl TypedFormTemplate
|
||||
if err := json.Unmarshal(raw, &tpl); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(tpl.Fieldsets) > 0 {
|
||||
var allFields []FormField
|
||||
for _, fs := range tpl.Fieldsets {
|
||||
allFields = append(allFields, fs.Fields...)
|
||||
}
|
||||
if len(allFields) > 0 {
|
||||
tpl.Fields = allFields
|
||||
return &tpl
|
||||
}
|
||||
}
|
||||
|
||||
if len(tpl.Fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Verify this is actually the typed format (first field must have key+type)
|
||||
if tpl.Fields[0].Key == "" || tpl.Fields[0].Type == "" {
|
||||
return nil
|
||||
}
|
||||
return &tpl
|
||||
}
|
||||
|
||||
// AllFields returns all fields from the template, whether from top-level fields or fieldsets.
|
||||
func (t *TypedFormTemplate) AllFields() []FormField {
|
||||
return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────
|
||||
|
||||
// FieldError is a validation error for a specific form field.
|
||||
type FieldError struct {
|
||||
Key string `json:"key"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ValidateFormData validates submitted data against a typed form template.
|
||||
func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError {
|
||||
var errs []FieldError
|
||||
for _, f := range tpl.Fields {
|
||||
if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) {
|
||||
continue
|
||||
}
|
||||
|
||||
val, present := data[f.Key]
|
||||
|
||||
// Required check
|
||||
if f.Required && (!present || val == nil || val == "") {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " is required"})
|
||||
continue
|
||||
}
|
||||
if !present || val == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch f.Type {
|
||||
case "text", "textarea":
|
||||
errs = append(errs, validateString(f, val)...)
|
||||
case "email":
|
||||
errs = append(errs, validateEmail(f, val)...)
|
||||
case "number":
|
||||
errs = append(errs, validateNumber(f, val)...)
|
||||
case "date":
|
||||
errs = append(errs, validateDate(f, val)...)
|
||||
case "select":
|
||||
errs = append(errs, validateSelect(f, val)...)
|
||||
case "checkbox":
|
||||
if _, ok := val.(bool); !ok {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " must be true or false"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateString(f FormField, val interface{}) []FieldError {
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
|
||||
}
|
||||
var errs []FieldError
|
||||
v := f.Validation
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
if v.MinLength != nil && len(s) < *v.MinLength {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %d characters", f.Label, *v.MinLength)})
|
||||
}
|
||||
if v.MaxLength != nil && len(s) > *v.MaxLength {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %d characters", f.Label, *v.MaxLength)})
|
||||
}
|
||||
if v.Pattern != "" {
|
||||
if re, err := regexp.Compile(v.Pattern); err == nil && !re.MatchString(s) {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||
|
||||
func validateEmail(f FormField, val interface{}) []FieldError {
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
|
||||
}
|
||||
if !emailRe.MatchString(s) {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid email address"}}
|
||||
}
|
||||
var errs []FieldError
|
||||
if f.Validation != nil {
|
||||
if f.Validation.Pattern != "" {
|
||||
if re, err := regexp.Compile(f.Validation.Pattern); err == nil && !re.MatchString(s) {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateNumber(f FormField, val interface{}) []FieldError {
|
||||
var n float64
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
n = v
|
||||
case int:
|
||||
n = float64(v)
|
||||
case json.Number:
|
||||
var err error
|
||||
n, err = v.Float64()
|
||||
if err != nil {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
||||
}
|
||||
case string:
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
||||
default:
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
||||
}
|
||||
var errs []FieldError
|
||||
if f.Validation != nil {
|
||||
if f.Validation.Min != nil && n < *f.Validation.Min {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %g", f.Label, *f.Validation.Min)})
|
||||
}
|
||||
if f.Validation.Max != nil && n > *f.Validation.Max {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %g", f.Label, *f.Validation.Max)})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateDate(f FormField, val interface{}) []FieldError {
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a date string (YYYY-MM-DD)"}}
|
||||
}
|
||||
_, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid date (YYYY-MM-DD)"}}
|
||||
}
|
||||
if f.Validation != nil {
|
||||
if f.Validation.MinDate != "" {
|
||||
if minD, e := time.Parse("2006-01-02", f.Validation.MinDate); e == nil {
|
||||
if d, _ := time.Parse("2006-01-02", s); d.Before(minD) {
|
||||
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or after %s", f.Label, f.Validation.MinDate)}}
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.Validation.MaxDate != "" {
|
||||
if maxD, e := time.Parse("2006-01-02", f.Validation.MaxDate); e == nil {
|
||||
if d, _ := time.Parse("2006-01-02", s); d.After(maxD) {
|
||||
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or before %s", f.Label, f.Validation.MaxDate)}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSelect(f FormField, val interface{}) []FieldError {
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a string"}}
|
||||
}
|
||||
if len(f.Options) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, o := range f.Options {
|
||||
if strings.EqualFold(o.Value, s) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}}
|
||||
}
|
||||
|
||||
// ── Field Condition Evaluator ─────
|
||||
|
||||
// EvaluateFieldCondition checks if a conditional field should be visible/validated.
|
||||
func EvaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool {
|
||||
return evaluateFieldCondition(cond, data)
|
||||
}
|
||||
|
||||
func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool {
|
||||
val, exists := data[cond.When]
|
||||
op := cond.Op
|
||||
if op == "" {
|
||||
op = "eq"
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "exists":
|
||||
return exists
|
||||
case "eq":
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value)
|
||||
case "neq":
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value)
|
||||
case "in":
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
arr, ok := cond.Value.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
vs := fmt.Sprintf("%v", val)
|
||||
for _, item := range arr {
|
||||
if fmt.Sprintf("%v", item) == vs {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
322
server/forms/forms_test.go
Normal file
322
server/forms/forms_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Parse Tests ──────────────────────────────
|
||||
|
||||
func TestParseTypedFormTemplate_Empty(t *testing.T) {
|
||||
for _, input := range []string{"", "{}", "null"} {
|
||||
if tpl := ParseTypedFormTemplate(json.RawMessage(input)); tpl != nil {
|
||||
t.Errorf("expected nil for %q, got %+v", input, tpl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTypedFormTemplate_InvalidJSON(t *testing.T) {
|
||||
if tpl := ParseTypedFormTemplate(json.RawMessage(`{bad`)); tpl != nil {
|
||||
t.Fatal("expected nil for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTypedFormTemplate_FlatFields(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"fields": [
|
||||
{"key": "name", "type": "text", "label": "Name"},
|
||||
{"key": "email", "type": "email", "label": "Email"}
|
||||
]
|
||||
}`)
|
||||
tpl := ParseTypedFormTemplate(raw)
|
||||
if tpl == nil {
|
||||
t.Fatal("expected non-nil template")
|
||||
}
|
||||
if len(tpl.Fields) != 2 {
|
||||
t.Fatalf("expected 2 fields, got %d", len(tpl.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTypedFormTemplate_Fieldsets(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"fieldsets": [
|
||||
{"label": "Step 1", "fields": [{"key": "a", "type": "text", "label": "A"}]},
|
||||
{"label": "Step 2", "fields": [{"key": "b", "type": "number", "label": "B"}]}
|
||||
]
|
||||
}`)
|
||||
tpl := ParseTypedFormTemplate(raw)
|
||||
if tpl == nil {
|
||||
t.Fatal("expected non-nil template")
|
||||
}
|
||||
if len(tpl.Fields) != 2 {
|
||||
t.Fatalf("expected 2 flattened fields, got %d", len(tpl.Fields))
|
||||
}
|
||||
if len(tpl.Fieldsets) != 2 {
|
||||
t.Fatalf("expected 2 fieldsets, got %d", len(tpl.Fieldsets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTypedFormTemplate_LegacyFormat(t *testing.T) {
|
||||
// Legacy format: fields present but missing key/type → returns nil
|
||||
raw := json.RawMessage(`{"fields": [{"label": "Name"}]}`)
|
||||
if tpl := ParseTypedFormTemplate(raw); tpl != nil {
|
||||
t.Fatal("expected nil for legacy format")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation Tests ─────────────────────────
|
||||
|
||||
func tpl(fields ...FormField) *TypedFormTemplate {
|
||||
return &TypedFormTemplate{Fields: fields}
|
||||
}
|
||||
|
||||
func intPtr(n int) *int { return &n }
|
||||
func floatPtr(n float64) *float64 { return &n }
|
||||
|
||||
func TestValidateFormData_Required(t *testing.T) {
|
||||
template := tpl(FormField{Key: "name", Type: "text", Label: "Name", Required: true})
|
||||
|
||||
// Missing
|
||||
errs := ValidateFormData(template, map[string]interface{}{})
|
||||
if len(errs) != 1 || errs[0].Key != "name" {
|
||||
t.Fatalf("expected 1 error for missing required field, got %+v", errs)
|
||||
}
|
||||
|
||||
// Present
|
||||
errs = ValidateFormData(template, map[string]interface{}{"name": "Alice"})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||
}
|
||||
|
||||
// Empty string counts as missing
|
||||
errs = ValidateFormData(template, map[string]interface{}{"name": ""})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected 1 error for empty required field, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormData_Text(t *testing.T) {
|
||||
template := tpl(FormField{
|
||||
Key: "bio", Type: "text", Label: "Bio",
|
||||
Validation: &FormValidation{MinLength: intPtr(3), MaxLength: intPtr(10), Pattern: `^[a-z]+$`},
|
||||
})
|
||||
|
||||
// Too short
|
||||
errs := ValidateFormData(template, map[string]interface{}{"bio": "ab"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected min_length error, got %+v", errs)
|
||||
}
|
||||
|
||||
// Too long
|
||||
errs = ValidateFormData(template, map[string]interface{}{"bio": "abcdefghijk"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected max_length error, got %+v", errs)
|
||||
}
|
||||
|
||||
// Pattern mismatch
|
||||
errs = ValidateFormData(template, map[string]interface{}{"bio": "ABC"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected pattern error, got %+v", errs)
|
||||
}
|
||||
|
||||
// Valid
|
||||
errs = ValidateFormData(template, map[string]interface{}{"bio": "hello"})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormData_Email(t *testing.T) {
|
||||
template := tpl(FormField{Key: "email", Type: "email", Label: "Email"})
|
||||
|
||||
errs := ValidateFormData(template, map[string]interface{}{"email": "bad"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected email error, got %+v", errs)
|
||||
}
|
||||
|
||||
errs = ValidateFormData(template, map[string]interface{}{"email": "a@b.c"})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormData_Number(t *testing.T) {
|
||||
template := tpl(FormField{
|
||||
Key: "age", Type: "number", Label: "Age",
|
||||
Validation: &FormValidation{Min: floatPtr(0), Max: floatPtr(150)},
|
||||
})
|
||||
|
||||
// Below min
|
||||
errs := ValidateFormData(template, map[string]interface{}{"age": float64(-1)})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected min error, got %+v", errs)
|
||||
}
|
||||
|
||||
// Above max
|
||||
errs = ValidateFormData(template, map[string]interface{}{"age": float64(200)})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected max error, got %+v", errs)
|
||||
}
|
||||
|
||||
// Non-number
|
||||
errs = ValidateFormData(template, map[string]interface{}{"age": "thirty"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected type error, got %+v", errs)
|
||||
}
|
||||
|
||||
// Valid
|
||||
errs = ValidateFormData(template, map[string]interface{}{"age": float64(25)})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormData_Date(t *testing.T) {
|
||||
template := tpl(FormField{
|
||||
Key: "start", Type: "date", Label: "Start",
|
||||
Validation: &FormValidation{MinDate: "2024-01-01", MaxDate: "2024-12-31"},
|
||||
})
|
||||
|
||||
// Invalid format
|
||||
errs := ValidateFormData(template, map[string]interface{}{"start": "Jan 1"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected format error, got %+v", errs)
|
||||
}
|
||||
|
||||
// Before min
|
||||
errs = ValidateFormData(template, map[string]interface{}{"start": "2023-12-31"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected min_date error, got %+v", errs)
|
||||
}
|
||||
|
||||
// After max
|
||||
errs = ValidateFormData(template, map[string]interface{}{"start": "2025-01-01"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected max_date error, got %+v", errs)
|
||||
}
|
||||
|
||||
// Valid
|
||||
errs = ValidateFormData(template, map[string]interface{}{"start": "2024-06-15"})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormData_Select(t *testing.T) {
|
||||
template := tpl(FormField{
|
||||
Key: "color", Type: "select", Label: "Color",
|
||||
Options: []FormOption{{Value: "red", Label: "Red"}, {Value: "blue", Label: "Blue"}},
|
||||
})
|
||||
|
||||
errs := ValidateFormData(template, map[string]interface{}{"color": "green"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected invalid option error, got %+v", errs)
|
||||
}
|
||||
|
||||
// Case-insensitive match
|
||||
errs = ValidateFormData(template, map[string]interface{}{"color": "Red"})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors (case-insensitive), got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormData_Checkbox(t *testing.T) {
|
||||
template := tpl(FormField{Key: "agree", Type: "checkbox", Label: "Agree"})
|
||||
|
||||
errs := ValidateFormData(template, map[string]interface{}{"agree": "yes"})
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected type error, got %+v", errs)
|
||||
}
|
||||
|
||||
errs = ValidateFormData(template, map[string]interface{}{"agree": true})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormData_Condition(t *testing.T) {
|
||||
template := tpl(
|
||||
FormField{Key: "type", Type: "select", Label: "Type", Options: []FormOption{{Value: "normal", Label: "Normal"}, {Value: "other", Label: "Other"}}},
|
||||
FormField{
|
||||
Key: "details", Type: "text", Label: "Details", Required: true,
|
||||
Condition: &FieldCondition{When: "type", Op: "eq", Value: "other"},
|
||||
},
|
||||
)
|
||||
|
||||
// Condition not met → required field skipped
|
||||
errs := ValidateFormData(template, map[string]interface{}{"type": "normal"})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors (condition not met), got %+v", errs)
|
||||
}
|
||||
|
||||
// Condition met → required enforced
|
||||
errs = ValidateFormData(template, map[string]interface{}{"type": "other"})
|
||||
if len(errs) != 1 || errs[0].Key != "details" {
|
||||
t.Fatalf("expected 1 error for conditional required field, got %+v", errs)
|
||||
}
|
||||
|
||||
// Condition met + value provided
|
||||
errs = ValidateFormData(template, map[string]interface{}{"type": "other", "details": "More info"})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors, got %+v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateFieldCondition_Operators(t *testing.T) {
|
||||
data := map[string]interface{}{"status": "active", "role": "admin"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cond FieldCondition
|
||||
expect bool
|
||||
}{
|
||||
{"eq match", FieldCondition{When: "status", Op: "eq", Value: "active"}, true},
|
||||
{"eq mismatch", FieldCondition{When: "status", Op: "eq", Value: "inactive"}, false},
|
||||
{"neq match", FieldCondition{When: "status", Op: "neq", Value: "inactive"}, true},
|
||||
{"neq mismatch", FieldCondition{When: "status", Op: "neq", Value: "active"}, false},
|
||||
{"exists present", FieldCondition{When: "status", Op: "exists"}, true},
|
||||
{"exists absent", FieldCondition{When: "missing", Op: "exists"}, false},
|
||||
{"in match", FieldCondition{When: "role", Op: "in", Value: []interface{}{"admin", "mod"}}, true},
|
||||
{"in mismatch", FieldCondition{When: "role", Op: "in", Value: []interface{}{"user", "guest"}}, false},
|
||||
{"default op is eq", FieldCondition{When: "status", Value: "active"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := EvaluateFieldCondition(&tt.cond, data)
|
||||
if got != tt.expect {
|
||||
t.Errorf("expected %v, got %v", tt.expect, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllFields(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"fieldsets": [
|
||||
{"label": "A", "fields": [{"key": "x", "type": "text", "label": "X"}]},
|
||||
{"label": "B", "fields": [{"key": "y", "type": "text", "label": "Y"}, {"key": "z", "type": "text", "label": "Z"}]}
|
||||
]
|
||||
}`)
|
||||
tpl := ParseTypedFormTemplate(raw)
|
||||
if tpl == nil {
|
||||
t.Fatal("expected non-nil template")
|
||||
}
|
||||
all := tpl.AllFields()
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("expected 3 fields, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFormData_OptionalSkipped(t *testing.T) {
|
||||
template := tpl(FormField{
|
||||
Key: "note", Type: "text", Label: "Note",
|
||||
Validation: &FormValidation{MinLength: intPtr(5)},
|
||||
})
|
||||
|
||||
// Not present and not required → no error
|
||||
errs := ValidateFormData(template, map[string]interface{}{})
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected 0 errors for absent optional field, got %+v", errs)
|
||||
}
|
||||
}
|
||||
63
server/handlers/forms.go
Normal file
63
server/handlers/forms.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"armature/forms"
|
||||
)
|
||||
|
||||
// FormsHandler provides REST endpoints for the standalone forms system.
|
||||
type FormsHandler struct{}
|
||||
|
||||
// formsValidateRequest is the request body for POST /api/v1/forms/validate.
|
||||
type formsValidateRequest struct {
|
||||
Template json.RawMessage `json:"template"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// formsValidateResponse is the response body for POST /api/v1/forms/validate.
|
||||
type formsValidateResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Errors []forms.FieldError `json:"errors"`
|
||||
}
|
||||
|
||||
// Validate validates form data against a typed form template.
|
||||
//
|
||||
// POST /api/v1/forms/validate
|
||||
// Body: { "template": {...}, "data": {...} }
|
||||
// Response: { "valid": true/false, "errors": [...] }
|
||||
func (h *FormsHandler) Validate(c *gin.Context) {
|
||||
var req formsValidateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Template) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "template is required"})
|
||||
return
|
||||
}
|
||||
|
||||
tpl := forms.ParseTypedFormTemplate(req.Template)
|
||||
if tpl == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "template is invalid or empty"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Data == nil {
|
||||
req.Data = make(map[string]interface{})
|
||||
}
|
||||
|
||||
errs := forms.ValidateFormData(tpl, req.Data)
|
||||
if errs == nil {
|
||||
errs = []forms.FieldError{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, formsValidateResponse{
|
||||
Valid: len(errs) == 0,
|
||||
Errors: errs,
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"armature/forms"
|
||||
)
|
||||
|
||||
// validManifestID matches lowercase alphanumeric slugs with optional hyphens.
|
||||
@@ -35,6 +37,7 @@ type ManifestInfo struct {
|
||||
GatePermission string // if set, checks this user permission before calling on_request
|
||||
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics)
|
||||
Adoptable bool // if true, teams can adopt this package to get a team-scoped copy
|
||||
HasFormTemplate bool // if true, package declares a form_template at the top level
|
||||
}
|
||||
|
||||
// ValidateManifest parses a manifest map and validates all required fields,
|
||||
@@ -184,6 +187,18 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
||||
info.Adoptable = v
|
||||
}
|
||||
|
||||
// v0.9.5: form_template — any package can declare a typed form template
|
||||
if manifest["form_template"] != nil {
|
||||
raw, err := json.Marshal(manifest["form_template"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("form_template is invalid JSON")
|
||||
}
|
||||
if tpl := forms.ParseTypedFormTemplate(raw); tpl == nil {
|
||||
return nil, fmt.Errorf("form_template is invalid or empty")
|
||||
}
|
||||
info.HasFormTemplate = true
|
||||
}
|
||||
|
||||
info.SchemaVersion = ParseSchemaVersion(manifest)
|
||||
|
||||
// ── Type-specific constraints ────────────────────────────────
|
||||
|
||||
@@ -545,6 +545,10 @@ func main() {
|
||||
protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit)
|
||||
protected.GET("/instances/:iid/signoffs", wfSignoffH.List)
|
||||
|
||||
// Forms validation
|
||||
formsH := &handlers.FormsHandler{}
|
||||
protected.POST("/forms/validate", formsH.Validate)
|
||||
|
||||
// Surface discovery
|
||||
pkgH := handlers.NewPackageHandler(stores)
|
||||
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
||||
|
||||
@@ -2,9 +2,6 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -118,320 +115,6 @@ var ValidAudiences = map[string]bool{
|
||||
AudienceSystem: true,
|
||||
}
|
||||
|
||||
// ── Typed Form Template ─────────────────────
|
||||
|
||||
// TypedFormTemplate is the structured form_template schema.
|
||||
type TypedFormTemplate struct {
|
||||
Fields []FormField `json:"fields"`
|
||||
Fieldsets []FormFieldset `json:"fieldsets,omitempty"`
|
||||
Hooks *FormHooks `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
// FormFieldset groups fields into a labelled step for progressive forms.
|
||||
// When fieldsets is present, top-level fields is ignored.
|
||||
type FormFieldset struct {
|
||||
Label string `json:"label"`
|
||||
Fields []FormField `json:"fields"`
|
||||
}
|
||||
|
||||
// FieldCondition controls conditional visibility of a form field.
|
||||
// When set, the field is shown/validated only if the condition is met.
|
||||
type FieldCondition struct {
|
||||
When string `json:"when"` // key of the field to check
|
||||
Op string `json:"op,omitempty"` // eq (default) | neq | in | exists
|
||||
Value any `json:"value,omitempty"` // value to compare against
|
||||
}
|
||||
|
||||
// FormField is a single field in a typed form template.
|
||||
type FormField struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"` // text | email | select | number | date | textarea | checkbox | file
|
||||
Label string `json:"label"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []FormOption `json:"options,omitempty"`
|
||||
Validation *FormValidation `json:"validation,omitempty"`
|
||||
Condition *FieldCondition `json:"condition,omitempty"`
|
||||
}
|
||||
|
||||
// FormOption is a choice for select fields.
|
||||
type FormOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// FormValidation holds type-specific validation rules.
|
||||
type FormValidation struct {
|
||||
MinLength *int `json:"min_length,omitempty"` // text, textarea, email
|
||||
MaxLength *int `json:"max_length,omitempty"` // text, textarea, email
|
||||
Pattern string `json:"pattern,omitempty"` // text, email (regex)
|
||||
Min *float64 `json:"min,omitempty"` // number
|
||||
Max *float64 `json:"max,omitempty"` // number
|
||||
Step *float64 `json:"step,omitempty"` // number
|
||||
MinDate string `json:"min_date,omitempty"` // date (YYYY-MM-DD)
|
||||
MaxDate string `json:"max_date,omitempty"` // date (YYYY-MM-DD)
|
||||
Accept string `json:"accept,omitempty"` // file (MIME types)
|
||||
MaxSize *int64 `json:"max_size_bytes,omitempty"` // file
|
||||
}
|
||||
|
||||
// FormHooks wires Starlark validation/submission hooks for a stage's form.
|
||||
type FormHooks struct {
|
||||
PackageID string `json:"package_id"`
|
||||
Validate string `json:"validate,omitempty"` // entry point name
|
||||
OnSubmit string `json:"on_submit,omitempty"` // entry point name
|
||||
}
|
||||
|
||||
// ValidFormFieldTypes is the set of recognized field types.
|
||||
var ValidFormFieldTypes = map[string]bool{
|
||||
"text": true, "email": true, "select": true, "number": true,
|
||||
"date": true, "textarea": true, "checkbox": true, "file": true,
|
||||
}
|
||||
|
||||
// ParseTypedFormTemplate parses a raw JSON form_template into the typed schema.
|
||||
// Returns nil if the template is empty, legacy format, or not a typed template.
|
||||
func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
|
||||
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
var tpl TypedFormTemplate
|
||||
if err := json.Unmarshal(raw, &tpl); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(tpl.Fieldsets) > 0 {
|
||||
var allFields []FormField
|
||||
for _, fs := range tpl.Fieldsets {
|
||||
allFields = append(allFields, fs.Fields...)
|
||||
}
|
||||
if len(allFields) > 0 {
|
||||
tpl.Fields = allFields
|
||||
return &tpl
|
||||
}
|
||||
}
|
||||
|
||||
if len(tpl.Fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Verify this is actually the typed format (first field must have key+type)
|
||||
if tpl.Fields[0].Key == "" || tpl.Fields[0].Type == "" {
|
||||
return nil
|
||||
}
|
||||
return &tpl
|
||||
}
|
||||
|
||||
// AllFields returns all fields from the template, whether from top-level fields or fieldsets.
|
||||
func (t *TypedFormTemplate) AllFields() []FormField {
|
||||
return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields
|
||||
}
|
||||
|
||||
// FieldError is a validation error for a specific form field.
|
||||
type FieldError struct {
|
||||
Key string `json:"key"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ValidateFormData validates submitted data against a typed form template.
|
||||
func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError {
|
||||
var errs []FieldError
|
||||
for _, f := range tpl.Fields {
|
||||
if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) {
|
||||
continue
|
||||
}
|
||||
|
||||
val, present := data[f.Key]
|
||||
|
||||
// Required check
|
||||
if f.Required && (!present || val == nil || val == "") {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " is required"})
|
||||
continue
|
||||
}
|
||||
if !present || val == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch f.Type {
|
||||
case "text", "textarea":
|
||||
errs = append(errs, validateString(f, val)...)
|
||||
case "email":
|
||||
errs = append(errs, validateEmail(f, val)...)
|
||||
case "number":
|
||||
errs = append(errs, validateNumber(f, val)...)
|
||||
case "date":
|
||||
errs = append(errs, validateDate(f, val)...)
|
||||
case "select":
|
||||
errs = append(errs, validateSelect(f, val)...)
|
||||
case "checkbox":
|
||||
if _, ok := val.(bool); !ok {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " must be true or false"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateString(f FormField, val interface{}) []FieldError {
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
|
||||
}
|
||||
var errs []FieldError
|
||||
v := f.Validation
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
if v.MinLength != nil && len(s) < *v.MinLength {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %d characters", f.Label, *v.MinLength)})
|
||||
}
|
||||
if v.MaxLength != nil && len(s) > *v.MaxLength {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %d characters", f.Label, *v.MaxLength)})
|
||||
}
|
||||
if v.Pattern != "" {
|
||||
if re, err := regexp.Compile(v.Pattern); err == nil && !re.MatchString(s) {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||
|
||||
func validateEmail(f FormField, val interface{}) []FieldError {
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}}
|
||||
}
|
||||
if !emailRe.MatchString(s) {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid email address"}}
|
||||
}
|
||||
var errs []FieldError
|
||||
if f.Validation != nil {
|
||||
if f.Validation.Pattern != "" {
|
||||
if re, err := regexp.Compile(f.Validation.Pattern); err == nil && !re.MatchString(s) {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateNumber(f FormField, val interface{}) []FieldError {
|
||||
var n float64
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
n = v
|
||||
case int:
|
||||
n = float64(v)
|
||||
case json.Number:
|
||||
var err error
|
||||
n, err = v.Float64()
|
||||
if err != nil {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
||||
}
|
||||
case string:
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
||||
default:
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}}
|
||||
}
|
||||
var errs []FieldError
|
||||
if f.Validation != nil {
|
||||
if f.Validation.Min != nil && n < *f.Validation.Min {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %g", f.Label, *f.Validation.Min)})
|
||||
}
|
||||
if f.Validation.Max != nil && n > *f.Validation.Max {
|
||||
errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %g", f.Label, *f.Validation.Max)})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateDate(f FormField, val interface{}) []FieldError {
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a date string (YYYY-MM-DD)"}}
|
||||
}
|
||||
_, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid date (YYYY-MM-DD)"}}
|
||||
}
|
||||
if f.Validation != nil {
|
||||
if f.Validation.MinDate != "" {
|
||||
if minD, e := time.Parse("2006-01-02", f.Validation.MinDate); e == nil {
|
||||
if d, _ := time.Parse("2006-01-02", s); d.Before(minD) {
|
||||
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or after %s", f.Label, f.Validation.MinDate)}}
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.Validation.MaxDate != "" {
|
||||
if maxD, e := time.Parse("2006-01-02", f.Validation.MaxDate); e == nil {
|
||||
if d, _ := time.Parse("2006-01-02", s); d.After(maxD) {
|
||||
return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or before %s", f.Label, f.Validation.MaxDate)}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSelect(f FormField, val interface{}) []FieldError {
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " must be a string"}}
|
||||
}
|
||||
if len(f.Options) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, o := range f.Options {
|
||||
if strings.EqualFold(o.Value, s) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}}
|
||||
}
|
||||
|
||||
// ── Field Condition Evaluator ─────
|
||||
|
||||
// evaluateFieldCondition checks if a conditional field should be visible/validated.
|
||||
func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool {
|
||||
val, exists := data[cond.When]
|
||||
op := cond.Op
|
||||
if op == "" {
|
||||
op = "eq"
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "exists":
|
||||
return exists
|
||||
case "eq":
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value)
|
||||
case "neq":
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value)
|
||||
case "in":
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
arr, ok := cond.Value.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
vs := fmt.Sprintf("%v", val)
|
||||
for _, item := range arr {
|
||||
if fmt.Sprintf("%v", item) == vs {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Instance Status Constants ───────────────
|
||||
|
||||
const (
|
||||
|
||||
86
server/sandbox/forms_module.go
Normal file
86
server/sandbox/forms_module.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package sandbox
|
||||
|
||||
// forms_module.go
|
||||
//
|
||||
// Starlark forms module — validates form data against typed form templates.
|
||||
// Gated by the forms.validate extension permission.
|
||||
//
|
||||
// Starlark API:
|
||||
// result = forms.validate(template, data)
|
||||
// # result["valid"] → True/False
|
||||
// # result["errors"] → [{"key": "...", "message": "..."}, ...]
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
"go.starlark.net/starlarkstruct"
|
||||
|
||||
"armature/forms"
|
||||
)
|
||||
|
||||
// BuildFormsModule creates the "forms" Starlark module.
|
||||
// Requires the forms.validate permission.
|
||||
func BuildFormsModule(_ context.Context) *starlarkstruct.Module {
|
||||
return MakeModule("forms", starlark.StringDict{
|
||||
"validate": starlark.NewBuiltin("forms.validate", formsValidateBuiltin()),
|
||||
})
|
||||
}
|
||||
|
||||
func formsValidateBuiltin() func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var templateVal, dataVal starlark.Value
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &templateVal, &dataVal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert template dict → Go map → JSON → TypedFormTemplate
|
||||
templateDict, ok := templateVal.(*starlark.Dict)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("forms.validate: template must be a dict, got %s", templateVal.Type())
|
||||
}
|
||||
templateMap := DictToMap(templateDict)
|
||||
templateJSON, err := json.Marshal(templateMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("forms.validate: failed to marshal template: %w", err)
|
||||
}
|
||||
|
||||
tpl := forms.ParseTypedFormTemplate(json.RawMessage(templateJSON))
|
||||
if tpl == nil {
|
||||
return nil, fmt.Errorf("forms.validate: template is invalid or empty")
|
||||
}
|
||||
|
||||
// Convert data dict → Go map
|
||||
dataDict, ok := dataVal.(*starlark.Dict)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("forms.validate: data must be a dict, got %s", dataVal.Type())
|
||||
}
|
||||
dataMap := DictToMap(dataDict)
|
||||
|
||||
// Coerce to map[string]interface{}
|
||||
data := make(map[string]interface{}, len(dataMap))
|
||||
for k, v := range dataMap {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
// Validate
|
||||
errs := forms.ValidateFormData(tpl, data)
|
||||
|
||||
// Build result dict
|
||||
errList := make([]starlark.Value, len(errs))
|
||||
for i, e := range errs {
|
||||
d := starlark.NewDict(2)
|
||||
_ = d.SetKey(starlark.String("key"), starlark.String(e.Key))
|
||||
_ = d.SetKey(starlark.String("message"), starlark.String(e.Message))
|
||||
errList[i] = d
|
||||
}
|
||||
|
||||
result := starlark.NewDict(2)
|
||||
_ = result.SetKey(starlark.String("valid"), starlark.Bool(len(errs) == 0))
|
||||
_ = result.SetKey(starlark.String("errors"), starlark.NewList(errList))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
116
server/sandbox/forms_module_test.go
Normal file
116
server/sandbox/forms_module_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
)
|
||||
|
||||
func runFormsScript(t *testing.T, script string) starlark.StringDict {
|
||||
t.Helper()
|
||||
mod := BuildFormsModule(context.Background())
|
||||
predeclared := starlark.StringDict{"forms": mod}
|
||||
globals, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return globals
|
||||
}
|
||||
|
||||
func TestFormsModule_ValidateValid(t *testing.T) {
|
||||
globals := runFormsScript(t, `
|
||||
tpl = {
|
||||
"fields": [
|
||||
{"key": "name", "type": "text", "label": "Name", "required": True},
|
||||
{"key": "email", "type": "email", "label": "Email"},
|
||||
]
|
||||
}
|
||||
result = forms.validate(tpl, {"name": "Alice", "email": "alice@example.com"})
|
||||
`)
|
||||
result := globals["result"].(*starlark.Dict)
|
||||
valid, _, _ := result.Get(starlark.String("valid"))
|
||||
if valid != starlark.True {
|
||||
t.Fatalf("expected valid=True, got %v", valid)
|
||||
}
|
||||
errors, _, _ := result.Get(starlark.String("errors"))
|
||||
errList := errors.(*starlark.List)
|
||||
if errList.Len() != 0 {
|
||||
t.Fatalf("expected 0 errors, got %d", errList.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormsModule_ValidateRequired(t *testing.T) {
|
||||
globals := runFormsScript(t, `
|
||||
tpl = {
|
||||
"fields": [
|
||||
{"key": "name", "type": "text", "label": "Name", "required": True},
|
||||
]
|
||||
}
|
||||
result = forms.validate(tpl, {})
|
||||
`)
|
||||
result := globals["result"].(*starlark.Dict)
|
||||
valid, _, _ := result.Get(starlark.String("valid"))
|
||||
if valid != starlark.False {
|
||||
t.Fatalf("expected valid=False, got %v", valid)
|
||||
}
|
||||
errors, _, _ := result.Get(starlark.String("errors"))
|
||||
errList := errors.(*starlark.List)
|
||||
if errList.Len() != 1 {
|
||||
t.Fatalf("expected 1 error, got %d", errList.Len())
|
||||
}
|
||||
errDict := errList.Index(0).(*starlark.Dict)
|
||||
key, _, _ := errDict.Get(starlark.String("key"))
|
||||
if key.(starlark.String).GoString() != "name" {
|
||||
t.Fatalf("expected error key 'name', got %v", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormsModule_ValidateTypeErrors(t *testing.T) {
|
||||
globals := runFormsScript(t, `
|
||||
tpl = {
|
||||
"fields": [
|
||||
{"key": "age", "type": "number", "label": "Age", "validation": {"min": 0, "max": 150}},
|
||||
{"key": "email", "type": "email", "label": "Email"},
|
||||
]
|
||||
}
|
||||
result = forms.validate(tpl, {"age": -5, "email": "bad"})
|
||||
`)
|
||||
result := globals["result"].(*starlark.Dict)
|
||||
valid, _, _ := result.Get(starlark.String("valid"))
|
||||
if valid != starlark.False {
|
||||
t.Fatalf("expected valid=False, got %v", valid)
|
||||
}
|
||||
errors, _, _ := result.Get(starlark.String("errors"))
|
||||
errList := errors.(*starlark.List)
|
||||
if errList.Len() != 2 {
|
||||
t.Fatalf("expected 2 errors, got %d", errList.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormsModule_ValidateCondition(t *testing.T) {
|
||||
globals := runFormsScript(t, `
|
||||
tpl = {
|
||||
"fields": [
|
||||
{"key": "type", "type": "text", "label": "Type"},
|
||||
{"key": "details", "type": "text", "label": "Details", "required": True,
|
||||
"condition": {"when": "type", "op": "eq", "value": "other"}},
|
||||
]
|
||||
}
|
||||
# Condition not met — details not required
|
||||
result1 = forms.validate(tpl, {"type": "normal"})
|
||||
# Condition met — details required
|
||||
result2 = forms.validate(tpl, {"type": "other"})
|
||||
`)
|
||||
result1 := globals["result1"].(*starlark.Dict)
|
||||
valid1, _, _ := result1.Get(starlark.String("valid"))
|
||||
if valid1 != starlark.True {
|
||||
t.Fatalf("result1: expected valid=True, got %v", valid1)
|
||||
}
|
||||
|
||||
result2 := globals["result2"].(*starlark.Dict)
|
||||
valid2, _, _ := result2.Get(starlark.String("valid"))
|
||||
if valid2 != starlark.False {
|
||||
t.Fatalf("result2: expected valid=False, got %v", valid2)
|
||||
}
|
||||
}
|
||||
@@ -417,6 +417,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
||||
})
|
||||
}
|
||||
|
||||
case models.ExtPermFormValidate:
|
||||
modules["forms"] = BuildFormsModule(ctx)
|
||||
|
||||
case models.ExtPermBatchExec:
|
||||
hasBatchExec = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user