mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-28 16:09:36 +00:00
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.
245 lines
5.4 KiB
Go
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))
|
|
}
|