Browse Source

fix(api-docs): generate request bodies for all encodings (#6296)

* fix(api-docs): generate request bodies for all encodings

The OpenAPI generator only recognized generic body parameters, so JSON, form, and multipart declarations disappeared into empty application/json objects. Generate the declared media type and schema, preserve optionality and conditional requirements, and encode repeated form arrays the way Gin expects. Correct the request metadata exposed by the complete schemas and keep the panel and docs specifications synchronized.

* fix(api-docs): align alternative request schemas

Keep non-empty constraints on the selected request-body alternative without rejecting empty values for the alternatives that panel requests also include. Allow null client IP lists because model serialization emits them while cleared rows await pruning.

* fix(api-docs): send object urlencoded fields as JSON, document the inbound update body

Four defects the request-body rework exposed or left behind:

- An object-typed field in an x-www-form-urlencoded body got no encoding
  entry, so OpenAPI 3.0 serialized it form-style. Swagger "Try it out"
  and generated clients sent memberWeights=3&memberWeights=0.2 to
  /panel/api/sub-balancers, and parseSubBalancerForm json.Unmarshals the
  raw field, so every such call failed with "invalid memberWeights".
  Emit encoding.<name>.contentType = application/json instead.
- bodyRequiredOneOf names were never checked against the declared body
  params: a typo emitted an anyOf branch requiring a property that does
  not exist — unsatisfiable — and make gen still passed. Throw now, and
  extend the requestSchema guard to reject bodyRequiredOneOf as well.
- /panel/api/inbounds/update/:id advertised no request body although its
  own summary says the shape mirrors /add and updateInbound binds one.
  Both entries now share an inboundBody const so they cannot drift.
- The mixed-locations error was the only buildOperation throw without
  the method and path, aborting make gen without naming the offender.

Regenerated frontend/public/openapi.json and copied it to
docs/public/openapi.json. No MDX regeneration: no summary changed.

---------

Co-authored-by: Sanaei <[email protected]>
ilyusha 4 hours ago
parent
commit
0ff3c23948

+ 6 - 6
docs/content/docs/en/reference/api/server.mdx

@@ -123,9 +123,9 @@ _openapi:
         dev release. Only effective on dev builds.
       url: '#toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds'
     - depth: 2
-      title: Refresh the default GeoIP / GeoSite data files. Body can include a
-        fileName, or use the /:fileName variant.
-      url: '#refresh-the-default-geoip--geosite-data-files-body-can-include-a-filename-or-use-the-filename-variant'
+      title: Refresh the default GeoIP / GeoSite data files. Use the /:fileName
+        variant to update one file.
+      url: '#refresh-the-default-geoip--geosite-data-files-use-the-filename-variant-to-update-one-file'
     - depth: 2
       title: Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).
       url: '#refresh-a-single-geo-file-by-filename-eg-geoipdat-geositedat'
@@ -271,9 +271,9 @@ _openapi:
       - content: Toggle the panel update channel between stable and the rolling
           per-commit dev release. Only effective on dev builds.
         id: toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds
-      - content: Refresh the default GeoIP / GeoSite data files. Body can include a
-          fileName, or use the /:fileName variant.
-        id: refresh-the-default-geoip--geosite-data-files-body-can-include-a-filename-or-use-the-filename-variant
+      - content: Refresh the default GeoIP / GeoSite data files. Use the /:fileName
+          variant to update one file.
+        id: refresh-the-default-geoip--geosite-data-files-use-the-filename-variant-to-update-one-file
       - content: Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).
         id: refresh-a-single-geo-file-by-filename-eg-geoipdat-geositedat
       - content: Return the last N lines of the panel’s own log.

+ 6 - 6
docs/content/docs/en/reference/api/subscription-balancers.mdx

@@ -18,9 +18,9 @@ _openapi:
       url: '#create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound'
     - depth: 2
       title: Update a balancer by id. Accepts the same form fields as create (full-row
-        update, including the enabled toggle); omitting memberWeights clears
-        stored weights.
-      url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle-omitting-memberweights-clears-stored-weights'
+        update); omitting memberWeights clears stored weights, while omitting
+        enabled keeps its current value.
+      url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-omitting-memberweights-clears-stored-weights-while-omitting-enabled-keeps-its-current-value'
     - depth: 2
       title: Delete a balancer by id.
       url: '#delete-a-balancer-by-id'
@@ -36,9 +36,9 @@ _openapi:
           every client that sits on at least one selected inbound.
         id: create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound
       - content: Update a balancer by id. Accepts the same form fields as create
-          (full-row update, including the enabled toggle); omitting
-          memberWeights clears stored weights.
-        id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle-omitting-memberweights-clears-stored-weights
+          (full-row update); omitting memberWeights clears stored weights, while
+          omitting enabled keeps its current value.
+        id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-omitting-memberweights-clears-stored-weights-while-omitting-enabled-keeps-its-current-value
       - content: Delete a balancer by id.
         id: delete-a-balancer-by-id
       - content: Delete a balancer by id (POST alias of DELETE for clients that cannot

File diff suppressed because it is too large
+ 681 - 64
docs/public/openapi.json


File diff suppressed because it is too large
+ 681 - 64
frontend/public/openapi.json


+ 109 - 27
frontend/scripts/build-openapi.mjs

@@ -16,7 +16,8 @@ const SECURITY_SCHEMES = {
   bearerAuth: {
     type: 'http',
     scheme: 'bearer',
-    description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
+    description:
+      'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
   },
   cookieAuth: {
     type: 'apiKey',
@@ -55,9 +56,35 @@ function schemaFromType(t) {
     const itemType = v.slice(0, -2);
     return { type: 'array', items: { type: mapType(itemType) } };
   }
+  if (v === 'file') return { type: 'string', format: 'binary' };
   return { type: mapType(v) };
 }
 
+function schemaFromParam(p) {
+  const schema = schemaFromType(p.type);
+  if (p.defaultValue !== undefined) schema.default = p.defaultValue;
+  if (p.minLength !== undefined) schema.minLength = p.minLength;
+  if (p.pattern !== undefined) schema.pattern = p.pattern;
+  return schema;
+}
+
+function requestBodyContentType(ep, bodyParams) {
+  const locations = new Set(bodyParams.map((p) => p.in));
+  if (locations.size > 1) {
+    throw new Error(
+      `${ep.method} ${ep.path}: request body mixes parameter locations: ${[...locations].join(', ')}`,
+    );
+  }
+  switch (bodyParams[0]?.in) {
+    case 'body (form)':
+      return 'application/x-www-form-urlencoded';
+    case 'body (multipart)':
+      return 'multipart/form-data';
+    default:
+      return 'application/json';
+  }
+}
+
 function tryParseJson(raw) {
   if (typeof raw !== 'string') return undefined;
   try {
@@ -73,9 +100,8 @@ function paramToOpenApi(p) {
     in: p.in,
     required: p.in === 'path' ? true : !p.optional,
     description: p.desc || '',
-    schema: schemaFromType(p.type),
+    schema: schemaFromParam(p),
   };
-  if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
   return out;
 }
 
@@ -91,7 +117,7 @@ function buildOperation(ep, tag) {
   const params = [];
   const bodyParams = [];
   for (const p of ep.params || []) {
-    if (p.in === 'body') {
+    if (p.in.startsWith('body')) {
       bodyParams.push(p);
     } else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
       params.push(paramToOpenApi(p));
@@ -113,26 +139,80 @@ function buildOperation(ep, tag) {
 
   if (params.length > 0) op.parameters = params;
 
-  if (ep.body || bodyParams.length > 0) {
-    const example = tryParseJson(ep.body);
+  if (ep.body || bodyParams.length > 0 || ep.requestSchema) {
+    const contentType = requestBodyContentType(ep, bodyParams);
+    const example = contentType === 'application/json' ? tryParseJson(ep.body) : undefined;
     const properties = {};
     const required = [];
     for (const bp of bodyParams) {
       properties[bp.name] = {
-        ...schemaFromType(bp.type),
+        ...schemaFromParam(bp),
         description: bp.desc || '',
       };
       if (!bp.optional) required.push(bp.name);
     }
-    const schema = bodyParams.length > 0
-      ? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
-      : { type: 'object' };
+    let schema;
+    if (ep.requestSchema) {
+      if (bodyParams.length > 0 || ep.bodyRequiredOneOf?.length) {
+        throw new Error(
+          `${ep.method} ${ep.path}: requestSchema cannot be combined with body parameters or bodyRequiredOneOf`,
+        );
+      }
+      schema = ep.requestSchema;
+    } else {
+      schema =
+        bodyParams.length > 0
+          ? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
+          : { type: 'object' };
+      if (ep.bodyRequiredOneOf?.length) {
+        schema = {
+          anyOf: ep.bodyRequiredOneOf.map((name) => {
+            if (!properties[name]) {
+              throw new Error(
+                `${ep.method} ${ep.path}: bodyRequiredOneOf "${name}" is not a declared body parameter`,
+              );
+            }
+            const branchProperties = { ...properties };
+            for (const other of ep.bodyRequiredOneOf) {
+              if (other === name || !branchProperties[other]) continue;
+              const { pattern: _pattern, minLength: _minLength, ...rest } =
+                branchProperties[other];
+              branchProperties[other] = rest;
+            }
+            return {
+              type: 'object',
+              properties: branchProperties,
+              required: [...required, name],
+            };
+          }),
+        };
+      }
+    }
+
+    const encoding = {};
+    if (contentType === 'application/x-www-form-urlencoded') {
+      for (const bp of bodyParams) {
+        const kind = schemaFromType(bp.type).type;
+        if (kind === 'array') {
+          encoding[bp.name] = { style: 'form', explode: true };
+        } else if (kind === 'object') {
+          // The panel reads such a field with json.Unmarshal, so it must be sent
+          // as JSON text rather than form-style key/value pairs.
+          encoding[bp.name] = { contentType: 'application/json' };
+        }
+      }
+    }
 
     op.requestBody = {
-      required: required.length > 0 || bodyParams.length === 0,
+      required:
+        Boolean(ep.requestSchema) ||
+        Boolean(ep.bodyRequiredOneOf?.length) ||
+        required.length > 0 ||
+        bodyParams.length === 0,
       content: {
-        'application/json': {
+        [contentType]: {
           schema,
+          ...(Object.keys(encoding).length > 0 ? { encoding } : {}),
           ...(example !== undefined ? { example } : {}),
         },
       },
@@ -145,10 +225,14 @@ function buildOperation(ep, tag) {
   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`);
+      throw new Error(
+        `${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`,
+      );
     }
     if (SCHEMAS[ep.responseSchema] === undefined) {
-      throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
+      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;
@@ -197,7 +281,7 @@ function buildOperation(ep, tag) {
   return op;
 }
 
-function buildSpec() {
+export function buildSpec() {
   const paths = {};
   for (const section of sections) {
     const tag = section.title;
@@ -221,9 +305,7 @@ function buildSpec() {
       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 — an API token is a full-admin credential, so treat it like the panel password.',
     },
-    servers: [
-      { url: '/', description: 'Current panel (basePath aware)' },
-    ],
+    servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
     components: {
       securitySchemes: SECURITY_SCHEMES,
       schemas: SCHEMAS,
@@ -234,13 +316,13 @@ function buildSpec() {
   };
 }
 
-const spec = buildSpec();
-writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
-
-const pathCount = Object.keys(spec.paths).length;
-let opCount = 0;
-for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
-console.log(`[openapi] wrote ${outPath}`);
-console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+  const spec = buildSpec();
+  writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
 
-void pathToFileURL;
+  const pathCount = Object.keys(spec.paths).length;
+  let opCount = 0;
+  for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
+  console.log(`[openapi] wrote ${outPath}`);
+  console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
+}

+ 271 - 107
frontend/src/pages/api-docs/endpoints.ts

@@ -11,6 +11,7 @@ export type ParamType =
   | 'string'
   | 'integer'
   | 'integer[]'
+  | 'string[]'
   | 'number'
   | 'boolean'
   | 'object'
@@ -25,6 +26,8 @@ export interface EndpointParam {
   desc?: string;
   optional?: boolean;
   defaultValue?: string | number | boolean;
+  minLength?: number;
+  pattern?: string;
 }
 
 export interface Endpoint {
@@ -38,6 +41,8 @@ export interface Endpoint {
   response?: string;
   errorResponse?: string;
   errorStatus?: number;
+  requestSchema?: Record<string, unknown>;
+  bodyRequiredOneOf?: string[];
   responseSchema?: string;
   responseSchemaArray?: boolean;
 }
@@ -55,6 +60,118 @@ export interface Section {
   endpoints: Endpoint[];
 }
 
+// /inbounds/update replaces the whole row, so it takes the same payload as /add.
+const inboundBody =
+  '{\n  "enable": true,\n  "remark": "VLESS-443",\n  "listen": "",\n  "port": 443,\n  "protocol": "vless",\n  "expiryTime": 0,\n  "total": 0,\n  "settings": {\n    "clients": [{ "id": "...", "email": "user1" }],\n    "decryption": "none",\n    "fallbacks": []\n  },\n  "streamSettings": {\n    "network": "tcp",\n    "security": "reality",\n    "realitySettings": { "show": false, "dest": "..." }\n  },\n  "sniffing": {\n    "enabled": true,\n    "destOverride": ["http", "tls"]\n  }\n}';
+
+const outboundSubscriptionBodyParams: EndpointParam[] = [
+  {
+    name: 'remark',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Optional display label.',
+    optional: true,
+  },
+  {
+    name: 'url',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.',
+  },
+  {
+    name: 'tagPrefix',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Prefix for generated outbound tags. Defaults to the lowest free "sub<N>-" prefix.',
+    optional: true,
+  },
+  {
+    name: 'updateInterval',
+    in: 'body (form)',
+    type: 'integer',
+    desc: 'Seconds between auto-refreshes. Default 600.',
+    optional: true,
+    defaultValue: 600,
+  },
+  {
+    name: 'enabled',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: 'Whether the subscription is active. Default true.',
+    optional: true,
+    defaultValue: true,
+  },
+  {
+    name: 'allowPrivate',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: 'Allow the URL to point at a private/internal/loopback address. Default false.',
+    optional: true,
+    defaultValue: false,
+  },
+  {
+    name: 'allowInsecure',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: "Skip TLS certificate verification when fetching the subscription's URL. Default false.",
+    optional: true,
+    defaultValue: false,
+  },
+  {
+    name: 'prepend',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: "Place this subscription's outbounds before the manual template outbounds. Default false.",
+    optional: true,
+    defaultValue: false,
+  },
+];
+
+const subBalancerBodyParams: EndpointParam[] = [
+  {
+    name: 'remark',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Display label, used as the config remarks (required).',
+  },
+  {
+    name: 'strategy',
+    in: 'body (form)',
+    type: 'string',
+    desc: 'Balancer strategy: "leastLoad", "leastPing", "roundRobin" or "random". Default "random".',
+    optional: true,
+    defaultValue: 'random',
+  },
+  {
+    name: 'inboundIds',
+    in: 'body (form)',
+    type: 'integer[]',
+    desc: 'Repeated form keys selecting the member inbounds (required, at least one).',
+  },
+  {
+    name: 'memberWeights',
+    in: 'body (form)',
+    type: 'object',
+    desc: 'leastLoad only: JSON object mapping inbound id to a static weight > 0, e.g. {"3":0.2}. Lower weight = picked more often; absent ids weigh 1. Rejected for other strategies; entries for unselected inbounds are dropped.',
+    optional: true,
+  },
+  {
+    name: 'sortOrder',
+    in: 'body (form)',
+    type: 'integer',
+    desc: '1-based position in the subscription list. Default 1.',
+    optional: true,
+    defaultValue: 1,
+  },
+  {
+    name: 'enabled',
+    in: 'body (form)',
+    type: 'boolean',
+    desc: 'Whether the balancer is emitted. Default true on create; unchanged when omitted on update.',
+    optional: true,
+  },
+];
+
 export const sections: readonly Section[] = [
   {
     id: 'authentication',
@@ -75,6 +192,7 @@ export const sections: readonly Section[] = [
             in: 'body',
             type: 'string',
             desc: 'OTP code when 2FA is enabled. Omit otherwise.',
+            optional: true,
           },
         ],
         body: '{\n  "username": "admin",\n  "password": "admin",\n  "twoFactorCode": "123456"\n}',
@@ -153,7 +271,7 @@ export const sections: readonly Section[] = [
         path: '/panel/api/inbounds/add',
         summary:
           'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
-        body: '{\n  "enable": true,\n  "remark": "VLESS-443",\n  "listen": "",\n  "port": 443,\n  "protocol": "vless",\n  "expiryTime": 0,\n  "total": 0,\n  "settings": {\n    "clients": [{ "id": "...", "email": "user1" }],\n    "decryption": "none",\n    "fallbacks": []\n  },\n  "streamSettings": {\n    "network": "tcp",\n    "security": "reality",\n    "realitySettings": { "show": false, "dest": "..." }\n  },\n  "sniffing": {\n    "enabled": true,\n    "destOverride": ["http", "tls"]\n  }\n}',
+        body: inboundBody,
         errorResponse: '{\n  "success": false,\n  "msg": "Port 443 is already in use"\n}',
       },
       {
@@ -177,6 +295,7 @@ export const sections: readonly Section[] = [
         summary:
           'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
         params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
+        body: inboundBody,
       },
       {
         method: 'POST',
@@ -517,6 +636,15 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/server/updatePanel',
         summary: 'Self-update the panel to the latest version. The server restarts on success.',
+        params: [
+          {
+            name: 'dev',
+            in: 'body (form)',
+            type: 'boolean',
+            desc: "Override this run's channel. Omit to use the panel's configured channel.",
+            optional: true,
+          },
+        ],
         response: '{\n  "success": true,\n  "obj": {\n    "runId": "1735689600123456789"\n  }\n}',
       },
       {
@@ -538,16 +666,7 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/server/updateGeofile',
         summary:
-          'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
-        params: [
-          {
-            name: 'fileName',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.',
-          },
-        ],
-        body: 'fileName=geoip.dat',
+          'Refresh the default GeoIP / GeoSite data files. Use the /:fileName variant to update one file.',
       },
       {
         method: 'POST',
@@ -568,8 +687,22 @@ export const sections: readonly Section[] = [
         summary: 'Return the last N lines of the panel\u2019s own log.',
         params: [
           { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
+          {
+            name: 'level',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Minimum log level filter.',
+            optional: true,
+          },
+          {
+            name: 'syslog',
+            in: 'body (form)',
+            type: 'boolean',
+            desc: 'Read system logs instead of the panel log.',
+            optional: true,
+          },
         ],
-        body: '{\n  "level": "info",\n  "syslog": false\n}',
+        body: 'level=info&syslog=false',
         response:
           '{\n  "success": true,\n  "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\n}',
       },
@@ -584,24 +717,28 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Keyword filter — only lines containing this string.',
+            optional: true,
           },
           {
             name: 'showDirect',
             in: 'body (form)',
             type: 'string',
             desc: '"true" to include direct (freedom) traffic lines.',
+            optional: true,
           },
           {
             name: 'showBlocked',
             in: 'body (form)',
             type: 'string',
             desc: '"true" to include blocked (blackhole) traffic lines.',
+            optional: true,
           },
           {
             name: 'showProxy',
             in: 'body (form)',
             type: 'string',
             desc: '"true" to include proxy traffic lines.',
+            optional: true,
           },
         ],
         body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
@@ -625,6 +762,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Keyword filter — only rows/lines containing this string.',
+            optional: true,
           },
         ],
         body: 'filter=awg1',
@@ -642,6 +780,14 @@ export const sections: readonly Section[] = [
             type: 'file',
             desc: 'Database backup or migration file to upload.',
           },
+          {
+            name: 'keepHostSettings',
+            in: 'body (multipart)',
+            type: 'boolean',
+            desc: "Keep this machine's addresses, certificates and node identity. Default true.",
+            optional: true,
+            defaultValue: true,
+          },
         ],
       },
       {
@@ -666,18 +812,23 @@ export const sections: readonly Section[] = [
         path: '/panel/api/server/getCertHash',
         summary:
           'Compute the hex SHA-256 of a certificate (DER) for pinning (pinnedPeerCertSha256). Provide either a server file path or inline PEM/DER content.',
+        bodyRequiredOneOf: ['certFile', 'certContent'],
         params: [
           {
             name: 'certFile',
             in: 'body (form)',
             type: 'string',
             desc: 'Path to a certificate file on the server. Takes precedence over certContent.',
+            optional: true,
+            pattern: '.*\\S.*',
           },
           {
             name: 'certContent',
             in: 'body (form)',
             type: 'string',
             desc: 'Inline PEM (or DER) certificate content, used when certFile is empty.',
+            optional: true,
+            pattern: '.*\\S.*',
           },
         ],
         body: 'certFile=/root/cert.crt',
@@ -767,14 +918,25 @@ export const sections: readonly Section[] = [
         path: '/panel/api/server/clientIps',
         summary:
           'Submit a list of recently active IP timestamps. The panel merges them with the existing database to maintain a unified global IP-limit view.',
-        params: [
-          {
-            name: 'ips',
-            in: 'body (json)',
-            type: 'object[]',
-            desc: 'Array of InboundClientIps to merge.',
-          },
-        ],
+        requestSchema: {
+          type: 'array',
+          items: {
+            type: 'object',
+            properties: {
+              clientEmail: { type: 'string' },
+              ips: {
+                type: 'array',
+                nullable: true,
+                items: {
+                  type: 'object',
+                  properties: { ip: { type: 'string' }, timestamp: { type: 'integer' } },
+                  required: ['ip', 'timestamp'],
+                },
+              },
+            },
+            required: ['clientEmail', 'ips'],
+          },
+        },
       },
     ],
   },
@@ -1077,7 +1239,7 @@ export const sections: readonly Section[] = [
           {
             name: 'emails',
             in: 'body (json)',
-            type: 'array',
+            type: 'string[]',
             desc: 'Emails of existing clients to attach.',
           },
           {
@@ -1100,7 +1262,7 @@ export const sections: readonly Section[] = [
           {
             name: 'emails',
             in: 'body (json)',
-            type: 'array',
+            type: 'string[]',
             desc: 'Emails of existing clients to detach.',
           },
           {
@@ -1666,12 +1828,14 @@ export const sections: readonly Section[] = [
             in: 'body',
             type: 'string',
             desc: 'admin (default), monitor, or node-sync.',
+            optional: true,
           },
           {
             name: 'expiresAt',
             in: 'body',
             type: 'number',
             desc: 'Future Unix milliseconds, or 0 for no expiry.',
+            optional: true,
           },
         ],
         body: '{\n  "name": "central-panel-a",\n  "scope": "node-sync",\n  "expiresAt": 1798761600000\n}',
@@ -1766,6 +1930,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'URL used for outbound reachability tests. Defaults to https://www.google.com/generate_204.',
+            optional: true,
           },
         ],
       },
@@ -1778,25 +1943,35 @@ export const sections: readonly Section[] = [
             name: 'action',
             in: 'path',
             type: 'string',
-            desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).',
+            desc: 'data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.',
           },
           {
             name: 'privateKey',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=reg.',
+            optional: true,
           },
           {
             name: 'publicKey',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=reg.',
+            optional: true,
           },
           {
             name: 'license',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=license.',
+            optional: true,
+          },
+          {
+            name: 'interval',
+            in: 'body (form)',
+            type: 'integer',
+            desc: 'Non-negative hours between automatic rotations. Required when action=interval; 0 disables rotation.',
+            optional: true,
           },
         ],
       },
@@ -1816,9 +1991,22 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=servers.',
+            optional: true,
+          },
+          {
+            name: 'token',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Required when action=reg.',
+            optional: true,
+          },
+          {
+            name: 'key',
+            in: 'body (form)',
+            type: 'string',
+            desc: 'Required when action=setKey.',
+            optional: true,
           },
-          { name: 'token', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
-          { name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
         ],
       },
       {
@@ -1837,24 +2025,28 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=reg.',
+            optional: true,
           },
           {
             name: 'password',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=reg.',
+            optional: true,
           },
           {
             name: 'countryCode',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=servers.',
+            optional: true,
           },
           {
             name: 'hostname',
             in: 'body (form)',
             type: 'string',
             desc: 'Required when action=addKey.',
+            optional: true,
           },
         ],
       },
@@ -1889,12 +2081,14 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.',
+            optional: true,
           },
           {
             name: 'mode',
             in: 'body (form)',
             type: 'string',
             desc: '"tcp" for a fast dial-only probe (parallel-safe), "real" for a real-delay probe whose delay is the full request time including tunnel establishment. Default/empty uses a full HTTP probe reporting the warm per-request round-trip. Both HTTP variants run through a temp xray instance.',
+            optional: true,
           },
         ],
         body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
@@ -1916,12 +2110,14 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.',
+            optional: true,
           },
           {
             name: 'mode',
             in: 'body (form)',
             type: 'string',
             desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP), "real" for real-delay probes whose delay is the full request time including tunnel establishment. Default/empty routes an HTTP request through each outbound and reports the warm per-request round-trip.',
+            optional: true,
           },
         ],
         body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
@@ -1953,6 +2149,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: 'Outbound tag to force. Empty clears the override and returns control to the strategy.',
+            optional: true,
           },
         ],
         body: 'tag=b1&target=proxy',
@@ -1962,38 +2159,58 @@ export const sections: readonly Section[] = [
         path: '/panel/api/xray/routeTest',
         summary:
           'Ask the running core which outbound its router would pick for a synthetic connection (RoutingService.TestRoute). No traffic is sent.',
+        bodyRequiredOneOf: ['domain', 'ip'],
         params: [
           {
             name: 'domain',
             in: 'body (form)',
             type: 'string',
             desc: 'Target domain. Either domain or ip is required.',
+            optional: true,
+            minLength: 1,
           },
           {
             name: 'ip',
             in: 'body (form)',
             type: 'string',
             desc: 'Target IP. Either domain or ip is required.',
+            optional: true,
+            minLength: 1,
+          },
+          {
+            name: 'port',
+            in: 'body (form)',
+            type: 'number',
+            desc: 'Target port (optional).',
+            optional: true,
+          },
+          {
+            name: 'network',
+            in: 'body (form)',
+            type: 'string',
+            desc: '"tcp" (default) or "udp".',
+            optional: true,
           },
-          { name: 'port', in: 'body (form)', type: 'number', desc: 'Target port (optional).' },
-          { name: 'network', in: 'body (form)', type: 'string', desc: '"tcp" (default) or "udp".' },
           {
             name: 'inboundTag',
             in: 'body (form)',
             type: 'string',
             desc: 'Simulate arrival on this inbound (optional).',
+            optional: true,
           },
           {
             name: 'protocol',
             in: 'body (form)',
             type: 'string',
             desc: 'Sniffed protocol such as http, tls, bittorrent (optional).',
+            optional: true,
           },
           {
             name: 'email',
             in: 'body (form)',
             type: 'string',
             desc: 'User attribution for user-based rules (optional).',
+            optional: true,
           },
         ],
         body: 'domain=example.com&port=443&network=tcp',
@@ -2097,6 +2314,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: '"ip" to parse the tokens as IP rules (geoip:, ext-ip:, leading !). Anything else parses them as domain rules (geosite:, ext-site:).',
+            optional: true,
           },
         ],
         body: 'kind=domain&tokens=geosite:google,geosite:blabla',
@@ -2112,52 +2330,17 @@ export const sections: readonly Section[] = [
         path: '/panel/api/xray/outbound-subs',
         summary:
           'Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.',
-        params: [
-          { name: 'remark', in: 'body (form)', type: 'string', desc: 'Optional display label.' },
-          {
-            name: 'url',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.',
-          },
-          {
-            name: 'tagPrefix',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Prefix for generated outbound tags. Defaults to "sub<id>-".',
-          },
-          {
-            name: 'updateInterval',
-            in: 'body (form)',
-            type: 'integer',
-            desc: 'Seconds between auto-refreshes. Default 600.',
-          },
-          {
-            name: 'enabled',
-            in: 'body (form)',
-            type: 'boolean',
-            desc: 'Whether the subscription is active. Default true.',
-          },
-          {
-            name: 'allowPrivate',
-            in: 'body (form)',
-            type: 'boolean',
-            desc: 'Allow the URL to point at a private/internal/loopback address (localhost/LAN). Default false (SSRF guard blocks private targets).',
-          },
-          {
-            name: 'prepend',
-            in: 'body (form)',
-            type: 'boolean',
-            desc: "Place this subscription's outbounds before the manual template outbounds (so one can become the default). Default false.",
-          },
-        ],
+        params: outboundSubscriptionBodyParams,
       },
       {
         method: 'POST',
         path: '/panel/api/xray/outbound-subs/:id',
         summary:
           'Update an existing outbound subscription by id. Accepts the same form fields as create.',
-        params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' }],
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+          ...outboundSubscriptionBodyParams,
+        ],
       },
       {
         method: 'DELETE',
@@ -2191,6 +2374,7 @@ export const sections: readonly Section[] = [
             in: 'body (form)',
             type: 'string',
             desc: '"up" to raise priority, anything else to lower it.',
+            optional: true,
           },
         ],
       },
@@ -2206,6 +2390,20 @@ export const sections: readonly Section[] = [
             type: 'string',
             desc: 'Subscription URL to preview (required).',
           },
+          {
+            name: 'allowPrivate',
+            in: 'body (form)',
+            type: 'boolean',
+            desc: 'Allow a private/internal/loopback URL. Default false.',
+            optional: true,
+          },
+          {
+            name: 'allowInsecure',
+            in: 'body (form)',
+            type: 'boolean',
+            desc: 'Skip TLS certificate verification. Default false.',
+            optional: true,
+          },
         ],
       },
     ],
@@ -2229,52 +2427,18 @@ export const sections: readonly Section[] = [
         path: '/panel/api/sub-balancers',
         summary:
           'Create a subscription balancer. It appears in the JSON subscription of every client that sits on at least one selected inbound.',
-        params: [
-          {
-            name: 'remark',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Display label, used as the config remarks (required).',
-          },
-          {
-            name: 'strategy',
-            in: 'body (form)',
-            type: 'string',
-            desc: 'Balancer strategy: "leastLoad", "leastPing", "roundRobin" or "random" (xray routing balancer strategies). Default "random".',
-          },
-          {
-            name: 'inboundIds',
-            in: 'body (form)',
-            type: 'integer[]',
-            desc: 'Repeated form keys selecting the member inbounds, e.g. inboundIds=1&inboundIds=3 (required, at least one).',
-          },
-          {
-            name: 'memberWeights',
-            in: 'body (form)',
-            type: 'object',
-            desc: 'leastLoad only: JSON object mapping inbound id to a static weight > 0, e.g. {"3":0.2}. Lower weight = picked more often; absent ids weigh 1. Rejected for other strategies; entries for unselected inbounds are dropped.',
-          },
-          {
-            name: 'sortOrder',
-            in: 'body (form)',
-            type: 'integer',
-            desc: '1-based position in the subscription list, interleaved with the inbounds subSortIndex. Default 1.',
-          },
-          {
-            name: 'enabled',
-            in: 'body (form)',
-            type: 'boolean',
-            desc: 'Whether the balancer is emitted. Default true.',
-          },
-        ],
+        params: subBalancerBodyParams,
         responseSchema: 'SubBalancer',
       },
       {
         method: 'POST',
         path: '/panel/api/sub-balancers/:id',
         summary:
-          'Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle); omitting memberWeights clears stored weights.',
-        params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
+          'Update a balancer by id. Accepts the same form fields as create (full-row update); omitting memberWeights clears stored weights, while omitting enabled keeps its current value.',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' },
+          ...subBalancerBodyParams,
+        ],
         responseSchema: 'SubBalancer',
       },
       {

+ 140 - 0
frontend/src/test/openapi-request-bodies.test.ts

@@ -0,0 +1,140 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildSpec } from '../../scripts/build-openapi.mjs';
+
+interface OpenApiSchema {
+  type?: string;
+  format?: string;
+  description?: string;
+  default?: string | number | boolean;
+  minLength?: number;
+  pattern?: string;
+  properties?: Record<string, OpenApiSchema>;
+  required?: string[];
+  items?: OpenApiSchema;
+  anyOf?: OpenApiSchema[];
+}
+
+interface OpenApiRequestBody {
+  required?: boolean;
+  content: Record<
+    string,
+    {
+      schema: OpenApiSchema;
+      encoding?: Record<string, { style?: string; explode?: boolean; contentType?: string }>;
+    }
+  >;
+}
+
+interface OpenApiOperation {
+  requestBody?: OpenApiRequestBody;
+}
+
+const paths = buildSpec().paths as Record<string, Record<string, OpenApiOperation>>;
+
+function requestBody(path: string): OpenApiRequestBody {
+  const body = paths[path]?.post?.requestBody;
+  if (!body) throw new Error(`${path} has no POST request body`);
+  return body;
+}
+
+describe('generated OpenAPI request bodies', () => {
+  it('preserves JSON, form, and multipart parameter declarations', () => {
+    const login = requestBody('/login').content['application/json'];
+    expect(login.schema.properties).toHaveProperty('username');
+    expect(login.schema.required).toEqual(['username', 'password']);
+
+    const json = requestBody('/panel/api/inbounds/pushClientTraffics').content['application/json'];
+    expect(json.schema.properties).toHaveProperty('traffics');
+
+    const form = requestBody('/panel/api/inbounds/import').content[
+      'application/x-www-form-urlencoded'
+    ];
+    expect(form.schema.properties).toHaveProperty('data');
+
+    const logs = requestBody('/panel/api/server/logs/{count}');
+    expect(logs.content).toHaveProperty('application/x-www-form-urlencoded');
+    expect(logs.content['application/x-www-form-urlencoded'].schema.properties).toHaveProperty(
+      'syslog',
+    );
+
+    const outboundTest = requestBody('/panel/api/xray/testOutbound').content[
+      'application/x-www-form-urlencoded'
+    ];
+    expect(outboundTest.schema.required).toEqual(['outbound']);
+
+    const outboundUpdate = requestBody('/panel/api/xray/outbound-subs/{id}').content[
+      'application/x-www-form-urlencoded'
+    ];
+    expect(outboundUpdate.schema.required).toEqual(['url']);
+    expect(outboundUpdate.schema.properties).toHaveProperty('allowInsecure');
+
+    const balancerUpdate = requestBody('/panel/api/sub-balancers/{id}').content[
+      'application/x-www-form-urlencoded'
+    ];
+    expect(balancerUpdate.schema.required).toEqual(['remark', 'inboundIds']);
+    expect(balancerUpdate.encoding?.inboundIds).toEqual({ style: 'form', explode: true });
+    expect(balancerUpdate.encoding?.memberWeights).toEqual({ contentType: 'application/json' });
+
+    const inboundUpdate = requestBody('/panel/api/inbounds/update/{id}').content[
+      'application/json'
+    ];
+    expect(inboundUpdate.schema).toEqual({ type: 'object' });
+
+    const multipart = requestBody('/panel/api/server/importDB').content['multipart/form-data'];
+    expect(multipart.schema.properties?.db).toEqual({
+      type: 'string',
+      format: 'binary',
+      description: 'Database backup or migration file to upload.',
+    });
+    expect(multipart.schema.properties).toHaveProperty('keepHostSettings');
+    expect(multipart.schema.properties?.keepHostSettings?.default).toBe(true);
+
+    const array = requestBody('/panel/api/server/clientIps').content['application/json'];
+    expect(array.schema).toEqual({
+      type: 'array',
+      items: {
+        type: 'object',
+        properties: {
+          clientEmail: { type: 'string' },
+          ips: {
+            type: 'array',
+            nullable: true,
+            items: {
+              type: 'object',
+              properties: { ip: { type: 'string' }, timestamp: { type: 'integer' } },
+              required: ['ip', 'timestamp'],
+            },
+          },
+        },
+        required: ['clientEmail', 'ips'],
+      },
+    });
+
+    const certHash = requestBody('/panel/api/server/getCertHash');
+    expect(certHash.required).toBe(true);
+    const certSchema = certHash.content['application/x-www-form-urlencoded'].schema;
+    expect(certSchema.anyOf?.map((branch) => branch.required)).toEqual([
+      ['certFile'],
+      ['certContent'],
+    ]);
+    expect(certSchema.anyOf?.[0].properties?.certFile.pattern).toBe('.*\\S.*');
+    expect(certSchema.anyOf?.[0].properties?.certContent).not.toHaveProperty('pattern');
+    expect(certSchema.anyOf?.[1].properties?.certFile).not.toHaveProperty('pattern');
+    expect(certSchema.anyOf?.[1].properties?.certContent.pattern).toBe('.*\\S.*');
+
+    const routeSchema = requestBody('/panel/api/xray/routeTest').content[
+      'application/x-www-form-urlencoded'
+    ].schema;
+    expect(routeSchema.anyOf?.map((branch) => branch.required)).toEqual([['domain'], ['ip']]);
+    expect(routeSchema.anyOf?.[0].properties?.domain?.minLength).toBe(1);
+    expect(routeSchema.anyOf?.[0].properties?.ip).not.toHaveProperty('minLength');
+    expect(routeSchema.anyOf?.[1].properties?.domain).not.toHaveProperty('minLength');
+    expect(routeSchema.anyOf?.[1].properties?.ip?.minLength).toBe(1);
+
+    const bulkAttach = requestBody('/panel/api/clients/bulkAttach').content['application/json'];
+    expect(bulkAttach.schema.properties?.emails?.items).toEqual({ type: 'string' });
+
+    expect(paths['/panel/api/server/updateGeofile'].post).not.toHaveProperty('requestBody');
+  });
+});

Some files were not shown because too many files changed in this diff