Skip to content

Commit 3da7737

Browse files
hi-raiAndrianBdn
andauthored
Add support for custom validation tags (#14)
* Introduce converter options and and a new constructor NewConverterWithOpts that uses it. The current constructor is marked deprecated * Add new option to specify handlers for custom validation tags * Add support for custom validation tags in all types * Improve ignore tags check for all types * Upgrade go and golangci-lint version * Add support for new omitzero json tag introduced in Go 1.24 --------- Co-authored-by: Andrian Budantsov <a@hypersequent.com>
1 parent 5f01b1b commit 3da7737

12 files changed

Lines changed: 471 additions & 250 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ jobs:
2121
with:
2222
fetch-depth: 2
2323

24-
- uses: actions/setup-go@v4
24+
- uses: actions/setup-go@v5
2525
with:
26-
go-version: '^1.21.3'
26+
go-version: '^1.23.5'
2727
- run: go version
2828

2929
- name: Install gofumpt
@@ -38,7 +38,7 @@ jobs:
3838
- name: golangci-lint
3939
uses: golangci/golangci-lint-action@v6
4040
with:
41-
version: v1.59
41+
version: v1.63
4242
args: --verbose --timeout=3m
4343

4444
- name: Test

.golangci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
run:
2-
skip-files:
1+
issues:
2+
exclude-files:
33
- regexes.go

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ GOCMD=GO111MODULE=on go
33
linters-install:
44
@golangci-lint --version >/dev/null 2>&1 || { \
55
echo "installing linting tools..."; \
6-
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v1.52.2; \
6+
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v1.63.4; \
77
}
88

99
lint: linters-install

README.md

Lines changed: 117 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Zod + Generate = Zen
44

5-
Converts Go structs with go-validator validations to Zod schemas.
5+
Converts Go structs with [go-validator](https://github.com/go-playground/validator) validations to Zod schemas.
66

77
Zen supports self-referential types and generic types. Other cyclic types (apart from self referential types) are not supported
88
as they are not supported by zod itself.
@@ -34,7 +34,7 @@ type Tree struct {
3434
fmt.Print(zen.StructToZodSchema(Tree{}))
3535

3636
// We can also use create a converter and convert multiple types together
37-
c := zen.NewConverter(nil)
37+
c := zen.NewConverterWithOpts()
3838

3939
// Generic types are also supported
4040
type GenericPair[T any, U any] struct {
@@ -114,7 +114,7 @@ export type PairMapStringIntBool = z.infer<typeof PairMapStringIntBoolSchema>
114114
- Then using go templates and passing these struct names as input, we generate go code that is later used to generate the zod schemas.
115115
116116
```go.tmpl
117-
converter := zen.NewConverter(make(map[string]zen.CustomFn))
117+
converter := zen.NewConverterWithOpts(make(map[string]zen.CustomFn))
118118

119119
{{range .TypesToGenerate}}
120120
converter.AddType(types.{{.}}{})
@@ -123,42 +123,6 @@ export type PairMapStringIntBool = z.infer<typeof PairMapStringIntBoolSchema>
123123
schema := converter.Export()
124124
```
125125

126-
## Custom Types
127-
128-
We can pass type name mappings to custom conversion functions:
129-
130-
```go
131-
c := zen.NewConverter(map[string]zen.CustomFn{
132-
"github.com/shopspring/decimal.Decimal": func (c *zen.Converter, t reflect.Type, v string, i int) string {
133-
// Shopspring's decimal type serialises to a string.
134-
return "z.string()"
135-
},
136-
})
137-
138-
c.Convert(User{
139-
Money decimal.Decimal
140-
})
141-
```
142-
143-
Outputs:
144-
145-
```typescript
146-
export const UserSchema = z.object({
147-
Money: z.string(),
148-
})
149-
export type User = z.infer<typeof UserSchema>
150-
```
151-
152-
There are some custom types with tests in the "custom" directory.
153-
154-
The function signature for custom type handlers is:
155-
156-
```go
157-
func(c *Converter, t reflect.Type, validate string, indent int) string
158-
```
159-
160-
We can use `c` to process nested types. Indent level is for passing to other converter APIs.
161-
162126
## Supported validations
163127

164128
### Network
@@ -248,6 +212,120 @@ We can use `c` to process nested types. Indent level is for passing to other con
248212

249213
- required checks that the value is not default, but we are not implementing this check for numbers and booleans
250214

215+
## Custom Tags
216+
217+
In addition to the [go-validator](https://github.com/go-playground/validator) tags supported out of the box, custom tags can also be implemented.
218+
219+
```go
220+
type SortParams struct {
221+
Order *string `json:"order,omitempty" validate:"omitempty,oneof=asc desc"`
222+
Field *string `json:"field,omitempty"`
223+
}
224+
225+
type Request struct {
226+
SortParams `validate:"sortFields=title address age dob"`
227+
PaginationParams struct {
228+
Start *int `json:"start,omitempty" validate:"omitempty,gt=0"`
229+
End *int `json:"end,omitempty" validate:"omitempty,gt=0"`
230+
} `validate:"pageParams"`
231+
Search *string `json:"search,omitempty" validate:"identifier"`
232+
}
233+
234+
customTagHandlers := map[string]zen.CustomFn{
235+
"identifier": func(c *zen.Converter, t reflect.Type, validate string, indent int) string {
236+
return ".refine((val) => !val || /^[a-z0-9_]*$/.test(val), 'Invalid search identifier')"
237+
},
238+
"pageParams": func(c *zen.Converter, t reflect.Type, validate string, indent int) string {
239+
return ".refine((val) => !val.start || !val.end || val.start < val.end, 'Start should be less than end')"
240+
},
241+
"sortFields": func(c *zen.Converter, t reflect.Type, validate string, indent int) string {
242+
sortFields := strings.Split(validate, " ")
243+
for i := range sortFields {
244+
sortFields[i] = fmt.Sprintf("'%s'", sortFields[i])
245+
}
246+
return fmt.Sprintf(".extend({field: z.enum([%s])})", strings.Join(sortFields, ", "))
247+
},
248+
}
249+
opt := zen.WithCustomTags(customTagHandlers)
250+
c := zen.NewConverterWithOpts(opt)
251+
252+
c.Convert(Request{})
253+
```
254+
255+
Outputs:
256+
257+
```ts
258+
export const SortParamsSchema = z.object({
259+
order: z.enum(["asc", "desc"] as const).optional(),
260+
field: z.string().optional(),
261+
})
262+
export type SortParams = z.infer<typeof SortParamsSchema>
263+
264+
export const RequestSchema = z.object({
265+
PaginationParams: z.object({
266+
start: z.number().gt(0).optional(),
267+
end: z.number().gt(0).optional(),
268+
}).refine((val) => !val.start || !val.end || val.start < val.end, 'Start should be less than end'),
269+
search: z.string().refine((val) => !val || /^[a-z0-9_]*$/.test(val), 'Invalid search identifier').optional(),
270+
}).merge(SortParamsSchema.extend({field: z.enum(['title', 'address', 'age', 'dob'])}))
271+
export type Request = z.infer<typeof RequestSchema>
272+
```
273+
274+
The function signature for custom type handlers is:
275+
276+
```go
277+
func(c *Converter, t reflect.Type, validate string, indent int) string
278+
```
279+
280+
We can use `c` to process nested types. Indent level is for passing to other converter APIs.
281+
282+
## Ignored Tags
283+
284+
To ensure safety, `zen` will panic if it encounters unknown validation tags. If these tags are intentional, they should be explicitly ignored.
285+
286+
```go
287+
opt := zen.WithIgnoreTags("identifier")
288+
c := zen.NewConverterWithOpts(opt)
289+
```
290+
291+
## Custom Types
292+
293+
We can pass type name mappings to custom conversion functions:
294+
295+
```go
296+
customTypeHandlers := map[string]zen.CustomFn{
297+
"github.com/shopspring/decimal.Decimal": func (c *zen.Converter, t reflect.Type, v string, indent int) string {
298+
// Shopspring's decimal type serialises to a string.
299+
return "z.string()"
300+
},
301+
}
302+
opt := zen.WithCustomTypes(customTypeHandlers)
303+
c := zen.NewConverterWithOpts(opt)
304+
305+
c.Convert(User{
306+
Money decimal.Decimal
307+
})
308+
```
309+
310+
Outputs:
311+
312+
```typescript
313+
export const UserSchema = z.object({
314+
Money: z.string(),
315+
})
316+
export type User = z.infer<typeof UserSchema>
317+
```
318+
319+
There are some custom types with tests in the [custom](./custom) directory.
320+
321+
The function signature for custom type handlers is:
322+
323+
```go
324+
func(c *Converter, t reflect.Type, validate string, indent int) string
325+
```
326+
327+
We can use `c` to process nested types. Indent level is for passing to other converter APIs.
328+
251329
## Caveats
252330
253331
- Does not support cyclic types - it's a limitation of zod, but self-referential types are supported.

custom/decimal/decimal_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import (
1111
)
1212

1313
func TestCustom(t *testing.T) {
14-
c := zen.NewConverter(map[string]zen.CustomFn{
14+
opt := zen.WithCustomTypes(map[string]zen.CustomFn{
1515
customDecimal.DecimalType: customDecimal.DecimalFunc,
1616
})
17+
c := zen.NewConverterWithOpts(opt)
1718

1819
type User struct {
1920
Money decimal.Decimal

custom/decimal/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/hypersequent/zen/custom/decimal
22

3-
go 1.21
3+
go 1.23
44

55
replace github.com/hypersequent/zen => ../..
66

custom/optional/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/hypersequent/zen/custom/optional
22

3-
go 1.21
3+
go 1.23
44

55
replace github.com/hypersequent/zen => ../..
66

custom/optional/optional_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import (
1010
)
1111

1212
func TestCustom(t *testing.T) {
13-
c := zen.NewConverter(map[string]zen.CustomFn{
13+
opt := zen.WithCustomTypes(map[string]zen.CustomFn{
1414
customoptional.OptionalType: customoptional.OptionalFunc,
1515
})
16+
c := zen.NewConverterWithOpts(opt)
1617

1718
type Profile struct {
1819
Bio string

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/hypersequent/zen
22

3-
go 1.21
3+
go 1.23
44

55
require github.com/stretchr/testify v1.8.3
66

go.work

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
go 1.21
1+
go 1.23
22

33
use (
44
.

0 commit comments

Comments
 (0)