build-openapi.mjs 7.1 KB

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