From 3666bada0fb0e4937ee910e807c5ed1bf96e6426 Mon Sep 17 00:00:00 2001 From: "Silvio J. Gutierrez" Date: Sun, 1 Feb 2026 14:35:10 +0000 Subject: [PATCH 1/6] Update for modern Go (1.21+) - Add go.mod for module support - Add shell.nix for Nix-based development environment - Replace deprecated ioutil.ReadAll with io.ReadAll - Fix tests to use & instead of ; as query separator (Go 1.17+ security fix) Co-Authored-By: Claude Opus 4.5 --- decode.go | 3 +-- form_test.go | 2 +- go.mod | 3 +++ shell.nix | 18 ++++++++++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 go.mod create mode 100644 shell.nix diff --git a/decode.go b/decode.go index dd8bd4f..6f0a441 100644 --- a/decode.go +++ b/decode.go @@ -7,7 +7,6 @@ package form import ( "fmt" "io" - "io/ioutil" "net/url" "reflect" "strconv" @@ -42,7 +41,7 @@ func (d *Decoder) EscapeWith(r rune) *Decoder { // Decode reads in and decodes form-encoded data into dst. func (d Decoder) Decode(dst interface{}) error { - bs, err := ioutil.ReadAll(d.r) + bs, err := io.ReadAll(d.r) if err != nil { return err } diff --git a/form_test.go b/form_test.go index 0ceb4e5..36408ce 100644 --- a/form_test.go +++ b/form_test.go @@ -126,7 +126,7 @@ func testCases(dir direction) (cs []testCase) { var T time.Time var U url.URL const canonical = `A.0=x&A.1=y&A.2=z&B=true&C=42%2B6.6i&E.Bytes1=%00%01%02&E.Bytes2=%03%04%05&F=6.6&M.Bar=8&M.Foo=7&M.Qux=9&P%5C.D%5C%5CQ%5C.B.A=P%2FD&P%5C.D%5C%5CQ%5C.B.B=Q-B&R=8734&S=Hello%2C+there.&T=2013-10-01T07%3A05%3A34.000000088Z&U=http%3A%2F%2Fexample.org%2Ffoo%23bar&Zs.0.Q=11_22&Zs.0.Qp=33_44&Zs.0.Z=2006-12-01&life=42` - const variation = `;C=42%2B6.6i;A.0=x;M.Bar=8;F=6.6;A.1=y;R=8734;A.2=z;Zs.0.Qp=33_44;B=true;M.Foo=7;T=2013-10-01T07:05:34.000000088Z;E.Bytes1=%00%01%02;Bytes2=%03%04%05;Zs.0.Q=11_22;Zs.0.Z=2006-12-01;M.Qux=9;life=42;S=Hello,+there.;P\.D\\Q\.B.A=P/D;P\.D\\Q\.B.B=Q-B;U=http%3A%2F%2Fexample.org%2Ffoo%23bar;` + const variation = `C=42%2B6.6i&A.0=x&M.Bar=8&F=6.6&A.1=y&R=8734&A.2=z&Zs.0.Qp=33_44&B=true&M.Foo=7&T=2013-10-01T07:05:34.000000088Z&E.Bytes1=%00%01%02&Bytes2=%03%04%05&Zs.0.Q=11_22&Zs.0.Z=2006-12-01&M.Qux=9&life=42&S=Hello,+there.&P\.D\\Q\.B.A=P/D&P\.D\\Q\.B.B=Q-B&U=http%3A%2F%2Fexample.org%2Ffoo%23bar` for _, c := range []testCase{ // Bools diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a634d43 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/ajg/form + +go 1.21 diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..cee1bff --- /dev/null +++ b/shell.nix @@ -0,0 +1,18 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = with pkgs; [ + go + ]; + + shellHook = '' + echo "Go development environment ready" + echo "Go version: $(go version)" + echo "" + echo "Available commands:" + echo " go build ./... - Build the package" + echo " go test ./... - Run tests" + echo " go test -v ./... - Run tests with verbose output" + echo " go test -cover ./... - Run tests with coverage" + ''; +} From c595a80c2a78b1ed713e0ea458ec1bb7e13b890b Mon Sep 17 00:00:00 2001 From: "Silvio J. Gutierrez" Date: Sun, 1 Feb 2026 14:40:08 +0000 Subject: [PATCH 2/6] Add CI workflow for PRs and main Runs build, test, and vet using Nix shell environment. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c158ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: nixbuild/nix-quick-install-action@v34 + + - name: Setup Nix path + run: echo "NIX_PATH=nixpkgs=channel:nixos-unstable" >> $GITHUB_ENV + + - name: Restore and cache Nix store + uses: nix-community/cache-nix-action@v6.1.3 + with: + primary-key: nix-${{ runner.os }}-${{ hashFiles('shell.nix') }} + + - name: Build + run: nix-shell --run "go build ./..." + + - name: Test + run: nix-shell --run "go test -v -cover ./..." + + - name: Vet + run: nix-shell --run "go vet ./..." From ab3a146d31c9c4e32edf201b3ab3c56ed4dde751 Mon Sep 17 00:00:00 2001 From: "Silvio J. Gutierrez" Date: Sun, 1 Feb 2026 14:42:39 +0000 Subject: [PATCH 3/6] Pin nixpkgs version for reproducible builds Uses commit 8c5066250910 instead of channel for deterministic builds. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 3 --- shell.nix | 6 ++++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c158ff..3cbb9f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,6 @@ jobs: - uses: nixbuild/nix-quick-install-action@v34 - - name: Setup Nix path - run: echo "NIX_PATH=nixpkgs=channel:nixos-unstable" >> $GITHUB_ENV - - name: Restore and cache Nix store uses: nix-community/cache-nix-action@v6.1.3 with: diff --git a/shell.nix b/shell.nix index cee1bff..152eeee 100644 --- a/shell.nix +++ b/shell.nix @@ -1,6 +1,8 @@ -{ pkgs ? import {} }: +let + pkgs = import (fetchTarball + "https://github.com/NixOS/nixpkgs/archive/8c5066250910.tar.gz") { }; -pkgs.mkShell { +in pkgs.mkShell { buildInputs = with pkgs; [ go ]; From 4fb9545c2ae1eadbe73b47123dc346f5b5e57931 Mon Sep 17 00:00:00 2001 From: "Silvio J. Gutierrez" Date: Sun, 1 Feb 2026 14:47:55 +0000 Subject: [PATCH 4/6] Add gofmt check to CI Fails the build if any files are not properly formatted. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cbb9f2..c3b6205 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: with: primary-key: nix-${{ runner.os }}-${{ hashFiles('shell.nix') }} + - name: Format check + run: nix-shell --run "test -z \$(gofmt -l .)" + - name: Build run: nix-shell --run "go build ./..." From 7d673348189125ef98eec5e97732e061d7f37004 Mon Sep 17 00:00:00 2001 From: "Silvio J. Gutierrez" Date: Sun, 1 Feb 2026 21:44:21 +0000 Subject: [PATCH 5/6] Replace Travis CI with GitHub Actions Travis CI is no longer active. Update badge to GitHub Actions. Co-Authored-By: Claude Opus 4.5 --- .travis.yml | 25 ------------------------- README.md | 2 +- 2 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 14608c7..0000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -## Copyright 2014 Alvaro J. Genial. All rights reserved. -## Use of this source code is governed by a BSD-style -## license that can be found in the LICENSE file. - -language: go - -go: - - tip - - 1.6 - - 1.5 - - 1.4 - - 1.3 - # 1.2 - -before_install: - # - go get -v golang.org/x/tools/cmd/cover - # - go get -v golang.org/x/tools/cmd/vet - # - go get -v golang.org/x/lint/golint - - export PATH=$PATH:/home/travis/gopath/bin - -script: - - go build -v ./... - - go test -v -cover ./... - - go vet ./... - # - golint . diff --git a/README.md b/README.md index ad99be4..42383b3 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ form A Form Encoding & Decoding Package for Go, written by [Alvaro J. Genial](http://alva.ro). -[![Build Status](https://travis-ci.org/ajg/form.png?branch=master)](https://travis-ci.org/ajg/form) +[![Build Status](https://github.com/ajg/form/actions/workflows/ci.yml/badge.svg)](https://github.com/ajg/form/actions/workflows/ci.yml) [![GoDoc](https://godoc.org/github.com/ajg/form?status.png)](https://godoc.org/github.com/ajg/form) Synopsis From 1b82dad2e3de38e1036bdb7e48933426c8d53caa Mon Sep 17 00:00:00 2001 From: "Silvio J. Gutierrez" Date: Sun, 1 Feb 2026 21:56:10 +0000 Subject: [PATCH 6/6] Add OmitEmpty option to Encoder Add Encoder.OmitEmpty(bool) method that treats all struct fields as if they had the omitempty tag. This implements the first item from the Future Work section. Usage: form.NewEncoder(w).OmitEmpty(true).Encode(value) Co-Authored-By: Claude Opus 4.5 --- README.md | 1 - encode.go | 51 ++++++++++++++++++++++++++++---------------------- encode_test.go | 37 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 42383b3..b768e7c 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,6 @@ Future Work The following items would be nice to have in the future—though they are not being worked on yet: - - An option to treat all values as if they had been tagged with `omitempty`. - An option to automatically treat all field names in `camelCase` or `underscore_case`. - Built-in support for the types in [`math/big`](http://golang.org/pkg/math/big/). - Built-in support for the types in [`image/color`](http://golang.org/pkg/image/color/). diff --git a/encode.go b/encode.go index 2eb38fc..302d993 100644 --- a/encode.go +++ b/encode.go @@ -18,7 +18,7 @@ import ( // NewEncoder returns a new form Encoder. func NewEncoder(w io.Writer) *Encoder { - return &Encoder{w, defaultDelimiter, defaultEscape, false} + return &Encoder{w, defaultDelimiter, defaultEscape, false, false} } // Encoder provides a way to encode to a Writer. @@ -27,6 +27,7 @@ type Encoder struct { d rune e rune z bool + o bool } // DelimitWith sets r as the delimiter used for composite keys by Encoder e and returns the latter; it is '.' by default. @@ -47,10 +48,16 @@ func (e *Encoder) KeepZeros(z bool) *Encoder { return e } +// OmitEmpty sets whether Encoder e should omit empty (zero) struct fields during encoding, and returns the former; this is equivalent to having ",omitempty" on every field. By default, empty fields are included. +func (e *Encoder) OmitEmpty(o bool) *Encoder { + e.o = o + return e +} + // Encode encodes dst as form and writes it out using the Encoder's Writer. func (e Encoder) Encode(dst interface{}) error { v := reflect.ValueOf(dst) - n, err := encodeToNode(v, e.z) + n, err := encodeToNode(v, e.z, e.o) if err != nil { return err } @@ -72,7 +79,7 @@ func EncodeToString(dst interface{}, needEmptyValue ...bool) (string, error) { if len(needEmptyValue) != 0 { z = needEmptyValue[0] } - n, err := encodeToNode(v, z) + n, err := encodeToNode(v, z, false) if err != nil { return "", err } @@ -87,7 +94,7 @@ func EncodeToValues(dst interface{}, needEmptyValue ...bool) (url.Values, error) if len(needEmptyValue) != 0 { z = needEmptyValue[0] } - n, err := encodeToNode(v, z) + n, err := encodeToNode(v, z, false) if err != nil { return nil, err } @@ -95,16 +102,16 @@ func EncodeToValues(dst interface{}, needEmptyValue ...bool) (url.Values, error) return vs, nil } -func encodeToNode(v reflect.Value, z bool) (n node, err error) { +func encodeToNode(v reflect.Value, z bool, o bool) (n node, err error) { defer func() { if e := recover(); e != nil { err = fmt.Errorf("%v", e) } }() - return getNode(encodeValue(v, z)), nil + return getNode(encodeValue(v, z, o)), nil } -func encodeValue(v reflect.Value, z bool) interface{} { +func encodeValue(v reflect.Value, z bool, o bool) interface{} { t := v.Type() k := v.Kind() @@ -116,20 +123,20 @@ func encodeValue(v reflect.Value, z bool) interface{} { switch k { case reflect.Ptr, reflect.Interface: - return encodeValue(v.Elem(), z) + return encodeValue(v.Elem(), z, o) case reflect.Struct: if t.ConvertibleTo(timeType) { return encodeTime(v) } else if t.ConvertibleTo(urlType) { return encodeURL(v) } - return encodeStruct(v, z) + return encodeStruct(v, z, o) case reflect.Slice: - return encodeSlice(v, z) + return encodeSlice(v, z, o) case reflect.Array: - return encodeArray(v, z) + return encodeArray(v, z, o) case reflect.Map: - return encodeMap(v, z) + return encodeMap(v, z, o) case reflect.Invalid, reflect.Uintptr, reflect.UnsafePointer, reflect.Chan, reflect.Func: panic(t.String() + " has unsupported kind " + t.Kind().String()) default: @@ -137,7 +144,7 @@ func encodeValue(v reflect.Value, z bool) interface{} { } } -func encodeStruct(v reflect.Value, z bool) interface{} { +func encodeStruct(v reflect.Value, z bool, o bool) interface{} { t := v.Type() n := node{} for i := 0; i < t.NumField(); i++ { @@ -146,40 +153,40 @@ func encodeStruct(v reflect.Value, z bool) interface{} { if k == "-" { continue - } else if fv := v.Field(i); oe && isEmptyValue(fv) { + } else if fv := v.Field(i); (o || oe) && isEmptyValue(fv) { delete(n, k) } else { - n[k] = encodeValue(fv, z) + n[k] = encodeValue(fv, z, o) } } return n } -func encodeMap(v reflect.Value, z bool) interface{} { +func encodeMap(v reflect.Value, z bool, o bool) interface{} { n := node{} for _, i := range v.MapKeys() { - k := getString(encodeValue(i, z)) - n[k] = encodeValue(v.MapIndex(i), z) + k := getString(encodeValue(i, z, o)) + n[k] = encodeValue(v.MapIndex(i), z, o) } return n } -func encodeArray(v reflect.Value, z bool) interface{} { +func encodeArray(v reflect.Value, z bool, o bool) interface{} { n := node{} for i := 0; i < v.Len(); i++ { - n[strconv.Itoa(i)] = encodeValue(v.Index(i), z) + n[strconv.Itoa(i)] = encodeValue(v.Index(i), z, o) } return n } -func encodeSlice(v reflect.Value, z bool) interface{} { +func encodeSlice(v reflect.Value, z bool, o bool) interface{} { t := v.Type() if t.Elem().Kind() == reflect.Uint8 { return string(v.Bytes()) // Encode byte slices as a single string by default. } n := node{} for i := 0; i < v.Len(); i++ { - n[strconv.Itoa(i)] = encodeValue(v.Index(i), z) + n[strconv.Itoa(i)] = encodeValue(v.Index(i), z, o) } return n } diff --git a/encode_test.go b/encode_test.go index 885c80c..10326d5 100644 --- a/encode_test.go +++ b/encode_test.go @@ -99,3 +99,40 @@ func TestEncode_KeepZero(t *testing.T) { } } } + +func TestEncode_OmitEmpty(t *testing.T) { + num := uint(0) + nonZeroNum := uint(42) + for _, c := range []struct { + b interface{} + s string + o bool + }{ + // Thing3 and Thing4 have no omitempty tags, so OmitEmpty affects them. + {Thing3{"test", &nonZeroNum}, "name=test&num=42", false}, + {Thing3{"test", &nonZeroNum}, "name=test&num=42", true}, + {Thing3{"", &nonZeroNum}, "name=&num=42", false}, + {Thing3{"", &nonZeroNum}, "num=42", true}, + {Thing3{"test", nil}, "name=test&num=", false}, + {Thing3{"test", nil}, "name=test", true}, + {Thing4{"test", 0}, "name=test&num=", false}, + {Thing4{"test", 0}, "name=test", true}, + {Thing4{"test", 42}, "name=test&num=42", false}, + {Thing4{"test", 42}, "name=test&num=42", true}, + // Thing1 and Thing2 already have omitempty tags. + {Thing1{"test", &num}, "name=test&num=", false}, + {Thing1{"test", &num}, "name=test&num=", true}, + {Thing2{"test", 0}, "name=test", false}, + {Thing2{"test", 0}, "name=test", true}, + } { + + var w bytes.Buffer + e := NewEncoder(&w) + + if err := e.OmitEmpty(c.o).Encode(c.b); err != nil { + t.Errorf("OmitEmpty(%#v).Encode(%#v): %s", c.o, c.b, err) + } else if s := w.String(); c.s != s { + t.Errorf("OmitEmpty(%#v).Encode(%#v)\n want (%#v)\n have (%#v)", c.o, c.b, c.s, s) + } + } +}