build-openapi.mjs 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  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.js';
  6. const __dirname = dirname(fileURLToPath(import.meta.url));
  7. const outPath = join(__dirname, '..', 'public', 'openapi.json');
  8. const PANEL_VERSION = process.env.X_UI_VERSION || '3.x';
  9. const SECURITY_SCHEMES = {
  10. bearerAuth: {
  11. type: 'http',
  12. scheme: 'bearer',
  13. description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
  14. },
  15. cookieAuth: {
  16. type: 'apiKey',
  17. in: 'cookie',
  18. name: '3x-ui',
  19. description: 'Session cookie set by POST /login. Browser-only.',
  20. },
  21. };
  22. function ginPathToOpenApi(path) {
  23. return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
  24. }
  25. function extractPathParams(openApiPath) {
  26. const params = [];
  27. const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
  28. let m;
  29. while ((m = re.exec(openApiPath)) !== null) params.push(m[1]);
  30. return params;
  31. }
  32. function mapType(t) {
  33. const v = String(t || '').toLowerCase();
  34. if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
  35. if (v === 'float' || v === 'double') return 'number';
  36. if (v === 'boolean' || v === 'bool') return 'boolean';
  37. if (v === 'array') return 'array';
  38. if (v === 'object') return 'object';
  39. return 'string';
  40. }
  41. function tryParseJson(raw) {
  42. if (typeof raw !== 'string') return undefined;
  43. try {
  44. return JSON.parse(raw);
  45. } catch {
  46. return undefined;
  47. }
  48. }
  49. function paramToOpenApi(p) {
  50. const out = {
  51. name: p.name,
  52. in: p.in,
  53. required: p.in === 'path' ? true : !p.optional,
  54. description: p.desc || '',
  55. schema: { type: mapType(p.type) },
  56. };
  57. if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
  58. return out;
  59. }
  60. function buildOperation(ep, tag) {
  61. const op = {
  62. tags: [tag],
  63. summary: ep.summary || '',
  64. operationId: `${ep.method.toLowerCase()}_${ep.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
  65. };
  66. if (ep.description) op.description = ep.description;
  67. if (ep.deprecated) op.deprecated = true;
  68. const params = [];
  69. const bodyParams = [];
  70. for (const p of ep.params || []) {
  71. if (p.in === 'body') {
  72. bodyParams.push(p);
  73. } else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
  74. params.push(paramToOpenApi(p));
  75. }
  76. }
  77. const openApiPath = ginPathToOpenApi(ep.path);
  78. const declared = new Set(params.filter((x) => x.in === 'path').map((x) => x.name));
  79. for (const name of extractPathParams(openApiPath)) {
  80. if (declared.has(name)) continue;
  81. params.push({
  82. name,
  83. in: 'path',
  84. required: true,
  85. description: '',
  86. schema: { type: 'string' },
  87. });
  88. }
  89. if (params.length > 0) op.parameters = params;
  90. if (ep.body || bodyParams.length > 0) {
  91. const example = tryParseJson(ep.body);
  92. const properties = {};
  93. const required = [];
  94. for (const bp of bodyParams) {
  95. properties[bp.name] = {
  96. type: mapType(bp.type),
  97. description: bp.desc || '',
  98. };
  99. if (!bp.optional) required.push(bp.name);
  100. }
  101. const schema = bodyParams.length > 0
  102. ? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
  103. : { type: 'object' };
  104. op.requestBody = {
  105. required: required.length > 0 || bodyParams.length === 0,
  106. content: {
  107. 'application/json': {
  108. schema,
  109. ...(example !== undefined ? { example } : {}),
  110. },
  111. },
  112. };
  113. }
  114. const responses = {};
  115. const successExample = tryParseJson(ep.response);
  116. responses['200'] = {
  117. description: 'Successful response',
  118. content: {
  119. 'application/json': {
  120. schema: {
  121. type: 'object',
  122. properties: {
  123. success: { type: 'boolean' },
  124. msg: { type: 'string' },
  125. obj: {},
  126. },
  127. },
  128. ...(successExample !== undefined ? { example: successExample } : {}),
  129. },
  130. },
  131. };
  132. const errExample = tryParseJson(ep.errorResponse);
  133. if (errExample !== undefined || ep.errorStatus) {
  134. const code = String(ep.errorStatus || 400);
  135. responses[code] = {
  136. description: 'Error response',
  137. content: {
  138. 'application/json': {
  139. schema: {
  140. type: 'object',
  141. properties: {
  142. success: { type: 'boolean' },
  143. msg: { type: 'string' },
  144. },
  145. },
  146. ...(errExample !== undefined ? { example: errExample } : {}),
  147. },
  148. },
  149. };
  150. }
  151. op.responses = responses;
  152. return op;
  153. }
  154. function buildSpec() {
  155. const paths = {};
  156. for (const section of sections) {
  157. const tag = section.title;
  158. for (const ep of section.endpoints) {
  159. const openApiPath = ginPathToOpenApi(ep.path);
  160. if (!paths[openApiPath]) paths[openApiPath] = {};
  161. paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
  162. }
  163. }
  164. const tags = sections.map((s) => ({
  165. name: s.title,
  166. description: s.description || '',
  167. }));
  168. return {
  169. openapi: '3.0.3',
  170. info: {
  171. title: '3X-UI Panel API',
  172. version: PANEL_VERSION,
  173. description:
  174. '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.',
  175. },
  176. servers: [
  177. { url: '/', description: 'Current panel (basePath aware)' },
  178. ],
  179. components: {
  180. securitySchemes: SECURITY_SCHEMES,
  181. },
  182. security: [{ bearerAuth: [] }, { cookieAuth: [] }],
  183. tags,
  184. paths,
  185. };
  186. }
  187. const spec = buildSpec();
  188. writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
  189. const pathCount = Object.keys(spec.paths).length;
  190. let opCount = 0;
  191. for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
  192. console.log(`[openapi] wrote ${outPath}`);
  193. console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
  194. void pathToFileURL;