Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

To ensure the continued stability of this package, tests are required that cover the change in order for a pull request to be merged.

## AI Agents

AI-assisted contributions are welcome but must be clearly attributed. If you are an AI agent or using an AI agent to contribute:

- **Commits**: Include a `Co-Authored-By:` trailer identifying the AI model used.
- **Pull requests and issues**: State that the content was AI-generated or AI-assisted in the description.
- **Code review comments**: Prefix with `[AI]` when posting automated review comments.

Contributions that obscure their AI origin may be closed.

## Reporting issues

Please open an issue or join the gitter chat [![Join the chat at https://gitter.im/go-playground/validator](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/go-playground/validator?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) for any issues, questions or possible enhancements to the package.
29 changes: 12 additions & 17 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,23 @@ jobs:
test:
strategy:
matrix:
go-version: [1.24.x, 1.25.x]
go-version: [1.26.x, 1.27.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}

- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Restore Cache
uses: actions/cache@v4
- name: Install Go
uses: actions/setup-go@v7
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-v1-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-v1-go-
go-version: ${{ matrix.go-version }}

- name: Test
run: go test -race -covermode=atomic -coverprofile="profile.cov" ./...

- name: Send Coverage
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.20.x'
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.26.x'
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: profile.cov
Expand All @@ -41,8 +33,11 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: 1.26.x
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v9
with:
version: latest
version: v2.13.1
56 changes: 56 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

`github.com/go-playground/form/v4` — a Go library that decodes `url.Values` into Go structs and encodes Go structs back into `url.Values`. Designed for HTML form parsing. Zero non-test dependencies. Minimum Go 1.21.

## Commands

```bash
make # lint + test + bench
make lint # golangci-lint run --timeout 5m
make test # go test -covermode=atomic -race ./...
make bench # go test -run=NONE -bench=. -benchmem ./...

# Single test
go test -run TestDecoderNativeTime -race -v ./...
```

## Architecture

The library has symmetrical Decode and Encode pipelines sharing a reflection cache and configuration layer.

### Core Pipeline

1. **`form_decoder.go` / `form_encoder.go`** — Public `Decoder`/`Encoder` types, configuration methods, and entry points (`Decode`/`Encode`). These own a `sync.Pool` of worker instances (`*decoder`/`*encoder`) to avoid allocations.

2. **`decoder.go` / `encoder.go`** — Worker logic. `traverseStruct()` walks cached struct metadata and calls `setFieldByType()`, a large `reflect.Kind` switch that handles all Go types including nested structs, slices, arrays, maps, pointers, and `time.Time` (RFC3339).

3. **`cache.go`** — Thread-safe struct reflection cache using `atomic.Value` with copy-on-write writes. `parseStruct()` introspects fields once per type, reads the `form` tag, and sorts results (anonymous fields last). Shared by both decoder and encoder.

4. **`form.go`** — Shared constants: `Mode` (Implicit/Explicit controls whether untagged fields are processed) and `AnonymousMode` (Embed/Separate controls whether embedded struct fields are flattened or namespaced).

5. **`util.go`** — `ExtractType()` (exported, dereferences pointers/interfaces), `parseBool()` (extended: accepts on/off/yes/no/ok), `hasValue()` (zero-value check for omitempty).

### Key Design Decisions

- **Namespace paths**: Fields resolve via dot-separated paths (`Address.Name`) with bracket notation for indices/keys (`Items[0].Price`, `Map[key]`). Prefix/suffix are configurable.
- **Lazy bracket parsing**: `parseMapData()` only parses bracketed keys when encountering a slice/array/map, avoiding overhead for simple structs.
- **Error accumulation**: Decode errors are collected in `DecodeErrors` (map of namespace→error) rather than failing fast.
- **DOS protection**: `maxArraySize` (default 10000) caps dynamically-created slices/arrays.
- **Custom type functions**: `RegisterCustomTypeFunc` lets users override decode/encode for specific types. The functions receive `[]string` (decode) or `interface{}` (encode) and return parsed values.

### Extension Points

- `RegisterCustomTypeFunc(fn, types...)` — override decode/encode per type
- `RegisterTagNameFunc(fn)` — customize how struct tags map to field names
- `SetMode()` — `ModeExplicit` requires `form` tags on all fields
- `SetAnonymousMode()` — control embedded struct flattening (encoder only)

## Testing

All tests are white-box (same package). Tests use `github.com/go-playground/assert/v2` (dot-imported). `benchmarks/` contains comparative benchmarks in a separate package. `race_test.go`/`norace_test.go` provide a build-tagged `raceEnabled` constant.

Both `Decoder` and `Encoder` are safe for concurrent use — instantiate once, share across goroutines.
52 changes: 26 additions & 26 deletions decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

const (
errArraySize = "Array size of '%d' is larger than the maximum currently set on the decoder of '%d'. To increase this limit please see, SetMaxArraySize(size uint)"
errArraySize = "array size of '%d' is larger than the maximum currently set on the decoder of '%d'. To increase this limit please see, SetMaxArraySize(size uint)"
errMissingStartBracket = "Invalid formatting for key '%s' missing '[' bracket"
errMissingEndBracket = "Invalid formatting for key '%s' missing ']' bracket"
)
Expand Down Expand Up @@ -218,7 +218,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
v.Set(reflect.ValueOf(arr[idx]))
set = true

case reflect.Ptr:
case reflect.Pointer:
newVal := reflect.New(v.Type().Elem())
if set = d.setFieldByType(newVal.Elem(), namespace, idx); set {
v.Set(newVal)
Expand All @@ -237,7 +237,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var u64 uint64
if u64, err = strconv.ParseUint(arr[idx], 10, 64); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Unsigned Integer Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid unsigned integer value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetUint(u64)
Expand All @@ -249,7 +249,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var u64 uint64
if u64, err = strconv.ParseUint(arr[idx], 10, 8); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Unsigned Integer Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid unsigned integer value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetUint(u64)
Expand All @@ -261,7 +261,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var u64 uint64
if u64, err = strconv.ParseUint(arr[idx], 10, 16); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Unsigned Integer Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid unsigned integer value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetUint(u64)
Expand All @@ -273,7 +273,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var u64 uint64
if u64, err = strconv.ParseUint(arr[idx], 10, 32); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Unsigned Integer Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid unsigned integer value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetUint(u64)
Expand All @@ -285,7 +285,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var i64 int64
if i64, err = strconv.ParseInt(arr[idx], 10, 64); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Integer Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid integer value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetInt(i64)
Expand All @@ -297,7 +297,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var i64 int64
if i64, err = strconv.ParseInt(arr[idx], 10, 8); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Integer Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid integer value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetInt(i64)
Expand All @@ -309,7 +309,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var i64 int64
if i64, err = strconv.ParseInt(arr[idx], 10, 16); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Integer Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid integer value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetInt(i64)
Expand All @@ -321,7 +321,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var i64 int64
if i64, err = strconv.ParseInt(arr[idx], 10, 32); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Integer Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid integer value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetInt(i64)
Expand All @@ -333,7 +333,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var f float64
if f, err = strconv.ParseFloat(arr[idx], 32); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Float Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid float value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetFloat(f)
Expand All @@ -345,7 +345,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var f float64
if f, err = strconv.ParseFloat(arr[idx], 64); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Float Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid float value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetFloat(f)
Expand All @@ -357,7 +357,7 @@ func (d *decoder) setFieldByType(current reflect.Value, namespace []byte, idx in
}
var b bool
if b, err = parseBool(arr[idx]); err != nil {
d.setError(namespace, fmt.Errorf("Invalid Boolean Value '%s' Type '%v' Namespace '%s'", arr[idx], v.Type(), string(namespace)))
d.setError(namespace, fmt.Errorf("invalid boolean value '%s' type '%v' namespace '%s'", arr[idx], v.Type(), string(namespace)))
return
}
v.SetBool(b)
Expand Down Expand Up @@ -636,7 +636,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)
// say to a struct value we would not get here but kind would be struct.
v.Set(reflect.ValueOf(key))
return
case reflect.Ptr:
case reflect.Pointer:
newVal := reflect.New(v.Type().Elem())
if err = d.getMapKey(key, newVal.Elem(), namespace); err == nil {
v.Set(newVal)
Expand All @@ -649,7 +649,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

u64, e := strconv.ParseUint(key, 10, 64)
if e != nil {
err = fmt.Errorf("Invalid Unsigned Integer Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid unsigned integer value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -659,7 +659,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

u64, e := strconv.ParseUint(key, 10, 8)
if e != nil {
err = fmt.Errorf("Invalid Unsigned Integer Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid unsigned integer value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -669,7 +669,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

u64, e := strconv.ParseUint(key, 10, 16)
if e != nil {
err = fmt.Errorf("Invalid Unsigned Integer Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid unsigned integer value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -679,7 +679,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

u64, e := strconv.ParseUint(key, 10, 32)
if e != nil {
err = fmt.Errorf("Invalid Unsigned Integer Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid unsigned integer value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -689,7 +689,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

i64, e := strconv.ParseInt(key, 10, 64)
if e != nil {
err = fmt.Errorf("Invalid Integer Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid integer value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -699,7 +699,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

i64, e := strconv.ParseInt(key, 10, 8)
if e != nil {
err = fmt.Errorf("Invalid Integer Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid integer value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -709,7 +709,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

i64, e := strconv.ParseInt(key, 10, 16)
if e != nil {
err = fmt.Errorf("Invalid Integer Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid integer value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -719,7 +719,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

i64, e := strconv.ParseInt(key, 10, 32)
if e != nil {
err = fmt.Errorf("Invalid Integer Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid integer value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -729,7 +729,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

f, e := strconv.ParseFloat(key, 32)
if e != nil {
err = fmt.Errorf("Invalid Float Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid float value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -739,7 +739,7 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

f, e := strconv.ParseFloat(key, 64)
if e != nil {
err = fmt.Errorf("Invalid Float Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid float value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

Expand All @@ -749,14 +749,14 @@ func (d *decoder) getMapKey(key string, current reflect.Value, namespace []byte)

b, e := parseBool(key)
if e != nil {
err = fmt.Errorf("Invalid Boolean Value '%s' Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("invalid boolean value '%s' type '%v' namespace '%s'", key, v.Type(), string(namespace))
return
}

v.SetBool(b)

default:
err = fmt.Errorf("Unsupported Map Key '%s', Type '%v' Namespace '%s'", key, v.Type(), string(namespace))
err = fmt.Errorf("unsupported map key '%s', type '%v' namespace '%s'", key, v.Type(), string(namespace))
}

return
Expand Down
Loading
Loading