diff --git a/frontend/package.json b/frontend/package.json index a5fe99c0..1f5e4759 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts new file mode 100644 index 00000000..304bb6ac --- /dev/null +++ b/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; +} + diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts new file mode 100644 index 00000000..18b5a90f --- /dev/null +++ b/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; + +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; + +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; + +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; + +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; + +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; + +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; + +export const ClientReverseSchema = z.object({ + tag: z.string(), +}); +export type ClientReverse = z.infer; + +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; + +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; + +export const FallbackParentInfoSchema = z.object({ + masterId: z.number().int(), + path: z.string().optional(), +}); +export type FallbackParentInfo = z.infer; + +export const HistoryOfSeedersSchema = z.object({ + id: z.number().int(), + seederName: z.string(), +}); +export type HistoryOfSeeders = z.infer; + +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; + +export const InboundClientIpsSchema = z.object({ + clientEmail: z.string(), + id: z.number().int(), + ips: z.unknown(), +}); +export type InboundClientIps = z.infer; + +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; + +export const MsgSchema = z.object({ + msg: z.string(), + obj: z.unknown(), + success: z.boolean(), +}); +export type Msg = z.infer; + +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; + +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; + +export const SettingSchema = z.object({ + id: z.number().int(), + key: z.string(), + value: z.string(), +}); +export type Setting = z.infer; + +export const UserSchema = z.object({ + id: z.number().int(), + password: z.string(), + username: z.string(), +}); +export type User = z.infer; + diff --git a/tools/openapigen/emit_types.go b/tools/openapigen/emit_types.go new file mode 100644 index 00000000..338231a0 --- /dev/null +++ b/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.` diff --git a/tools/openapigen/emit_zod.go b/tools/openapigen/emit_zod.go new file mode 100644 index 00000000..afcb3181 --- /dev/null +++ b/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;\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;\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';` diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go new file mode 100644 index 00000000..53f71815 --- /dev/null +++ b/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 +} diff --git a/tools/openapigen/schema.go b/tools/openapigen/schema.go new file mode 100644 index 00000000..9ff6e279 --- /dev/null +++ b/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 +} diff --git a/tools/openapigen/walker.go b/tools/openapigen/walker.go new file mode 100644 index 00000000..0228026c --- /dev/null +++ b/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)) +}