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.
This commit is contained in:
MHSanaei
2026-05-25 19:29:44 +02:00
parent 7fda988fb2
commit 7bd281d26d
8 changed files with 1500 additions and 1 deletions

View File

@@ -14,7 +14,8 @@
"preview": "vite preview",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs"
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen"
},
"dependencies": {
"@ant-design/icons": "^6.2.3",

View File

@@ -0,0 +1,359 @@
// Code generated by tools/openapigen. DO NOT EDIT.
export type Protocol = string;
export interface AllSetting {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
pageSize: number;
remarkModel: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
subClashPath: string;
subClashURI: string;
subDomain: string;
subEmailInRemark: boolean;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subJsonEnable: boolean;
subJsonFragment: string;
subJsonMux: string;
subJsonNoises: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subKeyFile: string;
subListen: string;
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowInfo: boolean;
subSupportUrl: string;
subTitle: string;
subURI: string;
subUpdates: number;
tgBotAPIServer: string;
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotLoginNotify: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgLang: string;
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
webBasePath: string;
webCertFile: string;
webDomain: string;
webKeyFile: string;
webListen: string;
webPort: number;
}
export interface AllSettingView {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
hasApiToken: boolean;
hasLdapPassword: boolean;
hasNordSecret: boolean;
hasTgBotToken: boolean;
hasTwoFactorToken: boolean;
hasWarpSecret: boolean;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
pageSize: number;
remarkModel: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
subClashPath: string;
subClashURI: string;
subDomain: string;
subEmailInRemark: boolean;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subJsonEnable: boolean;
subJsonFragment: string;
subJsonMux: string;
subJsonNoises: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subKeyFile: string;
subListen: string;
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowInfo: boolean;
subSupportUrl: string;
subTitle: string;
subURI: string;
subUpdates: number;
tgBotAPIServer: string;
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotLoginNotify: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgLang: string;
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
webBasePath: string;
webCertFile: string;
webDomain: string;
webKeyFile: string;
webListen: string;
webPort: number;
}
export interface ApiToken {
createdAt: number;
enabled: boolean;
id: number;
name: string;
token: string;
}
export interface Client {
auth?: string;
comment: string;
created_at?: number;
email: string;
enable: boolean;
expiryTime: number;
flow?: string;
id?: string;
limitIp: number;
password?: string;
reset: number;
reverse?: ClientReverse | null;
security: string;
subId: string;
tgId: number;
totalGB: number;
updated_at?: number;
}
export interface ClientInbound {
clientId: number;
createdAt: number;
flowOverride: string;
inboundId: number;
}
export interface ClientRecord {
auth: string;
comment: string;
createdAt: number;
email: string;
enable: boolean;
expiryTime: number;
flow: string;
id: number;
limitIp: number;
password: string;
reset: number;
reverse: unknown;
security: string;
subId: string;
tgId: number;
totalGB: number;
updatedAt: number;
uuid: string;
}
export interface ClientReverse {
tag: string;
}
export interface ClientTraffic {
down: number;
email: string;
enable: boolean;
expiryTime: number;
id: number;
inboundId: number;
lastOnline: number;
reset: number;
subId: string;
total: number;
up: number;
uuid: string;
}
export interface CustomGeoResource {
alias: string;
createdAt: number;
id: number;
lastModified: string;
lastUpdatedAt: number;
localPath: string;
type: string;
updatedAt: number;
url: string;
}
export interface FallbackParentInfo {
masterId: number;
path?: string;
}
export interface HistoryOfSeeders {
id: number;
seederName: string;
}
export interface Inbound {
clientStats: ClientTraffic[];
down: number;
enable: boolean;
expiryTime: number;
fallbackParent?: FallbackParentInfo | null;
id: number;
lastTrafficResetTime: number;
listen: string;
nodeId?: number | null;
port: number;
protocol: Protocol;
remark: string;
settings: unknown;
sniffing: unknown;
streamSettings: unknown;
tag: string;
total: number;
trafficReset: string;
up: number;
}
export interface InboundClientIps {
clientEmail: string;
id: number;
ips: unknown;
}
export interface InboundFallback {
alpn: string;
childId: number;
id: number;
masterId: number;
name: string;
path: string;
sortOrder: number;
xver: number;
}
export interface Msg {
msg: string;
obj: unknown;
success: boolean;
}
export interface Node {
address: string;
allowPrivateAddress: boolean;
apiToken: string;
basePath: string;
clientCount: number;
cpuPct: number;
createdAt: number;
depletedCount: number;
enable: boolean;
id: number;
inboundCount: number;
lastError: string;
lastHeartbeat: number;
latencyMs: number;
memPct: number;
name: string;
onlineCount: number;
panelVersion: string;
port: number;
remark: string;
scheme: string;
status: string;
updatedAt: number;
uptimeSecs: number;
xrayVersion: string;
}
export interface OutboundTraffics {
down: number;
id: number;
tag: string;
total: number;
up: number;
}
export interface Setting {
id: number;
key: string;
value: string;
}
export interface User {
id: number;
password: string;
username: string;
}

View File

@@ -0,0 +1,380 @@
// Code generated by tools/openapigen. DO NOT EDIT.
import { z } from 'zod';
export const ProtocolSchema = z.string();
export type Protocol = z.infer<typeof ProtocolSchema>;
export const AllSettingSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
pageSize: z.number().int().min(1).max(1000),
remarkModel: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(0).max(525600),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
subClashPath: z.string(),
subClashURI: z.string(),
subDomain: z.string(),
subEmailInRemark: z.boolean(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFragment: z.string(),
subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowInfo: z.boolean(),
subSupportUrl: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
tgBotAPIServer: z.string(),
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotLoginNotify: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgLang: z.string(),
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
webBasePath: z.string(),
webCertFile: z.string(),
webDomain: z.string(),
webKeyFile: z.string(),
webListen: z.string(),
webPort: z.number().int().min(1).max(65535),
});
export type AllSetting = z.infer<typeof AllSettingSchema>;
export const AllSettingViewSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
hasApiToken: z.boolean(),
hasLdapPassword: z.boolean(),
hasNordSecret: z.boolean(),
hasTgBotToken: z.boolean(),
hasTwoFactorToken: z.boolean(),
hasWarpSecret: z.boolean(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
pageSize: z.number().int().min(1).max(1000),
remarkModel: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(0).max(525600),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
subClashPath: z.string(),
subClashURI: z.string(),
subDomain: z.string(),
subEmailInRemark: z.boolean(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFragment: z.string(),
subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowInfo: z.boolean(),
subSupportUrl: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
tgBotAPIServer: z.string(),
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotLoginNotify: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgLang: z.string(),
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
webBasePath: z.string(),
webCertFile: z.string(),
webDomain: z.string(),
webKeyFile: z.string(),
webListen: z.string(),
webPort: z.number().int().min(1).max(65535),
});
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
export const ApiTokenSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
id: z.number().int(),
name: z.string(),
token: z.string(),
});
export type ApiToken = z.infer<typeof ApiTokenSchema>;
export const ClientSchema = z.object({
auth: z.string().optional(),
comment: z.string(),
created_at: z.number().int().optional(),
email: z.string(),
enable: z.boolean(),
expiryTime: z.number().int(),
flow: z.string().optional(),
id: z.string().optional(),
limitIp: z.number().int(),
password: z.string().optional(),
reset: z.number().int(),
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
security: z.string(),
subId: z.string(),
tgId: z.number().int(),
totalGB: z.number().int(),
updated_at: z.number().int().optional(),
});
export type Client = z.infer<typeof ClientSchema>;
export const ClientInboundSchema = z.object({
clientId: z.number().int(),
createdAt: z.number().int(),
flowOverride: z.string(),
inboundId: z.number().int(),
});
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
export const ClientRecordSchema = z.object({
auth: z.string(),
comment: z.string(),
createdAt: z.number().int(),
email: z.string(),
enable: z.boolean(),
expiryTime: z.number().int(),
flow: z.string(),
id: z.number().int(),
limitIp: z.number().int(),
password: z.string(),
reset: z.number().int(),
reverse: z.unknown(),
security: z.string(),
subId: z.string(),
tgId: z.number().int(),
totalGB: z.number().int(),
updatedAt: z.number().int(),
uuid: z.string(),
});
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
export const ClientReverseSchema = z.object({
tag: z.string(),
});
export type ClientReverse = z.infer<typeof ClientReverseSchema>;
export const ClientTrafficSchema = z.object({
down: z.number().int(),
email: z.string(),
enable: z.boolean(),
expiryTime: z.number().int(),
id: z.number().int(),
inboundId: z.number().int(),
lastOnline: z.number().int(),
reset: z.number().int(),
subId: z.string(),
total: z.number().int(),
up: z.number().int(),
uuid: z.string(),
});
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
export const CustomGeoResourceSchema = z.object({
alias: z.string(),
createdAt: z.number().int(),
id: z.number().int(),
lastModified: z.string(),
lastUpdatedAt: z.number().int(),
localPath: z.string(),
type: z.string(),
updatedAt: z.number().int(),
url: z.string(),
});
export type CustomGeoResource = z.infer<typeof CustomGeoResourceSchema>;
export const FallbackParentInfoSchema = z.object({
masterId: z.number().int(),
path: z.string().optional(),
});
export type FallbackParentInfo = z.infer<typeof FallbackParentInfoSchema>;
export const HistoryOfSeedersSchema = z.object({
id: z.number().int(),
seederName: z.string(),
});
export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
export const InboundSchema = z.object({
clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
down: z.number().int(),
enable: z.boolean(),
expiryTime: z.number().int(),
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
id: z.number().int(),
lastTrafficResetTime: z.number().int(),
listen: z.string(),
nodeId: z.number().int().nullable().optional(),
port: z.number().int().min(1).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'hysteria2', 'http', 'mixed', 'tunnel']),
remark: z.string(),
settings: z.unknown(),
sniffing: z.unknown(),
streamSettings: z.unknown(),
tag: z.string(),
total: z.number().int(),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
up: z.number().int(),
});
export type Inbound = z.infer<typeof InboundSchema>;
export const InboundClientIpsSchema = z.object({
clientEmail: z.string(),
id: z.number().int(),
ips: z.unknown(),
});
export type InboundClientIps = z.infer<typeof InboundClientIpsSchema>;
export const InboundFallbackSchema = z.object({
alpn: z.string(),
childId: z.number().int(),
id: z.number().int(),
masterId: z.number().int(),
name: z.string(),
path: z.string(),
sortOrder: z.number().int(),
xver: z.number().int(),
});
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
export const MsgSchema = z.object({
msg: z.string(),
obj: z.unknown(),
success: z.boolean(),
});
export type Msg = z.infer<typeof MsgSchema>;
export const NodeSchema = z.object({
address: z.string(),
allowPrivateAddress: z.boolean(),
apiToken: z.string(),
basePath: z.string(),
clientCount: z.number().int(),
cpuPct: z.number(),
createdAt: z.number().int(),
depletedCount: z.number().int(),
enable: z.boolean(),
id: z.number().int(),
inboundCount: z.number().int(),
lastError: z.string(),
lastHeartbeat: z.number().int(),
latencyMs: z.number().int(),
memPct: z.number(),
name: z.string(),
onlineCount: z.number().int(),
panelVersion: z.string(),
port: z.number().int().min(1).max(65535),
remark: z.string(),
scheme: z.enum(['http', 'https']),
status: z.string(),
updatedAt: z.number().int(),
uptimeSecs: z.number().int(),
xrayVersion: z.string(),
});
export type Node = z.infer<typeof NodeSchema>;
export const OutboundTrafficsSchema = z.object({
down: z.number().int(),
id: z.number().int(),
tag: z.string(),
total: z.number().int(),
up: z.number().int(),
});
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
export const SettingSchema = z.object({
id: z.number().int(),
key: z.string(),
value: z.string(),
});
export type Setting = z.infer<typeof SettingSchema>;
export const UserSchema = z.object({
id: z.number().int(),
password: z.string(),
username: z.string(),
});
export type User = z.infer<typeof UserSchema>;

View File

@@ -0,0 +1,72 @@
package main
import (
"fmt"
"io"
"sort"
)
func emitTypes(w io.Writer, schemas []Schema, aliases []Alias) error {
if _, err := fmt.Fprintln(w, typesHeader); err != nil {
return err
}
for _, a := range sortAliases(aliases) {
if _, err := fmt.Fprintf(w, "export type %s = %s;\n", a.Name, tsTypeExpr(a.Underlying)); err != nil {
return err
}
}
if len(aliases) > 0 {
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
for _, s := range sortSchemas(schemas) {
if _, err := fmt.Fprintf(w, "export interface %s {\n", s.Name); err != nil {
return err
}
fields := append([]Field(nil), s.Fields...)
sort.SliceStable(fields, func(i, j int) bool { return fields[i].JSONName < fields[j].JSONName })
for _, f := range fields {
optional := ""
if f.Optional {
optional = "?"
}
line := fmt.Sprintf(" %s%s: %s;\n", quoteIfNeeded(f.JSONName), optional, tsTypeExpr(f.Type))
if _, err := fmt.Fprint(w, line); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, "}"); err != nil {
return err
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
return nil
}
func tsTypeExpr(t TypeRef) string {
switch t.Kind {
case KindString:
return "string"
case KindBool:
return "boolean"
case KindInt, KindNumber:
return "number"
case KindAny, KindUnknown, KindRaw:
return "unknown"
case KindArray:
return tsTypeExpr(*t.Element) + "[]"
case KindMap:
return "Record<" + tsTypeExpr(*t.Key) + ", " + tsTypeExpr(*t.Value) + ">"
case KindRef:
if t.Name == "nullable" {
return tsTypeExpr(*t.Inner) + " | null"
}
return t.Name
}
return "unknown"
}
const typesHeader = `// Code generated by tools/openapigen. DO NOT EDIT.`

View File

@@ -0,0 +1,156 @@
package main
import (
"fmt"
"io"
"sort"
"strings"
)
func emitZod(w io.Writer, schemas []Schema, aliases []Alias) error {
if _, err := fmt.Fprintln(w, zodHeader); err != nil {
return err
}
for _, a := range sortAliases(aliases) {
if _, err := fmt.Fprintf(w, "export const %sSchema = %s;\n", a.Name, zodTypeExpr(a.Underlying)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "export type %s = z.infer<typeof %sSchema>;\n\n", a.Name, a.Name); err != nil {
return err
}
}
for _, s := range sortSchemas(schemas) {
if _, err := fmt.Fprintf(w, "export const %sSchema = z.object({\n", s.Name); err != nil {
return err
}
fields := append([]Field(nil), s.Fields...)
sort.SliceStable(fields, func(i, j int) bool { return fields[i].JSONName < fields[j].JSONName })
for _, f := range fields {
line := fmt.Sprintf(" %s: %s,\n", quoteIfNeeded(f.JSONName), zodExpr(f))
if _, err := fmt.Fprint(w, line); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w, "});"); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "export type %s = z.infer<typeof %sSchema>;\n\n", s.Name, s.Name); err != nil {
return err
}
}
return nil
}
func zodExpr(f Field) string {
expr := zodTypeExpr(f.Type)
expr = applyZodValidations(expr, f.Type, f.Validate)
if f.Optional {
expr += ".optional()"
}
return expr
}
func zodTypeExpr(t TypeRef) string {
switch t.Kind {
case KindString:
return "z.string()"
case KindBool:
return "z.boolean()"
case KindInt:
return "z.number().int()"
case KindNumber:
return "z.number()"
case KindAny, KindUnknown:
return "z.unknown()"
case KindRaw:
return "z.unknown()"
case KindArray:
return "z.array(" + zodTypeExpr(*t.Element) + ")"
case KindMap:
return "z.record(" + zodTypeExpr(*t.Key) + ", " + zodTypeExpr(*t.Value) + ")"
case KindRef:
if t.Name == "nullable" {
return zodTypeExpr(*t.Inner) + ".nullable()"
}
return "z.lazy(() => " + t.Name + "Schema)"
}
return "z.unknown()"
}
func applyZodValidations(expr string, t TypeRef, rules []ValidateRule) string {
for _, r := range rules {
switch r.Name {
case "required":
continue
case "omitempty":
continue
case "gte":
if t.Kind == KindInt || t.Kind == KindNumber {
expr += fmt.Sprintf(".min(%s)", r.Param)
}
case "lte":
if t.Kind == KindInt || t.Kind == KindNumber {
expr += fmt.Sprintf(".max(%s)", r.Param)
}
case "gt":
if t.Kind == KindInt || t.Kind == KindNumber {
expr += fmt.Sprintf(".gt(%s)", r.Param)
}
case "lt":
if t.Kind == KindInt || t.Kind == KindNumber {
expr += fmt.Sprintf(".lt(%s)", r.Param)
}
case "min":
if t.Kind == KindString {
expr += fmt.Sprintf(".min(%s)", r.Param)
} else if t.Kind == KindInt || t.Kind == KindNumber {
expr += fmt.Sprintf(".min(%s)", r.Param)
}
case "max":
if t.Kind == KindString {
expr += fmt.Sprintf(".max(%s)", r.Param)
} else if t.Kind == KindInt || t.Kind == KindNumber {
expr += fmt.Sprintf(".max(%s)", r.Param)
}
case "url":
expr += ".url()"
case "email":
expr += ".email()"
case "oneof":
values := strings.Fields(r.Param)
quoted := make([]string, 0, len(values))
for _, v := range values {
quoted = append(quoted, fmt.Sprintf("'%s'", v))
}
expr = fmt.Sprintf("z.enum([%s])", strings.Join(quoted, ", "))
}
}
return expr
}
func quoteIfNeeded(name string) string {
if name == "" {
return "''"
}
for i, r := range name {
if r >= 'a' && r <= 'z' {
continue
}
if r >= 'A' && r <= 'Z' {
continue
}
if r == '_' || r == '$' {
continue
}
if i > 0 && r >= '0' && r <= '9' {
continue
}
return "'" + name + "'"
}
return name
}
const zodHeader = `// Code generated by tools/openapigen. DO NOT EDIT.
import { z } from 'zod';`

115
tools/openapigen/main.go Normal file
View File

@@ -0,0 +1,115 @@
package main
import (
"bytes"
"flag"
"fmt"
"os"
"path/filepath"
)
func main() {
root := flag.String("root", ".", "repository root containing database/model and web/entity")
outDir := flag.String("out", "frontend/src/generated", "output directory relative to root")
flag.Parse()
if err := run(*root, *outDir); err != nil {
fmt.Fprintln(os.Stderr, "openapigen:", err)
os.Exit(1)
}
}
func run(root, outDir string) error {
requests := []packageRequest{
{
Path: resolveRel(root, "database/model"),
StructAllow: setOf(
"User",
"Inbound",
"FallbackParentInfo",
"OutboundTraffics",
"InboundClientIps",
"ApiToken",
"HistoryOfSeeders",
"Setting",
"Node",
"CustomGeoResource",
"ClientReverse",
"Client",
"ClientRecord",
"ClientInbound",
"InboundFallback",
),
AliasAllow: setOf("Protocol"),
Overrides: map[string][]walkOverride{
"Inbound": {
{Field: "Settings", Kind: KindAny},
{Field: "StreamSettings", Kind: KindAny},
{Field: "Sniffing", Kind: KindAny},
},
"ClientRecord": {
{Field: "Reverse", Kind: KindAny},
},
"InboundClientIps": {
{Field: "Ips", Kind: KindAny},
},
},
},
{
Path: resolveRel(root, "web/entity"),
StructAllow: setOf(
"Msg",
"AllSetting",
"AllSettingView",
),
},
{
Path: resolveRel(root, "xray"),
StructAllow: setOf(
"ClientTraffic",
),
},
}
schemas, aliases, err := walkPackages(requests)
if err != nil {
return err
}
schemas = flattenEmbedded(schemas)
if len(schemas) == 0 {
return fmt.Errorf("no schemas produced; nothing to write")
}
target := filepath.Join(root, outDir)
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
zodBuf := &bytes.Buffer{}
if err := emitZod(zodBuf, schemas, aliases); err != nil {
return err
}
typesBuf := &bytes.Buffer{}
if err := emitTypes(typesBuf, schemas, aliases); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
return err
}
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
return nil
}
func setOf(names ...string) map[string]bool {
m := make(map[string]bool, len(names))
for _, n := range names {
m[n] = true
}
return m
}

172
tools/openapigen/schema.go Normal file
View File

@@ -0,0 +1,172 @@
package main
import (
"reflect"
"sort"
"strings"
)
type Schema struct {
Name string
Package string
Fields []Field
Doc string
}
type Alias struct {
Name string
Package string
Underlying TypeRef
}
type Field struct {
JSONName string
GoName string
Type TypeRef
Optional bool
Skip bool
Validate []ValidateRule
Doc string
}
type TypeRef struct {
Kind TypeKind
Name string
Element *TypeRef
Key *TypeRef
Value *TypeRef
Inner *TypeRef
}
type TypeKind string
const (
KindString TypeKind = "string"
KindNumber TypeKind = "number"
KindInt TypeKind = "int"
KindBool TypeKind = "boolean"
KindArray TypeKind = "array"
KindMap TypeKind = "map"
KindObject TypeKind = "object"
KindRef TypeKind = "ref"
KindUnknown TypeKind = "unknown"
KindAny TypeKind = "any"
KindRaw TypeKind = "raw"
)
type ValidateRule struct {
Name string
Param string
}
func parseStructTag(raw string) (json string, validate string, gormHasDash bool) {
tag := reflect.StructTag(strings.Trim(raw, "`"))
json = tag.Get("json")
validate = tag.Get("validate")
if g := tag.Get("gorm"); g != "" {
for _, part := range strings.Split(g, ";") {
if strings.TrimSpace(part) == "-" {
gormHasDash = true
}
}
}
return
}
func parseJSONTag(tag string) (name string, omit bool, omitempty bool) {
if tag == "" {
return "", false, false
}
parts := strings.Split(tag, ",")
name = parts[0]
if name == "-" {
return "", true, false
}
for _, p := range parts[1:] {
if p == "omitempty" {
omitempty = true
}
}
return
}
func parseValidateTag(tag string) []ValidateRule {
if tag == "" {
return nil
}
var rules []ValidateRule
for _, part := range strings.Split(tag, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
eq := strings.IndexByte(part, '=')
if eq < 0 {
rules = append(rules, ValidateRule{Name: part})
continue
}
rules = append(rules, ValidateRule{Name: part[:eq], Param: part[eq+1:]})
}
return rules
}
func (s Schema) HasValidationOn(field string) bool {
for _, f := range s.Fields {
if f.JSONName == field {
return len(f.Validate) > 0
}
}
return false
}
func sortSchemas(in []Schema) []Schema {
out := make([]Schema, len(in))
copy(out, in)
sort.Slice(out, func(i, j int) bool {
return out[i].Name < out[j].Name
})
return out
}
func sortAliases(in []Alias) []Alias {
out := make([]Alias, len(in))
copy(out, in)
sort.Slice(out, func(i, j int) bool {
return out[i].Name < out[j].Name
})
return out
}
func flattenEmbedded(schemas []Schema) []Schema {
byName := make(map[string]Schema, len(schemas))
for _, s := range schemas {
byName[s.Name] = s
}
out := make([]Schema, 0, len(schemas))
for _, s := range schemas {
var resolved []Field
seen := make(map[string]bool, len(s.Fields))
for _, f := range s.Fields {
if f.Type.Kind == KindRef && f.Type.Name != "nullable" {
if embedded, ok := byName[f.Type.Name]; ok && f.GoName == f.Type.Name {
for _, ef := range embedded.Fields {
if seen[ef.JSONName] {
continue
}
seen[ef.JSONName] = true
resolved = append(resolved, ef)
}
continue
}
}
if seen[f.JSONName] {
continue
}
seen[f.JSONName] = true
resolved = append(resolved, f)
}
s.Fields = resolved
out = append(out, s)
}
return out
}

244
tools/openapigen/walker.go Normal file
View File

@@ -0,0 +1,244 @@
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))
}