From a014c01725a6270f012c1c3c1e7e96acbbf81d3e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 6 Jun 2026 16:22:21 +0200 Subject: [PATCH] feat(api-docs): generate OpenAPI components/schemas from Go structs A new emit_jsonschema.go walks the same allow-listed structs as the zod/types/examples emitters and writes generated/schemas.ts (SCHEMAS). build-openapi mounts it under components.schemas and points each typed response obj at a $ref instead of an untyped {} blob, so Swagger renders real models and openapi-generator can emit clients. Also add a vitest guard that safeParses every EXAMPLES entry against its generated zod schema, reviving the previously unused generated/zod.ts and catching drift between the example and schema emitters. --- frontend/scripts/build-openapi.mjs | 18 +- frontend/src/generated/schemas.ts | 1785 ++++++++++++++++++ frontend/src/test/generated-examples.test.ts | 30 + tools/openapigen/emit_jsonschema.go | 190 ++ tools/openapigen/main.go | 7 + 5 files changed, 2026 insertions(+), 4 deletions(-) create mode 100644 frontend/src/generated/schemas.ts create mode 100644 frontend/src/test/generated-examples.test.ts create mode 100644 tools/openapigen/emit_jsonschema.go diff --git a/frontend/scripts/build-openapi.mjs b/frontend/scripts/build-openapi.mjs index 06d69e98..eb2401bf 100644 --- a/frontend/scripts/build-openapi.mjs +++ b/frontend/scripts/build-openapi.mjs @@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { sections } from '../src/pages/api-docs/endpoints.ts'; import { EXAMPLES } from '../src/generated/examples.ts'; +import { SCHEMAS } from '../src/generated/schemas.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const outPath = join(__dirname, '..', 'public', 'openapi.json'); @@ -130,12 +131,20 @@ function buildOperation(ep, tag) { const responses = {}; let successExample = tryParseJson(ep.response); - if (successExample === undefined && ep.responseSchema) { + let objSchema = {}; + if (ep.responseSchema) { const obj = EXAMPLES[ep.responseSchema]; if (obj === undefined) { throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`); } - successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj }; + if (SCHEMAS[ep.responseSchema] === undefined) { + throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`); + } + const ref = { $ref: `#/components/schemas/${ep.responseSchema}` }; + objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref; + if (successExample === undefined) { + successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj }; + } } responses['200'] = { description: 'Successful response', @@ -146,7 +155,7 @@ function buildOperation(ep, tag) { properties: { success: { type: 'boolean' }, msg: { type: 'string' }, - obj: {}, + obj: objSchema, }, }, ...(successExample !== undefined ? { example: successExample } : {}), @@ -200,13 +209,14 @@ function buildSpec() { title: '3X-UI Panel API', version: PANEL_VERSION, description: - 'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.', + 'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.', }, servers: [ { url: '/', description: 'Current panel (basePath aware)' }, ], components: { securitySchemes: SECURITY_SCHEMES, + schemas: SCHEMAS, }, security: [{ bearerAuth: [] }, { cookieAuth: [] }], tags, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts new file mode 100644 index 00000000..bc4240fe --- /dev/null +++ b/frontend/src/generated/schemas.ts @@ -0,0 +1,1785 @@ +// Code generated by tools/openapigen. DO NOT EDIT. +export const SCHEMAS: Record = { + "AllSetting": { + "description": "AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.", + "properties": { + "datepicker": { + "description": "Date picker format", + "type": "string" + }, + "expireDiff": { + "description": "Expiration warning threshold in days", + "minimum": 0, + "type": "integer" + }, + "externalTrafficInformEnable": { + "description": "Enable external traffic reporting", + "type": "boolean" + }, + "externalTrafficInformURI": { + "description": "URI for external traffic reporting", + "type": "string" + }, + "ldapAutoCreate": { + "type": "boolean" + }, + "ldapAutoDelete": { + "type": "boolean" + }, + "ldapBaseDN": { + "type": "string" + }, + "ldapBindDN": { + "type": "string" + }, + "ldapDefaultExpiryDays": { + "minimum": 0, + "type": "integer" + }, + "ldapDefaultLimitIP": { + "minimum": 0, + "type": "integer" + }, + "ldapDefaultTotalGB": { + "minimum": 0, + "type": "integer" + }, + "ldapEnable": { + "description": "LDAP settings", + "type": "boolean" + }, + "ldapFlagField": { + "description": "Generic flag configuration", + "type": "string" + }, + "ldapHost": { + "type": "string" + }, + "ldapInboundTags": { + "type": "string" + }, + "ldapInvertFlag": { + "type": "boolean" + }, + "ldapPassword": { + "type": "string" + }, + "ldapPort": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "ldapSyncCron": { + "type": "string" + }, + "ldapTruthyValues": { + "type": "string" + }, + "ldapUseTLS": { + "type": "boolean" + }, + "ldapUserAttr": { + "description": "e.g., mail or uid", + "type": "string" + }, + "ldapUserFilter": { + "type": "string" + }, + "ldapVlessField": { + "type": "string" + }, + "pageSize": { + "description": "UI settings\nNumber of items per page in lists (0 disables pagination)", + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "panelProxy": { + "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)", + "type": "string" + }, + "remarkModel": { + "description": "Remark model pattern for inbounds", + "type": "string" + }, + "restartXrayOnClientDisable": { + "description": "Restart Xray when clients are auto-disabled by expiry/traffic limit", + "type": "boolean" + }, + "sessionMaxAge": { + "description": "Session maximum age in minutes (cap at one year)", + "maximum": 525600, + "minimum": 1, + "type": "integer" + }, + "subAnnounce": { + "description": "Subscription announce", + "type": "string" + }, + "subCertFile": { + "description": "SSL certificate file for subscription server", + "type": "string" + }, + "subClashEnable": { + "description": "Enable Clash/Mihomo subscription endpoint", + "type": "boolean" + }, + "subClashEnableRouting": { + "description": "Enable global routing rules for Clash/Mihomo", + "type": "boolean" + }, + "subClashPath": { + "description": "Path for Clash/Mihomo subscription endpoint", + "type": "string" + }, + "subClashRules": { + "description": "Clash/Mihomo global routing rules", + "type": "string" + }, + "subClashURI": { + "description": "Clash/Mihomo subscription server URI", + "type": "string" + }, + "subDomain": { + "description": "Domain for subscription server validation", + "type": "string" + }, + "subEmailInRemark": { + "description": "Include email in subscription remark/name", + "type": "boolean" + }, + "subEnable": { + "description": "Subscription server settings\nEnable subscription server", + "type": "boolean" + }, + "subEnableRouting": { + "description": "Enable routing for subscription", + "type": "boolean" + }, + "subEncrypt": { + "description": "Encrypt subscription responses", + "type": "boolean" + }, + "subJsonEnable": { + "description": "Enable JSON subscription endpoint", + "type": "boolean" + }, + "subJsonFinalMask": { + "description": "JSON subscription global finalmask (tcp/udp masks + quicParams)", + "type": "string" + }, + "subJsonMux": { + "description": "JSON subscription mux configuration", + "type": "string" + }, + "subJsonPath": { + "description": "Path for JSON subscription endpoint", + "type": "string" + }, + "subJsonRules": { + "type": "string" + }, + "subJsonURI": { + "description": "JSON subscription server URI", + "type": "string" + }, + "subKeyFile": { + "description": "SSL private key file for subscription server", + "type": "string" + }, + "subListen": { + "description": "Subscription server listen IP", + "type": "string" + }, + "subPath": { + "description": "Base path for subscription URLs", + "type": "string" + }, + "subPort": { + "description": "Subscription server port", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "subProfileUrl": { + "description": "Subscription profile URL", + "type": "string" + }, + "subRoutingRules": { + "description": "Subscription global routing rules (Only for Happ)", + "type": "string" + }, + "subShowInfo": { + "description": "Show client information in subscriptions", + "type": "boolean" + }, + "subSupportUrl": { + "description": "Subscription support URL", + "type": "string" + }, + "subTitle": { + "description": "Subscription title", + "type": "string" + }, + "subURI": { + "description": "Subscription server URI", + "type": "string" + }, + "subUpdates": { + "description": "Subscription update interval in minutes", + "maximum": 525600, + "minimum": 0, + "type": "integer" + }, + "tgBotAPIServer": { + "description": "Custom API server for Telegram bot", + "type": "string" + }, + "tgBotBackup": { + "description": "Enable database backup via Telegram", + "type": "boolean" + }, + "tgBotChatId": { + "description": "Telegram chat ID for notifications", + "type": "string" + }, + "tgBotEnable": { + "description": "Telegram bot settings\nEnable Telegram bot notifications", + "type": "boolean" + }, + "tgBotLoginNotify": { + "description": "Send login notifications", + "type": "boolean" + }, + "tgBotProxy": { + "description": "Proxy URL for Telegram bot", + "type": "string" + }, + "tgBotToken": { + "description": "Telegram bot token", + "type": "string" + }, + "tgCpu": { + "description": "CPU usage threshold for alerts (percent)", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "tgLang": { + "description": "Telegram bot language", + "type": "string" + }, + "tgRunTime": { + "description": "Cron schedule for Telegram notifications", + "type": "string" + }, + "timeLocation": { + "description": "Security settings\nTime zone location", + "type": "string" + }, + "trafficDiff": { + "description": "Traffic warning threshold percentage", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "trustedProxyCIDRs": { + "description": "Trusted reverse proxy IPs/CIDRs for forwarded headers", + "type": "string" + }, + "twoFactorEnable": { + "description": "Enable two-factor authentication", + "type": "boolean" + }, + "twoFactorToken": { + "description": "Two-factor authentication token", + "type": "string" + }, + "webBasePath": { + "description": "Base path for web panel URLs", + "type": "string" + }, + "webCertFile": { + "description": "Path to SSL certificate file for web server", + "type": "string" + }, + "webDomain": { + "description": "Web server domain for domain validation", + "type": "string" + }, + "webKeyFile": { + "description": "Path to SSL private key file for web server", + "type": "string" + }, + "webListen": { + "description": "Web server settings\nWeb server listen IP address", + "type": "string" + }, + "webPort": { + "description": "Web server port number", + "maximum": 65535, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "datepicker", + "expireDiff", + "externalTrafficInformEnable", + "externalTrafficInformURI", + "ldapAutoCreate", + "ldapAutoDelete", + "ldapBaseDN", + "ldapBindDN", + "ldapDefaultExpiryDays", + "ldapDefaultLimitIP", + "ldapDefaultTotalGB", + "ldapEnable", + "ldapFlagField", + "ldapHost", + "ldapInboundTags", + "ldapInvertFlag", + "ldapPassword", + "ldapPort", + "ldapSyncCron", + "ldapTruthyValues", + "ldapUseTLS", + "ldapUserAttr", + "ldapUserFilter", + "ldapVlessField", + "pageSize", + "panelProxy", + "remarkModel", + "restartXrayOnClientDisable", + "sessionMaxAge", + "subAnnounce", + "subCertFile", + "subClashEnable", + "subClashEnableRouting", + "subClashPath", + "subClashRules", + "subClashURI", + "subDomain", + "subEmailInRemark", + "subEnable", + "subEnableRouting", + "subEncrypt", + "subJsonEnable", + "subJsonFinalMask", + "subJsonMux", + "subJsonPath", + "subJsonRules", + "subJsonURI", + "subKeyFile", + "subListen", + "subPath", + "subPort", + "subProfileUrl", + "subRoutingRules", + "subShowInfo", + "subSupportUrl", + "subTitle", + "subURI", + "subUpdates", + "tgBotAPIServer", + "tgBotBackup", + "tgBotChatId", + "tgBotEnable", + "tgBotLoginNotify", + "tgBotProxy", + "tgBotToken", + "tgCpu", + "tgLang", + "tgRunTime", + "timeLocation", + "trafficDiff", + "trustedProxyCIDRs", + "twoFactorEnable", + "twoFactorToken", + "webBasePath", + "webCertFile", + "webDomain", + "webKeyFile", + "webListen", + "webPort" + ], + "type": "object" + }, + "AllSettingView": { + "description": "AllSettingView is the browser-safe settings read model. Secret values\nare redacted from the embedded write model and represented by presence\nflags so the UI can show configured/not configured state.", + "properties": { + "datepicker": { + "description": "Date picker format", + "type": "string" + }, + "expireDiff": { + "description": "Expiration warning threshold in days", + "minimum": 0, + "type": "integer" + }, + "externalTrafficInformEnable": { + "description": "Enable external traffic reporting", + "type": "boolean" + }, + "externalTrafficInformURI": { + "description": "URI for external traffic reporting", + "type": "string" + }, + "hasApiToken": { + "type": "boolean" + }, + "hasLdapPassword": { + "type": "boolean" + }, + "hasNordSecret": { + "type": "boolean" + }, + "hasTgBotToken": { + "type": "boolean" + }, + "hasTwoFactorToken": { + "type": "boolean" + }, + "hasWarpSecret": { + "type": "boolean" + }, + "ldapAutoCreate": { + "type": "boolean" + }, + "ldapAutoDelete": { + "type": "boolean" + }, + "ldapBaseDN": { + "type": "string" + }, + "ldapBindDN": { + "type": "string" + }, + "ldapDefaultExpiryDays": { + "minimum": 0, + "type": "integer" + }, + "ldapDefaultLimitIP": { + "minimum": 0, + "type": "integer" + }, + "ldapDefaultTotalGB": { + "minimum": 0, + "type": "integer" + }, + "ldapEnable": { + "description": "LDAP settings", + "type": "boolean" + }, + "ldapFlagField": { + "description": "Generic flag configuration", + "type": "string" + }, + "ldapHost": { + "type": "string" + }, + "ldapInboundTags": { + "type": "string" + }, + "ldapInvertFlag": { + "type": "boolean" + }, + "ldapPassword": { + "type": "string" + }, + "ldapPort": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "ldapSyncCron": { + "type": "string" + }, + "ldapTruthyValues": { + "type": "string" + }, + "ldapUseTLS": { + "type": "boolean" + }, + "ldapUserAttr": { + "description": "e.g., mail or uid", + "type": "string" + }, + "ldapUserFilter": { + "type": "string" + }, + "ldapVlessField": { + "type": "string" + }, + "pageSize": { + "description": "UI settings\nNumber of items per page in lists (0 disables pagination)", + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "panelProxy": { + "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)", + "type": "string" + }, + "remarkModel": { + "description": "Remark model pattern for inbounds", + "type": "string" + }, + "restartXrayOnClientDisable": { + "description": "Restart Xray when clients are auto-disabled by expiry/traffic limit", + "type": "boolean" + }, + "sessionMaxAge": { + "description": "Session maximum age in minutes (cap at one year)", + "maximum": 525600, + "minimum": 1, + "type": "integer" + }, + "subAnnounce": { + "description": "Subscription announce", + "type": "string" + }, + "subCertFile": { + "description": "SSL certificate file for subscription server", + "type": "string" + }, + "subClashEnable": { + "description": "Enable Clash/Mihomo subscription endpoint", + "type": "boolean" + }, + "subClashEnableRouting": { + "description": "Enable global routing rules for Clash/Mihomo", + "type": "boolean" + }, + "subClashPath": { + "description": "Path for Clash/Mihomo subscription endpoint", + "type": "string" + }, + "subClashRules": { + "description": "Clash/Mihomo global routing rules", + "type": "string" + }, + "subClashURI": { + "description": "Clash/Mihomo subscription server URI", + "type": "string" + }, + "subDomain": { + "description": "Domain for subscription server validation", + "type": "string" + }, + "subEmailInRemark": { + "description": "Include email in subscription remark/name", + "type": "boolean" + }, + "subEnable": { + "description": "Subscription server settings\nEnable subscription server", + "type": "boolean" + }, + "subEnableRouting": { + "description": "Enable routing for subscription", + "type": "boolean" + }, + "subEncrypt": { + "description": "Encrypt subscription responses", + "type": "boolean" + }, + "subJsonEnable": { + "description": "Enable JSON subscription endpoint", + "type": "boolean" + }, + "subJsonFinalMask": { + "description": "JSON subscription global finalmask (tcp/udp masks + quicParams)", + "type": "string" + }, + "subJsonMux": { + "description": "JSON subscription mux configuration", + "type": "string" + }, + "subJsonPath": { + "description": "Path for JSON subscription endpoint", + "type": "string" + }, + "subJsonRules": { + "type": "string" + }, + "subJsonURI": { + "description": "JSON subscription server URI", + "type": "string" + }, + "subKeyFile": { + "description": "SSL private key file for subscription server", + "type": "string" + }, + "subListen": { + "description": "Subscription server listen IP", + "type": "string" + }, + "subPath": { + "description": "Base path for subscription URLs", + "type": "string" + }, + "subPort": { + "description": "Subscription server port", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "subProfileUrl": { + "description": "Subscription profile URL", + "type": "string" + }, + "subRoutingRules": { + "description": "Subscription global routing rules (Only for Happ)", + "type": "string" + }, + "subShowInfo": { + "description": "Show client information in subscriptions", + "type": "boolean" + }, + "subSupportUrl": { + "description": "Subscription support URL", + "type": "string" + }, + "subTitle": { + "description": "Subscription title", + "type": "string" + }, + "subURI": { + "description": "Subscription server URI", + "type": "string" + }, + "subUpdates": { + "description": "Subscription update interval in minutes", + "maximum": 525600, + "minimum": 0, + "type": "integer" + }, + "tgBotAPIServer": { + "description": "Custom API server for Telegram bot", + "type": "string" + }, + "tgBotBackup": { + "description": "Enable database backup via Telegram", + "type": "boolean" + }, + "tgBotChatId": { + "description": "Telegram chat ID for notifications", + "type": "string" + }, + "tgBotEnable": { + "description": "Telegram bot settings\nEnable Telegram bot notifications", + "type": "boolean" + }, + "tgBotLoginNotify": { + "description": "Send login notifications", + "type": "boolean" + }, + "tgBotProxy": { + "description": "Proxy URL for Telegram bot", + "type": "string" + }, + "tgBotToken": { + "description": "Telegram bot token", + "type": "string" + }, + "tgCpu": { + "description": "CPU usage threshold for alerts (percent)", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "tgLang": { + "description": "Telegram bot language", + "type": "string" + }, + "tgRunTime": { + "description": "Cron schedule for Telegram notifications", + "type": "string" + }, + "timeLocation": { + "description": "Security settings\nTime zone location", + "type": "string" + }, + "trafficDiff": { + "description": "Traffic warning threshold percentage", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "trustedProxyCIDRs": { + "description": "Trusted reverse proxy IPs/CIDRs for forwarded headers", + "type": "string" + }, + "twoFactorEnable": { + "description": "Enable two-factor authentication", + "type": "boolean" + }, + "twoFactorToken": { + "description": "Two-factor authentication token", + "type": "string" + }, + "webBasePath": { + "description": "Base path for web panel URLs", + "type": "string" + }, + "webCertFile": { + "description": "Path to SSL certificate file for web server", + "type": "string" + }, + "webDomain": { + "description": "Web server domain for domain validation", + "type": "string" + }, + "webKeyFile": { + "description": "Path to SSL private key file for web server", + "type": "string" + }, + "webListen": { + "description": "Web server settings\nWeb server listen IP address", + "type": "string" + }, + "webPort": { + "description": "Web server port number", + "maximum": 65535, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "datepicker", + "expireDiff", + "externalTrafficInformEnable", + "externalTrafficInformURI", + "hasApiToken", + "hasLdapPassword", + "hasNordSecret", + "hasTgBotToken", + "hasTwoFactorToken", + "hasWarpSecret", + "ldapAutoCreate", + "ldapAutoDelete", + "ldapBaseDN", + "ldapBindDN", + "ldapDefaultExpiryDays", + "ldapDefaultLimitIP", + "ldapDefaultTotalGB", + "ldapEnable", + "ldapFlagField", + "ldapHost", + "ldapInboundTags", + "ldapInvertFlag", + "ldapPassword", + "ldapPort", + "ldapSyncCron", + "ldapTruthyValues", + "ldapUseTLS", + "ldapUserAttr", + "ldapUserFilter", + "ldapVlessField", + "pageSize", + "panelProxy", + "remarkModel", + "restartXrayOnClientDisable", + "sessionMaxAge", + "subAnnounce", + "subCertFile", + "subClashEnable", + "subClashEnableRouting", + "subClashPath", + "subClashRules", + "subClashURI", + "subDomain", + "subEmailInRemark", + "subEnable", + "subEnableRouting", + "subEncrypt", + "subJsonEnable", + "subJsonFinalMask", + "subJsonMux", + "subJsonPath", + "subJsonRules", + "subJsonURI", + "subKeyFile", + "subListen", + "subPath", + "subPort", + "subProfileUrl", + "subRoutingRules", + "subShowInfo", + "subSupportUrl", + "subTitle", + "subURI", + "subUpdates", + "tgBotAPIServer", + "tgBotBackup", + "tgBotChatId", + "tgBotEnable", + "tgBotLoginNotify", + "tgBotProxy", + "tgBotToken", + "tgCpu", + "tgLang", + "tgRunTime", + "timeLocation", + "trafficDiff", + "trustedProxyCIDRs", + "twoFactorEnable", + "twoFactorToken", + "webBasePath", + "webCertFile", + "webDomain", + "webKeyFile", + "webListen", + "webPort" + ], + "type": "object" + }, + "ApiToken": { + "properties": { + "createdAt": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "token": { + "description": "SHA-256 hash; the plaintext is shown only once at creation", + "type": "string" + } + }, + "required": [ + "createdAt", + "enabled", + "id", + "name", + "token" + ], + "type": "object" + }, + "ApiTokenView": { + "properties": { + "createdAt": { + "example": 1736000000, + "type": "integer" + }, + "enabled": { + "example": true, + "type": "boolean" + }, + "id": { + "example": 2, + "type": "integer" + }, + "name": { + "example": "central-panel-a", + "type": "string" + }, + "token": { + "example": "new-token-string", + "type": "string" + } + }, + "required": [ + "createdAt", + "enabled", + "id", + "name" + ], + "type": "object" + }, + "Client": { + "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.", + "properties": { + "auth": { + "description": "Auth password (Hysteria)", + "type": "string" + }, + "comment": { + "description": "Client comment", + "type": "string" + }, + "created_at": { + "description": "Creation timestamp", + "type": "integer" + }, + "email": { + "description": "Client email identifier", + "type": "string" + }, + "enable": { + "description": "Whether the client is enabled", + "type": "boolean" + }, + "expiryTime": { + "description": "Expiration timestamp", + "type": "integer" + }, + "flow": { + "description": "Flow control (XTLS)", + "type": "string" + }, + "group": { + "description": "Logical grouping label", + "type": "string" + }, + "id": { + "description": "Unique client identifier", + "type": "string" + }, + "limitIp": { + "description": "IP limit for this client", + "type": "integer" + }, + "password": { + "description": "Client password", + "type": "string" + }, + "reset": { + "description": "Reset period in days", + "type": "integer" + }, + "reverse": { + "allOf": [ + { + "$ref": "#/components/schemas/ClientReverse" + } + ], + "description": "VLESS simple reverse proxy settings", + "nullable": true + }, + "security": { + "description": "Security method (e.g., \"auto\", \"aes-128-gcm\")", + "type": "string" + }, + "subId": { + "description": "Subscription identifier", + "type": "string" + }, + "tgId": { + "description": "Telegram user ID for notifications", + "type": "integer" + }, + "totalGB": { + "description": "Total traffic limit in GB", + "type": "integer" + }, + "updated_at": { + "description": "Last update timestamp", + "type": "integer" + } + }, + "required": [ + "comment", + "email", + "enable", + "expiryTime", + "limitIp", + "reset", + "security", + "subId", + "tgId", + "totalGB" + ], + "type": "object" + }, + "ClientInbound": { + "properties": { + "clientId": { + "type": "integer" + }, + "createdAt": { + "type": "integer" + }, + "flowOverride": { + "type": "string" + }, + "inboundId": { + "type": "integer" + } + }, + "required": [ + "clientId", + "createdAt", + "flowOverride", + "inboundId" + ], + "type": "object" + }, + "ClientRecord": { + "properties": { + "auth": { + "type": "string" + }, + "comment": { + "type": "string" + }, + "createdAt": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "enable": { + "type": "boolean" + }, + "expiryTime": { + "type": "integer" + }, + "flow": { + "type": "string" + }, + "group": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "limitIp": { + "type": "integer" + }, + "password": { + "type": "string" + }, + "reset": { + "type": "integer" + }, + "reverse": {}, + "security": { + "type": "string" + }, + "subId": { + "type": "string" + }, + "tgId": { + "type": "integer" + }, + "totalGB": { + "type": "integer" + }, + "updatedAt": { + "type": "integer" + }, + "uuid": { + "type": "string" + } + }, + "required": [ + "auth", + "comment", + "createdAt", + "email", + "enable", + "expiryTime", + "flow", + "group", + "id", + "limitIp", + "password", + "reset", + "reverse", + "security", + "subId", + "tgId", + "totalGB", + "updatedAt", + "uuid" + ], + "type": "object" + }, + "ClientReverse": { + "properties": { + "tag": { + "type": "string" + } + }, + "required": [ + "tag" + ], + "type": "object" + }, + "ClientTraffic": { + "description": "ClientTraffic represents traffic statistics and limits for a specific client.\nIt tracks upload/download usage, expiry times, and online status for inbound clients.", + "properties": { + "down": { + "example": 2097152, + "type": "integer" + }, + "email": { + "example": "user1", + "type": "string" + }, + "enable": { + "example": true, + "type": "boolean" + }, + "expiryTime": { + "example": 1735689600000, + "type": "integer" + }, + "id": { + "example": 14825, + "type": "integer" + }, + "inboundId": { + "example": 1, + "type": "integer" + }, + "lastOnline": { + "example": 1735680000000, + "type": "integer" + }, + "reset": { + "example": 0, + "type": "integer" + }, + "subId": { + "example": "i7tvdpeffi0hvvf1", + "type": "string" + }, + "total": { + "example": 10737418240, + "type": "integer" + }, + "up": { + "example": 1048576, + "type": "integer" + }, + "uuid": { + "example": "e18c9a96-71bf-48d4-933f-8b9a46d4290c", + "type": "string" + } + }, + "required": [ + "down", + "email", + "enable", + "expiryTime", + "id", + "inboundId", + "lastOnline", + "reset", + "subId", + "total", + "up", + "uuid" + ], + "type": "object" + }, + "CustomGeoResource": { + "properties": { + "alias": { + "type": "string" + }, + "createdAt": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "lastModified": { + "type": "string" + }, + "lastUpdatedAt": { + "type": "integer" + }, + "localPath": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updatedAt": { + "type": "integer" + }, + "url": { + "type": "string" + } + }, + "required": [ + "alias", + "createdAt", + "id", + "lastModified", + "lastUpdatedAt", + "localPath", + "type", + "updatedAt", + "url" + ], + "type": "object" + }, + "FallbackParentInfo": { + "description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.", + "properties": { + "masterId": { + "type": "integer" + }, + "path": { + "type": "string" + } + }, + "required": [ + "masterId" + ], + "type": "object" + }, + "HistoryOfSeeders": { + "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.", + "properties": { + "id": { + "type": "integer" + }, + "seederName": { + "type": "string" + } + }, + "required": [ + "id", + "seederName" + ], + "type": "object" + }, + "Inbound": { + "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.", + "properties": { + "clientStats": { + "description": "Client traffic statistics", + "items": { + "$ref": "#/components/schemas/ClientTraffic" + }, + "type": "array" + }, + "down": { + "description": "Download traffic in bytes", + "type": "integer" + }, + "enable": { + "description": "Whether the inbound is enabled", + "example": true, + "type": "boolean" + }, + "expiryTime": { + "description": "Expiration timestamp", + "type": "integer" + }, + "fallbackParent": { + "allOf": [ + { + "$ref": "#/components/schemas/FallbackParentInfo" + } + ], + "description": "FallbackParent is populated by the API layer when this inbound is\nattached as a fallback child of a VLESS/Trojan TCP-TLS master.\nThe frontend uses it to rewrite client-share links so they advertise\nthe master's externally reachable endpoint instead of the child's\nloopback listen. Not persisted.", + "nullable": true + }, + "id": { + "description": "Unique identifier", + "example": 1, + "type": "integer" + }, + "lastTrafficResetTime": { + "description": "Last traffic reset timestamp", + "type": "integer" + }, + "listen": { + "description": "Xray configuration fields", + "type": "string" + }, + "nodeId": { + "nullable": true, + "type": "integer" + }, + "originNodeGuid": { + "description": "OriginNodeGuid is the panelGuid of the node that physically hosts this\ninbound, propagated up across hops (#4983). Empty for an inbound that\nlives on this panel's own xray; set to the originating node's GUID when\nthe inbound was synced from a node (kept as-is across further hops). Lets\nthe master attribute a deeply nested inbound to the real node instead of\nthe intermediate one it was fetched through.", + "type": "string" + }, + "port": { + "example": 443, + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "protocol": { + "enum": [ + "vmess", + "vless", + "trojan", + "shadowsocks", + "wireguard", + "hysteria", + "http", + "mixed", + "tunnel", + "tun" + ], + "example": "vless", + "type": "string" + }, + "remark": { + "description": "Human-readable remark", + "example": "VLESS-443", + "type": "string" + }, + "settings": {}, + "sniffing": {}, + "streamSettings": {}, + "tag": { + "example": "in-443-tcp", + "type": "string" + }, + "total": { + "description": "Total traffic limit in bytes", + "type": "integer" + }, + "trafficReset": { + "description": "Traffic reset schedule", + "enum": [ + "never", + "hourly", + "daily", + "weekly", + "monthly" + ], + "type": "string" + }, + "up": { + "description": "Upload traffic in bytes", + "type": "integer" + } + }, + "required": [ + "clientStats", + "down", + "enable", + "expiryTime", + "id", + "lastTrafficResetTime", + "listen", + "port", + "protocol", + "remark", + "settings", + "sniffing", + "streamSettings", + "tag", + "total", + "trafficReset", + "up" + ], + "type": "object" + }, + "InboundClientIps": { + "description": "InboundClientIps stores IP addresses associated with inbound clients for access control.", + "properties": { + "clientEmail": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "ips": {} + }, + "required": [ + "clientEmail", + "id", + "ips" + ], + "type": "object" + }, + "InboundFallback": { + "properties": { + "alpn": { + "type": "string" + }, + "childId": { + "type": "integer" + }, + "dest": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "masterId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "xver": { + "type": "integer" + } + }, + "required": [ + "alpn", + "childId", + "dest", + "id", + "masterId", + "name", + "path", + "sortOrder", + "xver" + ], + "type": "object" + }, + "InboundOption": { + "properties": { + "id": { + "example": 1, + "type": "integer" + }, + "port": { + "example": 443, + "type": "integer" + }, + "protocol": { + "example": "vless", + "type": "string" + }, + "remark": { + "example": "VLESS-443", + "type": "string" + }, + "ssMethod": { + "type": "string" + }, + "tag": { + "example": "in-443-tcp", + "type": "string" + }, + "tlsFlowCapable": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "id", + "port", + "protocol", + "remark", + "ssMethod", + "tag", + "tlsFlowCapable" + ], + "type": "object" + }, + "Msg": { + "description": "Msg represents a standard API response message with success status, message text, and optional data object.", + "properties": { + "msg": { + "description": "Response message text", + "type": "string" + }, + "obj": { + "description": "Optional data object" + }, + "success": { + "description": "Indicates if the operation was successful", + "type": "boolean" + } + }, + "required": [ + "msg", + "obj", + "success" + ], + "type": "object" + }, + "Node": { + "description": "Node represents a remote 3x-ui panel registered with the central panel.\nThe central panel polls each node's existing /panel/api/server/status\nendpoint over HTTP using the per-node ApiToken to populate the runtime\nstatus fields below.", + "properties": { + "address": { + "example": "node1.example.com", + "type": "string" + }, + "allowPrivateAddress": { + "type": "boolean" + }, + "apiToken": { + "example": "abcdef0123456789", + "type": "string" + }, + "basePath": { + "example": "/", + "type": "string" + }, + "clientCount": { + "example": 27, + "type": "integer" + }, + "configDirty": { + "type": "boolean" + }, + "configDirtyAt": { + "type": "integer" + }, + "cpuPct": { + "example": 23.5, + "type": "number" + }, + "createdAt": { + "example": 1700000000, + "type": "integer" + }, + "depletedCount": { + "example": 1, + "type": "integer" + }, + "enable": { + "example": true, + "type": "boolean" + }, + "guid": { + "description": "Guid is the remote panel's stable self-identifier (its panelGuid),\nlearned from each heartbeat. It is the globally stable node identity used\nto attribute online clients/inbounds to the physical node across a chain\nof nodes (#4983); panel-local autoincrement ids don't survive a hop.\nObserved-state only — never user-edited.", + "type": "string" + }, + "id": { + "example": 1, + "type": "integer" + }, + "inboundCount": { + "example": 5, + "type": "integer" + }, + "lastError": { + "type": "string" + }, + "lastHeartbeat": { + "description": "unix seconds, 0 = never", + "example": 1700000000, + "type": "integer" + }, + "latencyMs": { + "example": 42, + "type": "integer" + }, + "memPct": { + "example": 45.1, + "type": "number" + }, + "name": { + "example": "de-fra-1", + "type": "string" + }, + "onlineCount": { + "example": 3, + "type": "integer" + }, + "panelVersion": { + "example": "v3.x.x", + "type": "string" + }, + "parentGuid": { + "description": "ParentGuid + Transitive are set only when a node is surfaced as part of a\nnode tree (#4983): direct nodes carry the master panel's own GUID, a\ntransitive sub-node carries its parent node's GUID. Transitive nodes are\nread-only projections (Id == 0, not persisted) — never edited or deployed.", + "type": "string" + }, + "pinnedCertSha256": { + "type": "string" + }, + "port": { + "example": 2053, + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "remark": { + "type": "string" + }, + "scheme": { + "enum": [ + "http", + "https" + ], + "example": "https", + "type": "string" + }, + "status": { + "description": "Heartbeat-updated fields. UpdatedAt advances on every probe even when\nthe row is otherwise unchanged so the UI's \"last seen\" tooltip is\ntruthful without us having to read LastHeartbeat separately.\nonline|offline|unknown", + "example": "online", + "type": "string" + }, + "tlsVerifyMode": { + "enum": [ + "verify", + "skip", + "pin" + ], + "type": "string" + }, + "transitive": { + "type": "boolean" + }, + "updatedAt": { + "example": 1700000000, + "type": "integer" + }, + "uptimeSecs": { + "example": 86400, + "type": "integer" + }, + "xrayVersion": { + "example": "25.10.31", + "type": "string" + } + }, + "required": [ + "address", + "allowPrivateAddress", + "apiToken", + "basePath", + "clientCount", + "configDirty", + "configDirtyAt", + "cpuPct", + "createdAt", + "depletedCount", + "enable", + "guid", + "id", + "inboundCount", + "lastError", + "lastHeartbeat", + "latencyMs", + "memPct", + "name", + "onlineCount", + "panelVersion", + "pinnedCertSha256", + "port", + "remark", + "scheme", + "status", + "tlsVerifyMode", + "updatedAt", + "uptimeSecs", + "xrayVersion" + ], + "type": "object" + }, + "OutboundTraffics": { + "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.", + "properties": { + "down": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "tag": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "up": { + "type": "integer" + } + }, + "required": [ + "down", + "id", + "tag", + "total", + "up" + ], + "type": "object" + }, + "ProbeResultUI": { + "properties": { + "cpuPct": { + "example": 12.5, + "type": "number" + }, + "error": { + "type": "string" + }, + "latencyMs": { + "example": 42, + "type": "integer" + }, + "memPct": { + "example": 45.2, + "type": "number" + }, + "panelVersion": { + "example": "v3.x.x", + "type": "string" + }, + "status": { + "example": "online", + "type": "string" + }, + "uptimeSecs": { + "example": 86400, + "type": "integer" + }, + "xrayVersion": { + "example": "25.10.31", + "type": "string" + } + }, + "required": [ + "cpuPct", + "error", + "latencyMs", + "memPct", + "panelVersion", + "status", + "uptimeSecs", + "xrayVersion" + ], + "type": "object" + }, + "Setting": { + "description": "Setting stores key-value configuration settings for the 3x-ui panel.", + "properties": { + "id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "id", + "key", + "value" + ], + "type": "object" + }, + "User": { + "description": "User represents a user account in the 3x-ui panel.", + "properties": { + "id": { + "type": "integer" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "id", + "password", + "username" + ], + "type": "object" + } +}; diff --git a/frontend/src/test/generated-examples.test.ts b/frontend/src/test/generated-examples.test.ts new file mode 100644 index 00000000..ba25990c --- /dev/null +++ b/frontend/src/test/generated-examples.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import type { ZodType } from 'zod'; + +import { EXAMPLES } from '@/generated/examples'; +import * as zodSchemas from '@/generated/zod'; + +const registry = zodSchemas as unknown as Record; +const names = Object.keys(EXAMPLES); + +describe('generated response examples', () => { + it('has at least one example to validate', () => { + expect(names.length).toBeGreaterThan(0); + }); + + it('pairs every example with a generated zod schema', () => { + const missing = names.filter((name) => typeof registry[`${name}Schema`]?.safeParse !== 'function'); + expect(missing).toEqual([]); + }); + + it.each(names)('EXAMPLES.%s satisfies its generated zod schema', (name) => { + const schema = registry[`${name}Schema`]; + const result = schema.safeParse(EXAMPLES[name]); + if (!result.success) { + throw new Error( + `EXAMPLES.${name} does not match ${name}Schema:\n${JSON.stringify(result.error.issues, null, 2)}`, + ); + } + expect(result.success).toBe(true); + }); +}); diff --git a/tools/openapigen/emit_jsonschema.go b/tools/openapigen/emit_jsonschema.go new file mode 100644 index 00000000..c1046479 --- /dev/null +++ b/tools/openapigen/emit_jsonschema.go @@ -0,0 +1,190 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + "strings" +) + +func emitJSONSchema(w io.Writer, schemas []Schema, aliases []Alias) error { + byName := make(map[string]Schema, len(schemas)) + for _, s := range schemas { + byName[s.Name] = s + } + aliasByName := make(map[string]Alias, len(aliases)) + for _, a := range aliases { + aliasByName[a.Name] = a + } + + gen := &schemaGen{byName: byName, aliasByName: aliasByName} + + out := make(map[string]any, len(schemas)) + for _, s := range schemas { + out[s.Name] = gen.objectSchema(s) + } + + payload, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + if _, err := fmt.Fprintln(w, examplesHeader); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "export const SCHEMAS: Record = %s;\n", payload); err != nil { + return err + } + return nil +} + +type schemaGen struct { + byName map[string]Schema + aliasByName map[string]Alias +} + +func (g *schemaGen) objectSchema(s Schema) map[string]any { + props := make(map[string]any, len(s.Fields)) + var required []string + for _, f := range s.Fields { + props[f.JSONName] = g.fieldSchema(f) + if !f.Optional { + required = append(required, f.JSONName) + } + } + obj := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + sort.Strings(required) + obj["required"] = required + } + if s.Doc != "" { + obj["description"] = s.Doc + } + return obj +} + +func (g *schemaGen) fieldSchema(f Field) map[string]any { + sch := g.typeSchema(f.Type) + if ref, ok := sch["$ref"]; ok { + if f.Doc == "" && f.Example == "" { + return sch + } + wrap := map[string]any{"allOf": []any{map[string]any{"$ref": ref}}} + if f.Doc != "" { + wrap["description"] = f.Doc + } + if f.Example != "" { + wrap["example"] = coerceExample(f.Example, baseKind(f.Type)) + } + return wrap + } + applyConstraints(sch, f.Type, f.Validate) + if f.Doc != "" { + sch["description"] = f.Doc + } + if f.Example != "" { + sch["example"] = coerceExample(f.Example, baseKind(f.Type)) + } + return sch +} + +func (g *schemaGen) typeSchema(t TypeRef) map[string]any { + switch t.Kind { + case KindString: + if t.Name == "datetime" { + return map[string]any{"type": "string", "format": "date-time"} + } + return map[string]any{"type": "string"} + case KindInt: + return map[string]any{"type": "integer"} + case KindNumber: + return map[string]any{"type": "number"} + case KindBool: + return map[string]any{"type": "boolean"} + case KindArray: + return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)} + case KindMap: + return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)} + case KindAny, KindUnknown, KindRaw: + return map[string]any{} + case KindRef: + if t.Name == "nullable" { + inner := g.typeSchema(*t.Inner) + if ref, ok := inner["$ref"]; ok { + return map[string]any{"nullable": true, "allOf": []any{map[string]any{"$ref": ref}}} + } + inner["nullable"] = true + return inner + } + if alias, ok := g.aliasByName[t.Name]; ok { + return g.typeSchema(alias.Underlying) + } + if _, ok := g.byName[t.Name]; ok { + return map[string]any{"$ref": "#/components/schemas/" + t.Name} + } + return map[string]any{} + } + return map[string]any{} +} + +func applyConstraints(sch map[string]any, t TypeRef, rules []ValidateRule) { + base := baseKind(t) + numeric := base.Kind == KindInt || base.Kind == KindNumber + str := base.Kind == KindString + for _, r := range rules { + switch r.Name { + case "gte": + if numeric { + sch["minimum"] = coerceExample(r.Param, base) + } + case "lte": + if numeric { + sch["maximum"] = coerceExample(r.Param, base) + } + case "gt": + if numeric { + sch["minimum"] = coerceExample(r.Param, base) + sch["exclusiveMinimum"] = true + } + case "lt": + if numeric { + sch["maximum"] = coerceExample(r.Param, base) + sch["exclusiveMaximum"] = true + } + case "min": + if numeric { + sch["minimum"] = coerceExample(r.Param, base) + } else if str { + if n, err := strconv.Atoi(r.Param); err == nil { + sch["minLength"] = n + } + } + case "max": + if numeric { + sch["maximum"] = coerceExample(r.Param, base) + } else if str { + if n, err := strconv.Atoi(r.Param); err == nil { + sch["maxLength"] = n + } + } + case "oneof": + vals := strings.Fields(r.Param) + if len(vals) > 0 { + enum := make([]any, len(vals)) + for i, v := range vals { + enum[i] = v + } + sch["enum"] = enum + } + case "email": + if str { + sch["format"] = "email" + } + case "url": + if str { + sch["format"] = "uri" + } + } + } +} diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index 9de9526c..85be28c3 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -106,6 +106,10 @@ func run(root, outDir string) error { if err := emitExamples(examplesBuf, schemas, aliases); err != nil { return err } + schemasBuf := &bytes.Buffer{} + if err := emitJSONSchema(schemasBuf, schemas, aliases); err != nil { + return err + } if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil { return err @@ -116,6 +120,9 @@ func run(root, outDir string) error { if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil { return err } + if err := os.WriteFile(filepath.Join(target, "schemas.ts"), schemasBuf.Bytes(), 0o644); err != nil { + return err + } fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target) return nil