From 02473a79cca797cd4a1fadc83204e1d6eb64066e Mon Sep 17 00:00:00 2001 From: remvn <34063162+remvn@users.noreply.github.com> Date: Tue, 24 Sep 2024 21:59:23 +0700 Subject: [PATCH] custom type's gotcha in go-validator --- config/_default/markup.yaml | 2 +- config/_default/params.yaml | 4 +- .../go-validator-feature.jpg | Bin 0 -> 4437 bytes .../index.en.md | 207 ++++++++++++++++++ 4 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 content/posts/custom-type-gotcha-in-go-validator/go-validator-feature.jpg create mode 100644 content/posts/custom-type-gotcha-in-go-validator/index.en.md diff --git a/config/_default/markup.yaml b/config/_default/markup.yaml index a109a6d..5a19a04 100644 --- a/config/_default/markup.yaml +++ b/config/_default/markup.yaml @@ -5,7 +5,7 @@ goldmark: renderer: unsafe: true highlight: - noClasses: false + noClasses: true tableOfContents: startLevel: 2 endLevel: 4 diff --git a/config/_default/params.yaml b/config/_default/params.yaml index 90255ba..52fead1 100644 --- a/config/_default/params.yaml +++ b/config/_default/params.yaml @@ -10,7 +10,7 @@ defaultAppearance: light autoSwitchAppearance: true defaultThemeColor: "#FFFFFF" enableSearch: false -enableCodeCopy: true +enableCodeCopy: false enableImageLazyLoading: true enableImageWebp: true fingerprintAlgorithm: sha256 @@ -49,7 +49,7 @@ article: invertPagination: false showReadingTime: true showTableOfContents: true - showTaxonomies: false + showTaxonomies: true showWordCount: false showComments: false diff --git a/content/posts/custom-type-gotcha-in-go-validator/go-validator-feature.jpg b/content/posts/custom-type-gotcha-in-go-validator/go-validator-feature.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fa024b538aa2c52de116326d5a1574fb2e895232 GIT binary patch literal 4437 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 100+ Struct and Field validation, including Cross Field, Cross Struct, Map, +> Slice and Array diving + +One common use-case you may think of is validating `request body` of an API +Endpoint + +```go +func makeValidator() *validator.Validate { + validate := validator.New(validator.WithRequiredStructEnabled()) + return validate +} + +func TestSimpleStruct(t *testing.T) { + // check `Name` length is greater than 10 + type requestBody struct { + Name string `validate:"required,gt=10"` + } + validate := makeValidator() + + body := &requestBody{ + Name: "short", + } + + err := validate.Struct(body) + assert.Error(t, err, "should raise an error because name is less than 10") +} +``` + +## Gotcha of custom type + +Go's lack of `nullability` makes us rely on some alternatives to represent +`nullability` in Golang {{< emoji "beat_brick" >}}. Two methods I know of are: +- Pointer +- Custom struct with a `Valid` field (check this type at [std + library](https://pkg.go.dev/database/sql#NullString)) + +If you happen to need the use of `nullability` and you see this: +> `go-validator` README
+> Handles custom field types such as sql driver Valuer see +> [Valuer](https://go.dev/src/database/sql/driver/types.go?s=1210:1293#L29) + +Then you might think it has support for anything that implements interface +`driver.Valuer` out of the box, like `sql.NullString` which I mentioned earlier + +But those simple unit-tests say otherwise: + +### 1st test + +```go +func TestNullField(t *testing.T) { + validate := makeValidator() + + type testCase struct { + Name sql.NullString `validate:"required"` + } + test := &testCase{ + Name: sql.NullString{ + String: "Hello", // This is not empty on purpose, + // I will explain it later + Valid: false, // Valid = false represents null + }, + } + + err := validate.Struct(test) + // check err is not nil + assert.Error(t, err, "should return an error because name is null") +} +``` + +Test result: +```bash +go test ./... -v +=== RUN TestNullField + main_test.go:31: + Error Trace: /home/remvn/personal/go-validator-custom-type/main_test.go:31 + Error: An error is expected but got nil. + Test: TestNullField + Messages: should return an error because Valid is false +--- FAIL: TestNullField (0.00s) +=== RUN TestSimpleStruct +--- PASS: TestSimpleStruct (0.00s) +FAIL +FAIL github.com/remvn/go-validator-custom-type 0.002s +FAIL +``` + +The test failed, `err` returned from validate function is nil even though Valid += false + +### 2nd test + +In this test. We created a non-null string with `Valid` being true, we also +added another validate tag: `gt=10` (check if the length is greater than 10) + +```go +func TestFieldLength(t *testing.T) { + validate := makeValidator() + + type testCase struct { + Name sql.NullString `validate:"required,gt=10"` + } + test := &testCase{ + // This is a non-null string + Name: sql.NullString{ + String: "hello", // note that string length is less than 10 + Valid: true, + }, + } + + err := validate.Struct(test) + // check err is not nil + assert.Error(t, err, "should return an error because length is less than 10") +} +``` + +The test even result with `panic`: +```bash +go test -run TestFieldLength -v ./... +=== RUN TestFieldLength +--- FAIL: TestFieldLength (0.00s) +panic: Bad field type sql.NullString [recovered] + panic: Bad field type sql.NullString + +goroutine 7 [running]: +testing.tRunner.func1.2({0x63e2a0, 0xc00002b200}) +``` + +## What's going on? Can we fix this? + +After some digging. Turns out this library didn't handle `driver.Valuer` out of +the box {{< emoji "canny" >}}. It also explains why: + +1. The [first test](#1st-test) is failing because by default `go-validator` + treats `Name` field as a normal struct. So, with a `required` tag on that + struct, it tried to check every field of that struct, the check will pass if + one of them is not zero-value. This is also why I put a non-empty string on + purpose to make the test failing, proving that `Valid = false` means + nothing. + +2. With the knowledge above, it's easier to understand why [second + test](#2nd-test) is failing, since now it tried to check **"the length of the + struct is greater than 10"**, which ultimately panic by default + +To use custom types for `go-validator` you need to register a custom function +to pull out the "real" value of the struct manually. + +Here's how we can do it: +```go +func makeValidator() *validator.Validate { + validate := validator.New(validator.WithRequiredStructEnabled()) + + // register func for custom struct, do this for every custom struct + // you're going to need + validate.RegisterCustomTypeFunc(validateValuer, sql.NullString{}, sql.NullInt64{}) + return validate +} + +func validateValuer(field reflect.Value) interface{} { + if valuer, ok := field.Interface().(driver.Valuer); ok { + val, err := valuer.Value() + if err == nil { + // return the "real" value of custom struct + // for example: if concrete type of driver.Valuer interface + // is sql.NullString then val will be a string + return val + } + } + // return nil means this field is indeed "null". + // field with tag `required` will fail the check + return nil +} +``` + +Now the tests are passing: +```bash +go test -v ./... +=== RUN TestNullField +--- PASS: TestNullField (0.00s) +=== RUN TestFieldLength +--- PASS: TestFieldLength (0.00s) +=== RUN TestSimpleStruct +--- PASS: TestSimpleStruct (0.00s) +PASS +ok github.com/remvn/go-validator-custom-type 0.002s +``` + +I think this is a small lack of documentation of `go-validator`, which can be +hard to figure out. But overall this is a good opportunity for me to understand +the language specs a little bit deeper. +