Browse Source

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.
MHSanaei 12 hours ago
parent
commit
7bd281d26d

+ 2 - 1
frontend/package.json

@@ -14,7 +14,8 @@
     "preview": "vite preview",
     "preview": "vite preview",
     "lint": "eslint src",
     "lint": "eslint src",
     "typecheck": "tsc --noEmit",
     "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": {
   "dependencies": {
     "@ant-design/icons": "^6.2.3",
     "@ant-design/icons": "^6.2.3",

+ 359 - 0
frontend/src/generated/types.ts

@@ -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;
+}
+

+ 380 - 0
frontend/src/generated/zod.ts

@@ -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>;
+

+ 72 - 0
tools/openapigen/emit_types.go

@@ -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.`

+ 156 - 0
tools/openapigen/emit_zod.go

@@ -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 - 0
tools/openapigen/main.go

@@ -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 - 0
tools/openapigen/schema.go

@@ -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 - 0
tools/openapigen/walker.go

@@ -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))
+}