Files

373 lines
8.7 KiB
Go

// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package forms
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"reflect"
"slices"
"strings"
)
var (
_ FormBinder = (*Form)(nil)
_ ContextHolder = (*Form)(nil)
)
// Error definitions.
var (
ErrFormIsBound = errors.New("form is already bound")
ErrFormInvalidInput = errors.New("invalid input data")
ErrUnknownContentType = errors.New("unknown content type")
ErrUnexpected = Gettext("an unexpected error has occurred")
)
// MimeType is a mime type forms can be loaded from.
type MimeType string
// Common input mime types.
const (
MimeJSON MimeType = "application/json"
MimeURLEncoded MimeType = "application/x-www-form-urlencoded"
MimeMultipart MimeType = "multipart/form-data"
)
type marshalledForm struct {
IsValid bool `json:"is_valid"`
Errors Errors `json:"errors"`
Fields map[string]Binder `json:"fields"`
}
// ValidateChecker describes a type that can bring its own validation method.
// The returned error is added to the error list. To return several errors at once,
// one can use [Errors] or [errors.Join].
type ValidateChecker interface {
Validate() error
}
// FormBinder is the interface implented by types that can
// act as a form.
type FormBinder interface {
Fields() map[string]Binder
Errors() Errors
AddErrors(...error)
IsBound() bool
Bind()
IsValid() bool
}
// Form is our based type for form composition.
type Form struct {
bound bool
valid *bool
instance FormBinder
fields map[string]Binder
errors Errors
ctx context.Context
}
// New prepares and returns a new instance of T.
// It panics if T is not a struct implementing [FormBinder]
// or when a field's "validate" tag does not exist.
func New[T any](ctx context.Context, options ...func(FormBinder)) *T {
t := reflect.TypeFor[T]()
// Must be a struct
if t.Kind() != reflect.Struct {
panic("type is not a struct")
}
// Must be a [FormBinder].
if !reflect.PointerTo(t).Implements(reflect.TypeFor[FormBinder]()) {
panic("type is not a forms.FormBinder")
}
form := new(T)
if form, ok := any(form).(ContextHolder); ok {
form.SetContext(ctx)
}
// Set each field and a field list for the form
fields := map[string]Binder{}
for info := range recurseStructFields(reflect.ValueOf(form).Elem(), "") {
if info.field.Tag.Get("json") == "-" {
continue
}
// Only [Binder] fields are allowed.
if !info.value.Addr().Type().Implements(reflect.TypeFor[Binder]()) {
continue
}
// Prepare field
field := info.value.Addr().Interface()
if field, ok := field.(interface{ SetName(string) }); ok {
field.SetName(info.name)
}
if field, ok := field.(ContextHolder); ok {
field.SetContext(ctx)
}
// Add validators from tag
if t := info.field.Tag.Get(ValidateTagName); len(t) > 0 {
if field, ok := field.(ValidatorsProvider); ok {
// Note: we don't set validators at once with [slices.Collect] because each
// iteration of [collectValidators] can have side effects.
// Thus, we must append to the existing validators, each time.
for v := range collectValidators(ctx, any(form).(FormBinder), field.(Binder), strings.Fields(t)...) {
field.SetValidators(append(field.Validators(), v))
}
}
}
fields[info.name] = field.(Binder)
}
// Set fields
if form, ok := any(form).(interface{ SetFields(map[string]Binder) }); ok {
form.SetFields(fields)
}
// Set form
if f, ok := any(form).(interface{ SetInstance(FormBinder) }); ok {
f.SetInstance(any(form).(FormBinder))
}
for _, fn := range options {
fn(any(form).(FormBinder))
}
return form
}
// Fields returns the form's registered [Binder] fields.
// Their respective name matches the name used during
// [url.Values] unmarshaling
// (with dot separated prefix and name for nested values).
func (f *Form) Fields() map[string]Binder {
return f.fields
}
// SetFields is used by [New] and will panic if called more than once.
func (f *Form) SetFields(fields map[string]Binder) {
if f.fields != nil {
panic("can't mutate form's field list")
}
f.fields = fields
}
// SetInstance is used by [New] and will panic if called more than once.
func (f *Form) SetInstance(instance FormBinder) {
if f.instance != nil {
panic("can't set Form.instance more than once")
}
f.instance = instance
}
// Context returns the form's context.
func (f *Form) Context() context.Context {
return f.ctx
}
// SetContext sets the form's context. It implements [ContextHolder].
func (f *Form) SetContext(ctx context.Context) {
f.ctx = ctx
}
// Errors return a flat list of errors.
func (f Form) Errors() Errors {
return slices.Collect(IterErrorsTr(f.ctx, f.errors))
}
// AddErrors adds errors to the form.
func (f *Form) AddErrors(errs ...error) {
for _, err := range errs {
if err != nil {
f.errors = append(f.errors, err)
}
}
}
// IsBound returns whether the form is bound.
func (f *Form) IsBound() bool {
return f.bound
}
// Bind marks the form as bound.
func (f *Form) Bind() {
f.bound = true
}
// IsValid returns true when the form is valid.
func (f *Form) IsValid() bool {
if !f.IsBound() {
return true
}
if f.valid == nil {
// We only run validators once
f.valid = new(true)
for _, field := range f.fields {
// This runs the field's validators
if field, ok := field.(ValidatorProvider); ok {
field.IsValid()
}
// Call Validate() on each field when they implement it.
if field, ok := field.(ValidateChecker); ok {
if err := field.Validate(); err != nil {
if field, ok := field.(interface{ AddErrors(...error) }); ok {
field.AddErrors(err)
}
}
}
}
// Call Validate() on the actual form if it implements it.
if form, ok := f.instance.(ValidateChecker); ok {
if err := form.Validate(); err != nil {
f.AddErrors(err)
}
}
}
// Check for each field error list.
for _, field := range f.fields {
if len(field.Errors()) > 0 {
*f.valid = false
break
}
}
return len(f.errors) == 0 && *f.valid
}
// MarshalJSON implement [json.Marshaler].
func (f *Form) MarshalJSON() ([]byte, error) {
res := marshalledForm{
IsValid: f.IsValid(),
Errors: slices.Collect(IterErrorsTr(f.ctx, f.Errors())),
Fields: f.fields,
}
return json.Marshal(res)
}
// MarshalValues calls [MarshalValues] on the form's concrete instance.
func (f *Form) MarshalValues() map[string]any {
return MarshalValues(f.instance)
}
// MarshalValues returns a recursive map of all values implementing [Binder].
// It panics when "in" is not a struct.
func MarshalValues(in any) map[string]any {
res := map[string]any{}
for info := range iterStructFields(reflect.ValueOf(in).Elem()) {
if v, ok := info.value.Interface().(Binder); ok {
res[info.name] = v.V()
continue
}
if info.field.Type.Kind() == reflect.Struct {
res[info.name] = MarshalValues(info.value.Addr().Interface())
}
}
return res
}
// Bind loads the data using the method tied
// to the request's content-type header.
func Bind(r *http.Request, f FormBinder) {
if f.IsBound() {
f.AddErrors(ErrFormIsBound)
return
}
f.Bind()
mediaType, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
// Default to application/x-www-form-urlencoded.
if mediaType == "" {
mediaType = string(MimeURLEncoded)
}
if fn, ok := RequestLoaders[mediaType]; ok {
if err := fn(r, f); err != nil {
f.AddErrors(err)
}
if r.Body != nil {
_ = r.Body.Close()
}
return
}
f.AddErrors(ErrUnknownContentType)
}
// BindValues loads the data from a [url.Values] input.
// This can be used to load values only from the URL's query string.
func BindValues(values url.Values, f FormBinder) {
if f.IsBound() {
f.AddErrors(ErrFormIsBound)
return
}
f.Bind()
if err := UnmarshalURLValues(values, f); err != nil {
f.AddErrors(err)
}
}
// BindAs combines [New] and [Bind] in one step, returning
// the newly created form.
func BindAs[T any](r *http.Request, options ...func(FormBinder)) *T {
form := New[T](r.Context(), options...)
Bind(r, any(form).(FormBinder))
return form
}
func unmarshalMultipart(r *http.Request, v any) (err error) {
if r.MultipartForm == nil {
if err = r.ParseMultipartForm(16 << 20); err != nil {
return err
}
}
// Bind the file fields
if v, ok := v.(FormBinder); ok {
fields := v.Fields()
if len(fields) == 0 {
goto URLValues
}
for name, headers := range r.MultipartForm.File {
if len(headers) == 0 {
continue
}
field, ok := fields[name]
if !ok {
continue
}
if field, ok := field.(FilesUnmarshaler); ok {
if err := field.UnmarshalFiles(headers); err != nil {
return err
}
}
}
}
// Bind the [url.Values]
URLValues:
return UnmarshalURLValues(r.Form, v)
}