build-openapi.mjs 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. #!/usr/bin/env node
  2. import { writeFileSync } from 'node:fs';
  3. import { join, dirname } from 'node:path';
  4. import { fileURLToPath, pathToFileURL } from 'node:url';
  5. import { sections } from '../src/pages/api-docs/endpoints.ts';
  6. import { EXAMPLES } from '../src/generated/examples.ts';
  7. import { SCHEMAS } from '../src/generated/schemas.ts';
  8. const __dirname = dirname(fileURLToPath(import.meta.url));
  9. const outPath = join(__dirname, '..', 'public', 'openapi.json');
  10. const PANEL_VERSION = process.env.X_UI_VERSION || '3.x';
  11. const SECURITY_SCHEMES = {
  12. bearerAuth: {
  13. type: 'http',
  14. scheme: 'bearer',
  15. description:
  16. 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
  17. },
  18. cookieAuth: {
  19. type: 'apiKey',
  20. in: 'cookie',
  21. name: '3x-ui',
  22. description: 'Session cookie set by POST /login. Browser-only.',
  23. },
  24. };
  25. function ginPathToOpenApi(path) {
  26. return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
  27. }
  28. function extractPathParams(openApiPath) {
  29. const params = [];
  30. const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
  31. let m;
  32. while ((m = re.exec(openApiPath)) !== null) params.push(m[1]);
  33. return params;
  34. }
  35. function mapType(t) {
  36. const v = String(t || '').toLowerCase();
  37. if (v.endsWith('[]')) return 'array';
  38. if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
  39. if (v === 'float' || v === 'double') return 'number';
  40. if (v === 'boolean' || v === 'bool') return 'boolean';
  41. if (v === 'array') return 'array';
  42. if (v === 'object') return 'object';
  43. return 'string';
  44. }
  45. function schemaFromType(t) {
  46. const v = String(t || '').toLowerCase();
  47. if (v.endsWith('[]')) {
  48. const itemType = v.slice(0, -2);
  49. return { type: 'array', items: { type: mapType(itemType) } };
  50. }
  51. if (v === 'file') return { type: 'string', format: 'binary' };
  52. return { type: mapType(v) };
  53. }
  54. function schemaFromParam(p) {
  55. const schema = schemaFromType(p.type);
  56. if (p.defaultValue !== undefined) schema.default = p.defaultValue;
  57. if (p.minLength !== undefined) schema.minLength = p.minLength;
  58. if (p.pattern !== undefined) schema.pattern = p.pattern;
  59. return schema;
  60. }
  61. function requestBodyContentType(ep, bodyParams) {
  62. const locations = new Set(bodyParams.map((p) => p.in));
  63. if (locations.size > 1) {
  64. throw new Error(
  65. `${ep.method} ${ep.path}: request body mixes parameter locations: ${[...locations].join(', ')}`,
  66. );
  67. }
  68. switch (bodyParams[0]?.in) {
  69. case 'body (form)':
  70. return 'application/x-www-form-urlencoded';
  71. case 'body (multipart)':
  72. return 'multipart/form-data';
  73. default:
  74. return 'application/json';
  75. }
  76. }
  77. function tryParseJson(raw) {
  78. if (typeof raw !== 'string') return undefined;
  79. try {
  80. return JSON.parse(raw);
  81. } catch {
  82. return undefined;
  83. }
  84. }
  85. function paramToOpenApi(p) {
  86. const out = {
  87. name: p.name,
  88. in: p.in,
  89. required: p.in === 'path' ? true : !p.optional,
  90. description: p.desc || '',
  91. schema: schemaFromParam(p),
  92. };
  93. return out;
  94. }
  95. function buildOperation(ep, tag) {
  96. const op = {
  97. tags: [tag],
  98. summary: ep.summary || '',
  99. operationId: `${ep.method.toLowerCase()}_${ep.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
  100. };
  101. if (ep.description) op.description = ep.description;
  102. if (ep.deprecated) op.deprecated = true;
  103. const params = [];
  104. const bodyParams = [];
  105. for (const p of ep.params || []) {
  106. if (p.in.startsWith('body')) {
  107. bodyParams.push(p);
  108. } else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
  109. params.push(paramToOpenApi(p));
  110. }
  111. }
  112. const openApiPath = ginPathToOpenApi(ep.path);
  113. const declared = new Set(params.filter((x) => x.in === 'path').map((x) => x.name));
  114. for (const name of extractPathParams(openApiPath)) {
  115. if (declared.has(name)) continue;
  116. params.push({
  117. name,
  118. in: 'path',
  119. required: true,
  120. description: '',
  121. schema: { type: 'string' },
  122. });
  123. }
  124. if (params.length > 0) op.parameters = params;
  125. if (ep.body || bodyParams.length > 0 || ep.requestSchema) {
  126. const contentType = requestBodyContentType(ep, bodyParams);
  127. const example = contentType === 'application/json' ? tryParseJson(ep.body) : undefined;
  128. const properties = {};
  129. const required = [];
  130. for (const bp of bodyParams) {
  131. properties[bp.name] = {
  132. ...schemaFromParam(bp),
  133. description: bp.desc || '',
  134. };
  135. if (!bp.optional) required.push(bp.name);
  136. }
  137. let schema;
  138. if (ep.requestSchema) {
  139. if (bodyParams.length > 0 || ep.bodyRequiredOneOf?.length) {
  140. throw new Error(
  141. `${ep.method} ${ep.path}: requestSchema cannot be combined with body parameters or bodyRequiredOneOf`,
  142. );
  143. }
  144. schema = ep.requestSchema;
  145. } else {
  146. schema =
  147. bodyParams.length > 0
  148. ? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
  149. : { type: 'object' };
  150. if (ep.bodyRequiredOneOf?.length) {
  151. schema = {
  152. anyOf: ep.bodyRequiredOneOf.map((name) => {
  153. if (!properties[name]) {
  154. throw new Error(
  155. `${ep.method} ${ep.path}: bodyRequiredOneOf "${name}" is not a declared body parameter`,
  156. );
  157. }
  158. const branchProperties = { ...properties };
  159. for (const other of ep.bodyRequiredOneOf) {
  160. if (other === name || !branchProperties[other]) continue;
  161. const { pattern: _pattern, minLength: _minLength, ...rest } =
  162. branchProperties[other];
  163. branchProperties[other] = rest;
  164. }
  165. return {
  166. type: 'object',
  167. properties: branchProperties,
  168. required: [...required, name],
  169. };
  170. }),
  171. };
  172. }
  173. }
  174. const encoding = {};
  175. if (contentType === 'application/x-www-form-urlencoded') {
  176. for (const bp of bodyParams) {
  177. const kind = schemaFromType(bp.type).type;
  178. if (kind === 'array') {
  179. encoding[bp.name] = { style: 'form', explode: true };
  180. } else if (kind === 'object') {
  181. // The panel reads such a field with json.Unmarshal, so it must be sent
  182. // as JSON text rather than form-style key/value pairs.
  183. encoding[bp.name] = { contentType: 'application/json' };
  184. }
  185. }
  186. }
  187. op.requestBody = {
  188. required:
  189. Boolean(ep.requestSchema) ||
  190. Boolean(ep.bodyRequiredOneOf?.length) ||
  191. required.length > 0 ||
  192. bodyParams.length === 0,
  193. content: {
  194. [contentType]: {
  195. schema,
  196. ...(Object.keys(encoding).length > 0 ? { encoding } : {}),
  197. ...(example !== undefined ? { example } : {}),
  198. },
  199. },
  200. };
  201. }
  202. const responses = {};
  203. let successExample = tryParseJson(ep.response);
  204. let objSchema = {};
  205. if (ep.responseSchema) {
  206. const obj = EXAMPLES[ep.responseSchema];
  207. if (obj === undefined) {
  208. throw new Error(
  209. `${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`,
  210. );
  211. }
  212. if (SCHEMAS[ep.responseSchema] === undefined) {
  213. throw new Error(
  214. `${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`,
  215. );
  216. }
  217. const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
  218. objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
  219. if (successExample === undefined) {
  220. successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
  221. }
  222. }
  223. responses['200'] = {
  224. description: 'Successful response',
  225. content: {
  226. 'application/json': {
  227. schema: {
  228. type: 'object',
  229. properties: {
  230. success: { type: 'boolean' },
  231. msg: { type: 'string' },
  232. obj: objSchema,
  233. },
  234. },
  235. ...(successExample !== undefined ? { example: successExample } : {}),
  236. },
  237. },
  238. };
  239. const errExample = tryParseJson(ep.errorResponse);
  240. if (errExample !== undefined || ep.errorStatus) {
  241. const code = String(ep.errorStatus || 400);
  242. responses[code] = {
  243. description: 'Error response',
  244. content: {
  245. 'application/json': {
  246. schema: {
  247. type: 'object',
  248. properties: {
  249. success: { type: 'boolean' },
  250. msg: { type: 'string' },
  251. },
  252. },
  253. ...(errExample !== undefined ? { example: errExample } : {}),
  254. },
  255. },
  256. };
  257. }
  258. op.responses = responses;
  259. return op;
  260. }
  261. export function buildSpec() {
  262. const paths = {};
  263. for (const section of sections) {
  264. const tag = section.title;
  265. for (const ep of section.endpoints) {
  266. const openApiPath = ginPathToOpenApi(ep.path);
  267. if (!paths[openApiPath]) paths[openApiPath] = {};
  268. paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
  269. }
  270. }
  271. const tags = sections.map((s) => ({
  272. name: s.title,
  273. description: s.description || '',
  274. }));
  275. return {
  276. openapi: '3.0.3',
  277. info: {
  278. title: '3X-UI Panel API',
  279. version: PANEL_VERSION,
  280. description:
  281. '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.',
  282. },
  283. servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
  284. components: {
  285. securitySchemes: SECURITY_SCHEMES,
  286. schemas: SCHEMAS,
  287. },
  288. security: [{ bearerAuth: [] }, { cookieAuth: [] }],
  289. tags,
  290. paths,
  291. };
  292. }
  293. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  294. const spec = buildSpec();
  295. writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
  296. const pathCount = Object.keys(spec.paths).length;
  297. let opCount = 0;
  298. for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
  299. console.log(`[openapi] wrote ${outPath}`);
  300. console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
  301. }