mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 09:29:34 +00:00
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:
@@ -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",
|
||||
|
||||
359
frontend/src/generated/types.ts
Normal file
359
frontend/src/generated/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
380
frontend/src/generated/zod.ts
Normal file
380
frontend/src/generated/zod.ts
Normal 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>;
|
||||
|
||||
72
tools/openapigen/emit_types.go
Normal file
72
tools/openapigen/emit_types.go
Normal 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.`
|
||||
156
tools/openapigen/emit_zod.go
Normal file
156
tools/openapigen/emit_zod.go
Normal 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
115
tools/openapigen/main.go
Normal 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
172
tools/openapigen/schema.go
Normal 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
244
tools/openapigen/walker.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user