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 0000000..fa024b5 Binary files /dev/null and b/content/posts/custom-type-gotcha-in-go-validator/go-validator-feature.jpg differ diff --git a/content/posts/custom-type-gotcha-in-go-validator/index.en.md b/content/posts/custom-type-gotcha-in-go-validator/index.en.md new file mode 100644 index 0000000..cd9d197 --- /dev/null +++ b/content/posts/custom-type-gotcha-in-go-validator/index.en.md @@ -0,0 +1,207 @@ +--- +title: Gotcha of custom type in go-validator +date: "2024-09-24T19:14:28+07:00" +draft: false +showComments: true +description: "Gotcha of custom type in go-validator and how to fix it" +tags: +- golang +- random +--- + +Source code of this article can be found here: +[Github repo](https://github.com/remvn/go-validator-custom-type) + +## What's go-validator? + +From it's [repo](https://github.com/go-playground/validator): +> 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. +