// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package forms import ( "fmt" "net/http" "regexp" "strconv" "strings" "time" "github.com/google/uuid" ) // Parsing (design D6; spec form-library "The handler parses through the // declaration"). A handler reads a mutation's body through the // declaration and never through r.FormValue, which merges the query string // into the body and hides which one a value came from. Parse reads only // declared names, so the declaration is also the write allowlist: the CSRF // token, htmx's own parameters and anything else a body carries are // ignored (Django's fields warning, Rails' permit! warning; lesson L§15). // // Parse never builds a domain object. It returns the values and the // errors; the handler adds its own checks by field name or at the form // level and builds the object only once the error set is empty (L§16). // Values holds one form's values: the raw submitted string, whether the // name was present at all, and the typed value where the field's rules // passed. Go cannot tell an absent string from an empty one, so presence // is recorded beside the value (lesson L§4). type Values struct { raw map[string]string present map[string]bool typed map[string]any } // NewValues returns an empty value set, for binding a record to a form. func NewValues() Values { return Values{ raw: map[string]string{}, present: map[string]bool{}, typed: map[string]any{}, } } func (v *Values) init() { if v.raw == nil { v.raw = map[string]string{} v.present = map[string]bool{} v.typed = map[string]any{} } } // Set records a field's raw value, marking it present. Binding a record to // a form goes through here, so the record-bound render and the // submission-bound render read the same way. func (v *Values) Set(name, raw string) { v.init() v.raw[name] = raw v.present[name] = true } // SetBool records a checkbox's state. A ticked box carries the field's // submitted value; an unticked one carries nothing, which is how the // browser submits it. Either way, the typed bool is recorded too, so a // bound checkbox answers Bool the same way a parsed one does (design D1). func (v *Values) SetBool(f Field, on bool) { v.init() if on { v.Set(f.Name, f.CheckboxValue()) v.setTyped(f.Name, true) return } v.raw[f.Name] = "" v.present[f.Name] = false v.setTyped(f.Name, false) } // Raw returns the submitted (or bound) string, trimmed as Parse trimmed it. func (v Values) Raw(name string) string { return v.raw[name] } // Present reports whether the name was submitted at all, which is not the // same as carrying a value. func (v Values) Present(name string) bool { return v.present[name] } // String returns the value as text. func (v Values) String(name string) string { return v.raw[name] } // Int returns the value as a whole number, and false when the field is // absent, empty, or was refused. func (v Values) Int(name string) (int, bool) { n, ok := v.typed[name].(int) return n, ok } // Bool returns a checkbox's state. An absent checkbox is false, never an // error (spec form-conventions "A checkbox is never required"). func (v Values) Bool(name string) bool { b, _ := v.typed[name].(bool) return b } // Time returns the value as an instant, and false when the field is // absent, empty, or was refused. func (v Values) Time(name string) (time.Time, bool) { t, ok := v.typed[name].(time.Time) return t, ok } // UUID returns the value as an identifier, and false when the field is // absent, empty, or was refused. func (v Values) UUID(name string) (uuid.UUID, bool) { id, ok := v.typed[name].(uuid.UUID) return id, ok } // setTyped records a field's typed value; an errored field never gets one, // so a handler that reads a typed value is reading a value that passed. func (v *Values) setTyped(name string, val any) { v.init() v.typed[name] = val } // Errors is one form's refusals: a message per field, plus the form-level // message for a refusal that belongs to no field. The form-level slot is // reached by the same call with an empty field name (lesson L§11). type Errors struct { fields map[string]string form string } // NewErrors returns an empty error set. func NewErrors() *Errors { return &Errors{fields: map[string]string{}} } // Field records a refusal under one field. The last message wins: the // convention is one concise message per field, not a list. func (e *Errors) Field(name, msg string) { if e.fields == nil { e.fields = map[string]string{} } if name == "" { e.form = msg return } e.fields[name] = msg } // Form records a refusal that belongs to no field. func (e *Errors) Form(msg string) { e.form = msg } // Get returns one field's message, or the empty string. func (e *Errors) Get(name string) string { if e == nil { return "" } return e.fields[name] } // FormError returns the form-level message, or the empty string. func (e *Errors) FormError() string { if e == nil { return "" } return e.form } // Has reports whether one field was refused. func (e *Errors) Has(name string) bool { if e == nil { return false } _, ok := e.fields[name] return ok } // Any reports whether anything was refused. This is the handler's branch // point: build the domain object only when it is false. func (e *Errors) Any() bool { if e == nil { return false } return len(e.fields) > 0 || e.form != "" } // Count returns how many fields were refused, the form level included. func (e *Errors) Count() int { if e == nil { return 0 } n := len(e.fields) if e.form != "" { n++ } return n } // Parse reads r through the declaration and returns the values and the // refusals. It reads r.PostForm for a mutation and r.URL.Query() for a // search form, never r.FormValue, so the query string cannot shadow a // body field. Repeated names on a scalar field take the first value, as // Go's own form parsing does; a multi-valued control does not exist. func (s FormSpec) Parse(r *http.Request) (Values, *Errors) { return s.ParseSide(r, BothSides, nil) } // ParseWith is Parse with the option sets of the fields whose options are // loaded per request (a picker of the deployment's entitlement sets). A // submitted value outside a field's options is a refusal, never a silent // default (finding FA-8). func (s FormSpec) ParseWith(r *http.Request, options map[string][]Option) (Values, *Errors) { return s.ParseSide(r, BothSides, options) } // ParseSide is ParseWith restricted to the fields that render on one side, // so a create submission cannot carry an edit-only field and an edit // submission cannot carry a create-only one. The side the form rendered is // the side it is read as; BothSides reads every declared field, which is // right for a form with one side. func (s FormSpec) ParseSide(r *http.Request, side Side, options map[string][]Option) (Values, *Errors) { values := NewValues() errs := NewErrors() var source map[string][]string if s.Kind == KindSearch { source = r.URL.Query() } else { if err := r.ParseForm(); err != nil { errs.Form("The form could not be read. Please try again.") return values, errs } source = r.PostForm } fields := make([]Field, 0, len(s.Fields)) for _, f := range s.Fields { if side != BothSides && !f.RendersOn(side) { continue } if f.Control == Static { // A static row states a fact where a control would be; it // carries no name, submits nothing, and is never parsed // (design D21). Reading one would invent a value the page // never offered. continue } fields = append(fields, f) } s.parseFields(source, fields, "", "", options, &values, errs) return values, errs } // parseFields reads one group of fields out of source and applies the // declaration's rules to each. namePrefix is what the submitted names carry // ahead of the field name and errPrefix what the error keys do: both empty // for an ordinary form, "rows.." or "staged.." for one group // of a batch, so one set of rules reads a form, a row and a delta alike and // none of them can drift from the others. func (s FormSpec) parseFields(source map[string][]string, fields []Field, namePrefix, errPrefix string, options map[string][]Option, out *Values, errs *Errors) { values := out for _, f := range fields { key := errPrefix + f.Name submitted := namePrefix + f.Name if !f.Shown(*values) { // A field the operator never saw (its ShowIf condition on an // earlier field's value did not match) is neither validated // nor carried into Values: the control was not on the page, // so nothing was there to type (design D2, ShowIf's doc on // the trust model). ShowIf.Field must therefore be declared // earlier in Fields, so its value is already in Values here. continue } vals, present := source[submitted] raw := "" if present && len(vals) > 0 { raw = vals[0] } raw = strings.TrimSpace(raw) if f.Control == Checkbox { // A checkbox's absence is false and never an error; a ticked // box submits the declared value (lessons L§21, L§34). on := present && raw == f.CheckboxValue() values.raw[f.Name] = raw values.present[f.Name] = present values.setTyped(f.Name, on) continue } values.raw[f.Name] = raw values.present[f.Name] = present opts := f.Options if runtime, ok := options[f.Name]; ok { opts = runtime } if f.Control == Select || f.Control == Radio { if !optionExists(opts, raw) { if raw == "" { // A Radio group has no "choose nothing" member the way a // Select's enabled empty option does (render.go's // fieldAttrs mirrors this): an Optional radio group left // untouched is a legitimate empty submission, not a // missing choice. if f.Control == Radio && f.Optional { continue } errs.Field(key, chooseMessage(f)) } else { errs.Field(key, "Choose "+article(f.Label)+" from the list.") } continue } if raw == "" { // A deliberate empty option (the edit side's "None") is a // legitimate submission that clears the column. continue } s.assignTyped(values, f, key, raw, errs) continue } if raw == "" { if !f.Optional { errs.Field(key, enterMessage(f)) } continue } if f.MaxLen > 0 && len([]rune(raw)) > f.MaxLen { errs.Field(key, fmt.Sprintf("%s must be %d characters or fewer.", f.Label, f.MaxLen)) continue } if f.Pattern != "" { re, err := regexp.Compile(f.Pattern) if err != nil || !re.MatchString(raw) { msg := f.PatternHint if msg == "" { msg = f.Label + " is not in the expected format." } errs.Field(key, msg) continue } } s.assignTyped(values, f, key, raw, errs) } } // assignTyped converts one passing raw value and applies the range rules // the control has. A field that fails here keeps its raw text for the // re-render and gets no typed value (lesson L§11, finding FA-23). func (s FormSpec) assignTyped(values *Values, f Field, key, raw string, errs *Errors) { switch f.Control { case Number: n, err := strconv.Atoi(raw) if err != nil { errs.Field(key, f.Label+" must be a whole number.") return } if f.Min != "" { if min, err := strconv.Atoi(f.Min); err == nil && n < min { errs.Field(key, fmt.Sprintf("%s must be %d or more.", f.Label, min)) return } } if f.Max != "" { if max, err := strconv.Atoi(f.Max); err == nil && n > max { errs.Field(key, fmt.Sprintf("%s must be %d or fewer.", f.Label, max)) return } } values.setTyped(f.Name, n) case Date, DateTime: layout := "2006-01-02" if f.Control == DateTime { layout = "2006-01-02T15:04" } t, err := time.Parse(layout, raw) if err != nil { errs.Field(key, f.Label+" is not a valid date.") return } if f.Min != "" { if min, err := time.Parse(layout, f.Min); err == nil && t.Before(min) { errs.Field(key, f.Label+" is earlier than this form allows.") return } } if f.Max != "" { if max, err := time.Parse(layout, f.Max); err == nil && t.After(max) { errs.Field(key, f.Label+" is later than this form allows.") return } } values.setTyped(f.Name, t) case Select, Radio, Hidden: // A picker's values are usually identifiers, read back with // Values.UUID. Parsing here keeps the handler from parsing a // second time and disagreeing about what a bad value means; a // value that is not an identifier simply has no typed form, which // is correct for the pickers whose values are enum strings. if id, err := uuid.Parse(raw); err == nil { values.setTyped(f.Name, id) } } } // optionExists reports whether value is one the control offered as a // choice. A disabled option is not one: it is the "Choose a ..." // placeholder, which a browser will not submit and the server must not // accept, so a select that needs a choice refuses the empty value while an // optional select, whose first option is an enabled "None", accepts it. func optionExists(options []Option, value string) bool { for _, o := range options { if o.Disabled { continue } if o.Value == value { return true } } return false } // enterMessage is the refusal for a required text-shaped field. func enterMessage(f Field) string { return "Enter " + article(f.Label) + "." } // chooseMessage is the refusal for a select or a radio group. func chooseMessage(f Field) string { return "Choose " + article(f.Label) + "." } // article lowercases a label and prefixes the indefinite article, so // "Entitlement set" becomes "an entitlement set" and the two refusal // messages read as sentences rather than as field names. func article(label string) string { if label == "" { return "a value" } lower := strings.ToLower(label) switch lower[0] { case 'a', 'e', 'i', 'o', 'u': return "an " + lower } return "a " + lower } // Batch is one request through a batch form's declaration (design D1's // "Rows family contract"; spec form-library "A batch form renders the // caller's rows and one tray"). Every posting control of a Rows form sends // the whole form, so one request carries what was pressed, every open // editor's typed values, the whole staged batch and the tray's own fields; // ParseBatch reads all four through the declaration and nothing else. // // ParseBatch validates shape, not domain: the handler decides what Act // means, re-reads the records the batch names, and refuses a delta whose // record has moved under it (design D3). type Batch struct { // Act is what was pressed and Key the record it was pressed on, from // the row action's own hx-vals. Both are the library's names, not // declared fields: a declaration names controls, and these name the // press. Act string Key string // Open are the record keys whose editors were open, in the order the // hidden inputs carried them. Open []string // Rows are those editors' values, keyed by record key, each read // through the row fields' own rules. Rows map[string]Values // Staged is the batch as submitted, in index order, each delta's values // read through the row and delta fields' rules. Line and Error are the // render's, never the request's, so both are empty here. Staged []StagedDelta // Tray holds the tray's own fields. Tray Values // Errors are the refusals, keyed "rows..", // "staged..", the tray field's own name, and the form level. Errors *Errors } // Row returns one open editor as the render wants it: its values, and its // refusals under the declaration's bare field names, so the handler hands // back what it parsed without re-keying anything by hand. func (b Batch) Row(instance string) RowBinding { rb := RowBinding{Values: b.Rows[instance], Errors: NewErrors()} prefix := "rows." + instance + "." for key, msg := range b.Errors.fields { if strings.HasPrefix(key, prefix) { rb.Errors.Field(strings.TrimPrefix(key, prefix), msg) } } return rb } // maxStagedDeltas caps how many deltas one request may carry. The render // writes one group per staged change and a set holds tens of rules, so the // cap is out of reach in use; it exists because the groups arrive as // numbered names a forged body could number as high as it likes. const maxStagedDeltas = 200 // ParseBatch reads a batch form's request through its declaration. func (s FormSpec) ParseBatch(r *http.Request) Batch { return s.ParseBatchWith(r, nil) } // ParseBatchWith is ParseBatch with the option sets of the fields whose // options are loaded per request, exactly as ParseWith is to Parse: a // submitted value outside a field's options is a refusal. A batch form // whose select offers a per-record option (a stored value the console // offers back rather than writes) passes every value it ever renders here, // because one request carries every row and every delta at once. func (s FormSpec) ParseBatchWith(r *http.Request, options map[string][]Option) Batch { if s.Family != Rows { panic(fmt.Sprintf("forms: %s is a %s form; ParseBatch reads a batch form", s.Name, s.Family)) } batch := Batch{Rows: map[string]Values{}, Tray: NewValues(), Errors: NewErrors()} if err := r.ParseForm(); err != nil { batch.Errors.Form("The form could not be read. Please try again.") return batch } source := r.PostForm batch.Act = strings.TrimSpace(firstValue(source, "act")) batch.Key = strings.TrimSpace(firstValue(source, "key")) seen := map[string]bool{} for _, raw := range source["open"] { instance := strings.TrimSpace(raw) if instance == "" || seen[instance] { continue } seen[instance] = true batch.Open = append(batch.Open, instance) } rowFields := s.placed(PlaceRow) deltaFields := s.placed(PlaceRow, PlaceDelta) trayFields := s.placed(PlaceTray) // A row that is not open submits nothing and is not read: its record is // on the page as text, and requiredness applies to what someone typed. for _, instance := range batch.Open { prefix := "rows." + instance + "." values := NewValues() s.parseFields(source, rowFields, prefix, prefix, options, &values, batch.Errors) batch.Rows[instance] = values } for i := 0; ; i++ { prefix := stagedPrefix(i) + "." _, hasVerb := source[prefix+"verb"] _, hasKey := source[prefix+"key"] if !hasVerb && !hasKey { break } if i >= maxStagedDeltas { batch.Errors.Form(fmt.Sprintf("A batch holds at most %d changes.", maxStagedDeltas)) break } values := NewValues() s.parseFields(source, deltaFields, prefix, prefix, options, &values, batch.Errors) batch.Staged = append(batch.Staged, StagedDelta{ Verb: strings.TrimSpace(firstValue(source, prefix+"verb")), Key: strings.TrimSpace(firstValue(source, prefix+"key")), Values: values, }) } s.parseFields(source, trayFields, "", "", options, &batch.Tray, batch.Errors) return batch } // placed returns the declared fields with one of these placements, in // declaration order, with the static rows dropped for the same reason Parse // drops them: a row that states a fact submits nothing. func (s FormSpec) placed(placements ...Placement) []Field { want := map[Placement]bool{} for _, p := range placements { want[p] = true } out := make([]Field, 0, len(s.Fields)) for _, f := range s.Fields { if f.Control == Static || !want[f.Placement] { continue } out = append(out, f) } return out } // firstValue returns the first submitted value under a name, as Go's own // form parsing does for a scalar field. func firstValue(source map[string][]string, name string) string { if vals, ok := source[name]; ok && len(vals) > 0 { return vals[0] } return "" }