1
0

build-openapi.mjs 12 KB

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