Files
3x-ui/tools/openapigen/walker.go
MHSanaei 7bd281d26d feat(codegen): Go-first tool emitting Zod schemas and TS types
Add tools/openapigen — a single-binary Go program that walks the
exported structs in database/model, web/entity, and xray via go/parser
and emits two committed artifacts under frontend/src/generated:

  - zod.ts   shared Zod schemas keyed off `validate:` tags (ports get
             .min(1).max(65535), Inbound.protocol becomes a z.enum,
             Node.scheme too, etc.)
  - types.ts plain TS interfaces inferred from the same walk, so
             consumers can import Inbound without dragging Zod along

The walker flattens embedded structs (AllSettingView.AllSetting),
honors json:"-" and omitempty, and accepts per-struct overrides so
the JSON-string-inside-JSON columns (Inbound.Settings/StreamSettings/
Sniffing, ClientRecord.Reverse, InboundClientIps.Ips) render as
z.unknown() instead of leaking the DB-storage type into the API
contract. Type aliases like model.Protocol are emitted as TS aliases
and Zod schemas in their own right.

Wires `npm run gen:zod` in frontend/package.json so the generator can
be re-run without leaving the frontend tree. The existing openapi.json
build (gen:api) is left alone for now; migrating the OpenAPI surface
to this generator is a follow-up.

PR2 of the planned Zod end-to-end rollout.
2026-05-25 19:29:44 +02:00

245 lines
5.4 KiB
Go

package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strings"
)
type walkOverride struct {
Field string
Kind TypeKind
}
type packageRequest struct {
Path string
StructAllow map[string]bool
AliasAllow map[string]bool
Overrides map[string][]walkOverride
}
func walkPackages(requests []packageRequest) ([]Schema, []Alias, error) {
fset := token.NewFileSet()
var schemas []Schema
var aliases []Alias
for _, req := range requests {
dir := req.Path
pkgs, err := parser.ParseDir(fset, dir, func(fi fs.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, parser.ParseComments)
if err != nil {
return nil, nil, fmt.Errorf("parse %s: %w", dir, err)
}
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if strct, ok := ts.Type.(*ast.StructType); ok {
if req.StructAllow != nil && !req.StructAllow[ts.Name.Name] {
continue
}
s := Schema{
Name: ts.Name.Name,
Package: pkg.Name,
Doc: collectDoc(gen.Doc, ts.Doc),
}
overrides := req.Overrides[ts.Name.Name]
for _, fld := range strct.Fields.List {
for _, f := range buildFields(fld, overrides) {
s.Fields = append(s.Fields, f)
}
}
schemas = append(schemas, s)
continue
}
if req.AliasAllow != nil && !req.AliasAllow[ts.Name.Name] {
continue
}
aliases = append(aliases, Alias{
Name: ts.Name.Name,
Package: pkg.Name,
Underlying: exprToType(ts.Type),
})
}
}
}
}
}
return schemas, aliases, nil
}
func collectDoc(group ...*ast.CommentGroup) string {
var b strings.Builder
for _, g := range group {
if g == nil {
continue
}
for _, c := range g.List {
line := strings.TrimPrefix(c.Text, "// ")
line = strings.TrimPrefix(line, "//")
b.WriteString(strings.TrimSpace(line))
b.WriteByte('\n')
}
}
return strings.TrimSpace(b.String())
}
func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
var fields []Field
tag := ""
if fld.Tag != nil {
tag = fld.Tag.Value
}
jsonTag, validateTag, gormDash := parseStructTag(tag)
if gormDash && jsonTag == "" {
return nil
}
jsonName, omit, omitempty := parseJSONTag(jsonTag)
if omit {
return nil
}
validate := parseValidateTag(validateTag)
doc := collectDoc(fld.Doc, fld.Comment)
for _, n := range fld.Names {
fname := jsonName
if fname == "" {
fname = lowerFirst(n.Name)
}
t := exprToType(fld.Type)
for _, o := range overrides {
if o.Field == n.Name || o.Field == jsonName {
t = TypeRef{Kind: o.Kind}
break
}
}
fields = append(fields, Field{
JSONName: fname,
GoName: n.Name,
Type: t,
Optional: omitempty || isPointer(fld.Type),
Validate: validate,
Doc: doc,
})
}
if len(fld.Names) == 0 {
fname := jsonName
if fname == "" {
fname = lowerFirst(exprIdentName(fld.Type))
}
t := exprToType(fld.Type)
for _, o := range overrides {
if o.Field == exprIdentName(fld.Type) || o.Field == jsonName {
t = TypeRef{Kind: o.Kind}
break
}
}
fields = append(fields, Field{
JSONName: fname,
GoName: exprIdentName(fld.Type),
Type: t,
Optional: omitempty || isPointer(fld.Type),
Validate: validate,
Doc: doc,
})
}
return fields
}
func exprToType(expr ast.Expr) TypeRef {
switch e := expr.(type) {
case *ast.Ident:
return identType(e.Name)
case *ast.StarExpr:
inner := exprToType(e.X)
return TypeRef{Kind: KindRef, Name: "nullable", Inner: &inner}
case *ast.ArrayType:
elem := exprToType(e.Elt)
return TypeRef{Kind: KindArray, Element: &elem}
case *ast.MapType:
k := exprToType(e.Key)
v := exprToType(e.Value)
return TypeRef{Kind: KindMap, Key: &k, Value: &v}
case *ast.SelectorExpr:
pkg := exprIdentName(e.X)
name := e.Sel.Name
if pkg == "json" && name == "RawMessage" {
return TypeRef{Kind: KindAny}
}
if pkg == "time" && name == "Time" {
return TypeRef{Kind: KindString, Name: "datetime"}
}
return TypeRef{Kind: KindRef, Name: name}
case *ast.InterfaceType:
return TypeRef{Kind: KindAny}
default:
return TypeRef{Kind: KindUnknown}
}
}
func identType(name string) TypeRef {
switch name {
case "string":
return TypeRef{Kind: KindString}
case "bool":
return TypeRef{Kind: KindBool}
case "int", "int8", "int16", "int32", "int64",
"uint", "uint8", "uint16", "uint32", "uint64":
return TypeRef{Kind: KindInt}
case "float32", "float64":
return TypeRef{Kind: KindNumber}
case "byte", "rune":
return TypeRef{Kind: KindInt}
case "any":
return TypeRef{Kind: KindAny}
default:
return TypeRef{Kind: KindRef, Name: name}
}
}
func isPointer(expr ast.Expr) bool {
_, ok := expr.(*ast.StarExpr)
return ok
}
func exprIdentName(expr ast.Expr) string {
switch e := expr.(type) {
case *ast.Ident:
return e.Name
case *ast.SelectorExpr:
return e.Sel.Name
case *ast.StarExpr:
return exprIdentName(e.X)
default:
return ""
}
}
func lowerFirst(s string) string {
if s == "" {
return s
}
return strings.ToLower(s[:1]) + s[1:]
}
func resolveRel(base, rel string) string {
if filepath.IsAbs(rel) {
return rel
}
return filepath.Clean(filepath.Join(base, rel))
}