Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom validation tags #14

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ jobs:
with:
fetch-depth: 2

- uses: actions/setup-go@v4
- uses: actions/setup-go@v5
with:
go-version: '^1.21.3'
go-version: '^1.23.5'
- run: go version

- name: Install gofumpt
Expand All @@ -38,7 +38,7 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.59
version: v1.63
args: --verbose --timeout=3m

- name: Test
Expand Down
4 changes: 2 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
run:
skip-files:
issues:
exclude-files:
- regexes.go
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ GOCMD=GO111MODULE=on go
linters-install:
@golangci-lint --version >/dev/null 2>&1 || { \
echo "installing linting tools..."; \
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v1.52.2; \
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v1.63.4; \
}

lint: linters-install
Expand Down
154 changes: 116 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Zod + Generate = Zen

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

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

// We can also use create a converter and convert multiple types together
c := zen.NewConverter(nil)
c := zen.NewConverter()

// Generic types are also supported
type GenericPair[T any, U any] struct {
Expand Down Expand Up @@ -123,42 +123,6 @@ export type PairMapStringIntBool = z.infer<typeof PairMapStringIntBoolSchema>
schema := converter.Export()
```

## Custom Types

We can pass type name mappings to custom conversion functions:

```go
c := zen.NewConverter(map[string]zen.CustomFn{
"github.com/shopspring/decimal.Decimal": func (c *zen.Converter, t reflect.Type, v string, i int) string {
// Shopspring's decimal type serialises to a string.
return "z.string()"
},
})

c.Convert(User{
Money decimal.Decimal
})
```

Outputs:

```typescript
export const UserSchema = z.object({
Money: z.string(),
})
export type User = z.infer<typeof UserSchema>
```

There are some custom types with tests in the "custom" directory.

The function signature for custom type handlers is:

```go
func(c *Converter, t reflect.Type, validate string, indent int) string
```

We can use `c` to process nested types. Indent level is for passing to other converter APIs.

## Supported validations

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

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

## Custom Tags

In addition to the [go-validator](https://github.com/go-playground/validator) tags supported out of the box, custom tags can also be implemented.

```go
type SortParams struct {
Order *string `json:"order,omitempty" validate:"omitempty,oneof=asc desc"`
Field *string `json:"field,omitempty"`
}

type Request struct {
SortParams `validate:"sortFields=title address age dob"`
PaginationParams struct {
Start *int `json:"start,omitempty" validate:"omitempty,gt=0"`
End *int `json:"end,omitempty" validate:"omitempty,gt=0"`
} `validate:"pageParams"`
Search *string `json:"search,omitempty" validate:"identifier"`
}

customTagHandlers := map[string]zen.CustomFn{
"identifier": func(c *zen.Converter, t reflect.Type, validate string, indent int) string {
return ".refine((val) => !val || /^[a-z0-9_]*$/.test(val), 'Invalid search identifier')"
},
"pageParams": func(c *zen.Converter, t reflect.Type, validate string, indent int) string {
return ".refine((val) => !val.start || !val.end || val.start < val.end, 'Start should be less than end')"
},
"sortFields": func(c *zen.Converter, t reflect.Type, validate string, indent int) string {
sortFields := strings.Split(validate, " ")
for i := range sortFields {
sortFields[i] = fmt.Sprintf("'%s'", sortFields[i])
}
return fmt.Sprintf(".extend({field: z.enum([%s])})", strings.Join(sortFields, ", "))
},
Comment on lines +241 to +247
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems quite useful for building advanced use cases.

Does go-validator just ignore these custom tags? If thats the case it may be better to use a new tag for it (zod:"identifier" or zen:"identifier"), to make it clear this validation won't run at go level.

}
opt := zen.WithCustomTags(customTagHandlers)
c := zen.NewConverter(opt)

c.Convert(Request{})
```

Outputs:

```ts
export const SortParamsSchema = z.object({
order: z.enum(["asc", "desc"] as const).optional(),
field: z.string().optional(),
})
export type SortParams = z.infer<typeof SortParamsSchema>

export const RequestSchema = z.object({
PaginationParams: z.object({
start: z.number().gt(0).optional(),
end: z.number().gt(0).optional(),
}).refine((val) => !val.start || !val.end || val.start < val.end, 'Start should be less than end'),
search: z.string().refine((val) => !val || /^[a-z0-9_]*$/.test(val), 'Invalid search identifier').optional(),
}).merge(SortParamsSchema.extend({field: z.enum(['title', 'address', 'age', 'dob'])}))
export type Request = z.infer<typeof RequestSchema>
```

The function signature for custom type handlers is:

```go
func(c *Converter, t reflect.Type, validate string, indent int) string
```

We can use `c` to process nested types. Indent level is for passing to other converter APIs.

## Ignored Tags

To ensure safety, `zen` will panic if it encounters unknown validation tags. If these tags are intentional, they should be explicitly ignored.

```go
opt := zen.WithIgnoreTags("identifier")
c := zen.NewConverter(opt)
```

## Custom Types

We can pass type name mappings to custom conversion functions:

```go
customTypeHandlers := map[string]zen.CustomFn{
"github.com/shopspring/decimal.Decimal": func (c *zen.Converter, t reflect.Type, v string, indent int) string {
// Shopspring's decimal type serialises to a string.
return "z.string()"
},
}
opt := zen.WithCustomTypes(customTypeHandlers)
c := zen.NewConverter(opt)

c.Convert(User{
Money decimal.Decimal
})
```

Outputs:

```typescript
export const UserSchema = z.object({
Money: z.string(),
})
export type User = z.infer<typeof UserSchema>
```

There are some custom types with tests in the [custom](./custom) directory.

The function signature for custom type handlers is:

```go
func(c *Converter, t reflect.Type, validate string, indent int) string
```

We can use `c` to process nested types. Indent level is for passing to other converter APIs.

## Caveats

- Does not support cyclic types - it's a limitation of zod, but self-referential types are supported.
Expand Down
3 changes: 2 additions & 1 deletion custom/decimal/decimal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import (
)

func TestCustom(t *testing.T) {
c := zen.NewConverter(map[string]zen.CustomFn{
opt := zen.WithCustomTypes(map[string]zen.CustomFn{
customDecimal.DecimalType: customDecimal.DecimalFunc,
})
c := zen.NewConverter(opt)

type User struct {
Money decimal.Decimal
Expand Down
2 changes: 1 addition & 1 deletion custom/decimal/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/hypersequent/zen/custom/decimal

go 1.21
go 1.23

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

Expand Down
2 changes: 1 addition & 1 deletion custom/optional/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/hypersequent/zen/custom/optional

go 1.21
go 1.23

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

Expand Down
3 changes: 2 additions & 1 deletion custom/optional/optional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import (
)

func TestCustom(t *testing.T) {
c := zen.NewConverter(map[string]zen.CustomFn{
opt := zen.WithCustomTypes(map[string]zen.CustomFn{
customoptional.OptionalType: customoptional.OptionalFunc,
})
c := zen.NewConverter(opt)
satvik007 marked this conversation as resolved.
Show resolved Hide resolved

type Profile struct {
Bio string
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/hypersequent/zen

go 1.21
go 1.23

require github.com/stretchr/testify v1.8.3

Expand Down
2 changes: 1 addition & 1 deletion go.work
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
go 1.21
go 1.23

use (
.
Expand Down
Loading