u
This commit is contained in:
@@ -11,7 +11,7 @@ ESM (`type: module`), Express 5, Node. Depends on `libdashstore` via `file:../da
|
|||||||
- Auth: `src/api/middleware/auth.js` (Google SSO + JWT cookie, `req.user.{id,selectedCompanyId}`); allowlist via `scripts/manageUsers.js`.
|
- Auth: `src/api/middleware/auth.js` (Google SSO + JWT cookie, `req.user.{id,selectedCompanyId}`); allowlist via `scripts/manageUsers.js`.
|
||||||
- SSE: `src/api/sse.js` (`emit`), `routes/events.js`. Stock adjust emits `stock`, `ledger`, `articles`, `article-<sku>`.
|
- SSE: `src/api/sse.js` (`emit`), `routes/events.js`. Stock adjust emits `stock`, `ledger`, `articles`, `article-<sku>`.
|
||||||
- `src/v1/` legacy POS compat — don't touch unless task is POS. `quixpos2jtl/` is a separate Electron/CLI bridge — ignore unless task mentions JTL.
|
- `src/v1/` legacy POS compat — don't touch unless task is POS. `quixpos2jtl/` is a separate Electron/CLI bridge — ignore unless task mentions JTL.
|
||||||
- Docs: generated, never hand-edit — see below.
|
- Docs: generated, never hand-edit — see below. Interactive API documentation & tester served under `/docs/` (`/docs/openapi.json`).
|
||||||
|
|
||||||
## Build / Test / Docs
|
## Build / Test / Docs
|
||||||
|
|
||||||
|
|||||||
16242
docs/api/openapi.json
Normal file
16242
docs/api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
20
package-lock.json
generated
20
package-lock.json
generated
@@ -27,7 +27,8 @@
|
|||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"sharp": "^0.35.5"
|
"sharp": "^0.35.5",
|
||||||
|
"swagger-ui-dist": "^5.33.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"swagger-jsdoc": "^6.3.0"
|
"swagger-jsdoc": "^6.3.0"
|
||||||
@@ -61,7 +62,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../llmorbiter": {
|
"../llmorbiter": {
|
||||||
"name": "llmorbiter",
|
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -909,6 +909,13 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@scarf/scarf": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -3911,6 +3918,15 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swagger-ui-dist": {
|
||||||
|
"version": "5.33.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.1.tgz",
|
||||||
|
"integrity": "sha512-H872wWkA53bFIsGgi7OWgmq+CRWw3nFQGdJWRqOB9wNwTm6e5ol34+qPDkV4AJlK+gglM2EsJiOOzsGsEGbluA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@scarf/scarf": "=1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tagged-tag": {
|
"node_modules/tagged-tag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"sharp": "^0.35.5"
|
"sharp": "^0.35.5",
|
||||||
|
"swagger-ui-dist": "^5.33.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"swagger-jsdoc": "^6.3.0"
|
"swagger-jsdoc": "^6.3.0"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import swaggerJsdoc from 'swagger-jsdoc';
|
import { openApiOptions, getOpenApiSpec } from '../src/api/openapiSpec.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const root = path.resolve(__dirname, '..');
|
const root = path.resolve(__dirname, '..');
|
||||||
@@ -44,55 +44,7 @@ function isRouteOmitted(method, apiPath) {
|
|||||||
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
|
return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const spec = getOpenApiSpec(true);
|
||||||
definition: {
|
|
||||||
openapi: '3.0.3',
|
|
||||||
info: {
|
|
||||||
title: 'Dash API',
|
|
||||||
version: '1.0.0',
|
|
||||||
description:
|
|
||||||
'Backend API for Dash — Google SSO auth, MSSQL, session cookies.\n\n' +
|
|
||||||
'Browser clients must send cookies (`credentials: \'include\'`). ' +
|
|
||||||
'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h); HTTPS is required for login.',
|
|
||||||
},
|
|
||||||
servers: [
|
|
||||||
{ url: 'http://localhost:4208/api', description: 'Local default HTTP (PORT / .env)' },
|
|
||||||
{ url: 'https://localhost:8443', description: 'Local default HTTPS (HTTPS_PORT / .env)' },
|
|
||||||
],
|
|
||||||
tags: [
|
|
||||||
{ name: 'Auth', description: 'Google SSO and session' },
|
|
||||||
{ name: 'Health', description: 'Connectivity checks' },
|
|
||||||
{ name: 'POS', description: 'POS bind and sync API (/v1)' },
|
|
||||||
{ name: 'Events', description: 'SSE update signals for live clients' },
|
|
||||||
{ name: 'Article', description: 'Articles under categories' },
|
|
||||||
{ name: 'Category', description: 'Category tree and membership' },
|
|
||||||
{ name: 'Company', description: 'Company CRUD' },
|
|
||||||
{ name: 'CompanySetting', description: 'Company-scoped settings' },
|
|
||||||
{ name: 'Unit', description: 'Company-scoped units of measure' },
|
|
||||||
{ name: 'Brand', description: 'Company-scoped brands' },
|
|
||||||
{ name: 'Supplier', description: 'Company-scoped suppliers (Lieferanten)' },
|
|
||||||
{ name: 'Delivery', description: 'Company-scoped goods receipts (Lieferungen)' },
|
|
||||||
{ name: 'Quote', description: 'Company-scoped quotes (Angebote)' },
|
|
||||||
{ name: 'Order', description: 'Company-scoped sales orders (Aufträge)' },
|
|
||||||
{ name: 'Customergroup', description: 'Company-scoped customer groups' },
|
|
||||||
{ name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' },
|
|
||||||
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },
|
|
||||||
{ name: 'Posterminal', description: 'Company-scoped POS terminals' },
|
|
||||||
{ name: 'Printer', description: 'Company-scoped receipt and label printers' },
|
|
||||||
{ name: 'Ledger', description: 'Append-only ledger transactions' },
|
|
||||||
{ name: 'Stock', description: 'Manual stock additions and removals' },
|
|
||||||
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
|
|
||||||
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
apis: [
|
|
||||||
path.join(root, 'src/api/openapi.js'),
|
|
||||||
path.join(root, 'src/api/routes/**/*.js'),
|
|
||||||
path.join(root, 'src/v1/**/*.js'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const spec = swaggerJsdoc(options);
|
|
||||||
|
|
||||||
function ensureDir(dir) {
|
function ensureDir(dir) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
@@ -756,5 +708,6 @@ for (const apiPath of paths) {
|
|||||||
const overviewOperations = operations.filter((op) => !op.omittedFromOverview);
|
const overviewOperations = operations.filter((op) => !op.omittedFromOverview);
|
||||||
fs.writeFileSync(path.join(docsDir, 'routes.md'), renderRoutesOverview(overviewOperations));
|
fs.writeFileSync(path.join(docsDir, 'routes.md'), renderRoutesOverview(overviewOperations));
|
||||||
fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations));
|
fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations));
|
||||||
|
fs.writeFileSync(path.join(docsDir, 'openapi.json'), JSON.stringify(spec, null, 2));
|
||||||
|
|
||||||
console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md → docs/api/`);
|
console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md + openapi.json → docs/api/`);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function authMeJson(res, req, status, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authMiddleware = async (req, res, next) => {
|
export const authMiddleware = async (req, res, next) => {
|
||||||
if (req.path === '/' || req.path === '/api/auth/login' || req.path === '/api/auth/logout' || req.path === '/api/health') {
|
if (req.path === '/' || req.path === '/api/auth/login' || req.path === '/api/auth/logout' || req.path === '/api/health' || req.path === '/api/openapi.json') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,14 +59,23 @@ export const authMiddleware = async (req, res, next) => {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = req.cookies.token;
|
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
|
||||||
if (!token) return authMeJson(res, req, 401, { error: 'Unauthorized' });
|
? req.headers.authorization.slice(7).trim()
|
||||||
|
: null;
|
||||||
|
const token = req.cookies.token || bearerToken;
|
||||||
|
|
||||||
|
const isDocs = req.path.startsWith('/docs') || req.path.startsWith('/api/docs');
|
||||||
|
if (!token) {
|
||||||
|
if (isDocs) return next();
|
||||||
|
return authMeJson(res, req, 401, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, env.jwtSecret);
|
const decoded = jwt.verify(token, env.jwtSecret);
|
||||||
const authId = Number(decoded.id);
|
const authId = Number(decoded.id);
|
||||||
if (!Number.isInteger(authId) || authId <= 0) {
|
if (!Number.isInteger(authId) || authId <= 0) {
|
||||||
clearTokenCookie(res);
|
if (req.cookies.token) clearTokenCookie(res);
|
||||||
|
if (isDocs) return next();
|
||||||
return authMeJson(res, req, 401, { error: 'Invalid token' });
|
return authMeJson(res, req, 401, { error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +91,8 @@ export const authMiddleware = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
clearCachedUser(authId);
|
clearCachedUser(authId);
|
||||||
clearTokenCookie(res);
|
if (req.cookies.token) clearTokenCookie(res);
|
||||||
|
if (isDocs) return next();
|
||||||
return authMeJson(res, req, 403, { error: 'User is no longer authorized' });
|
return authMeJson(res, req, 403, { error: 'User is no longer authorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +103,7 @@ export const authMiddleware = async (req, res, next) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (decoded.exp - now < 2 * 60 * 60) {
|
if (req.cookies.token && decoded.exp - now < 2 * 60 * 60) {
|
||||||
const newToken = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' });
|
const newToken = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' });
|
||||||
res.cookie('token', newToken, {
|
res.cookie('token', newToken, {
|
||||||
...TOKEN_COOKIE,
|
...TOKEN_COOKIE,
|
||||||
@@ -103,7 +113,9 @@ export const authMiddleware = async (req, res, next) => {
|
|||||||
|
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch {
|
||||||
clearTokenCookie(res);
|
if (req.cookies.token) clearTokenCookie(res);
|
||||||
|
if (isDocs) return next();
|
||||||
return authMeJson(res, req, 401, { error: 'Invalid token' });
|
return authMeJson(res, req, 401, { error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
* in: cookie
|
* in: cookie
|
||||||
* name: token
|
* name: token
|
||||||
* description: HTTP-only session cookie set by POST /auth/login
|
* description: HTTP-only session cookie set by POST /auth/login
|
||||||
|
* bearerAuth:
|
||||||
|
* type: http
|
||||||
|
* scheme: bearer
|
||||||
|
* bearerFormat: JWT
|
||||||
|
* description: JWT token in Authorization header (Bearer <token>)
|
||||||
* schemas:
|
* schemas:
|
||||||
* Error:
|
* Error:
|
||||||
* type: object
|
* type: object
|
||||||
|
|||||||
69
src/api/openapiSpec.js
Normal file
69
src/api/openapiSpec.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import swaggerJsdoc from 'swagger-jsdoc';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const root = path.resolve(__dirname, '../..');
|
||||||
|
|
||||||
|
export const openApiOptions = {
|
||||||
|
definition: {
|
||||||
|
openapi: '3.0.3',
|
||||||
|
info: {
|
||||||
|
title: 'Dash API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description:
|
||||||
|
'Backend API for Dash — Google SSO auth, MSSQL, session cookies.\n\n' +
|
||||||
|
'Browser clients must send cookies (`credentials: \'include\'`) or an `Authorization: Bearer <token>` header. ' +
|
||||||
|
'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h); HTTPS is required for login.',
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{ url: '/api', description: 'API Base (/api)' },
|
||||||
|
{ url: '/', description: 'Root (/)' },
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'Auth', description: 'Google SSO and session' },
|
||||||
|
{ name: 'Health', description: 'Connectivity checks' },
|
||||||
|
{ name: 'POS', description: 'POS bind and sync API (/v1)' },
|
||||||
|
{ name: 'Events', description: 'SSE update signals for live clients' },
|
||||||
|
{ name: 'Article', description: 'Articles under categories' },
|
||||||
|
{ name: 'Category', description: 'Category tree and membership' },
|
||||||
|
{ name: 'Company', description: 'Company CRUD' },
|
||||||
|
{ name: 'CompanySetting', description: 'Company-scoped settings' },
|
||||||
|
{ name: 'Unit', description: 'Company-scoped units of measure' },
|
||||||
|
{ name: 'Brand', description: 'Company-scoped brands' },
|
||||||
|
{ name: 'Supplier', description: 'Company-scoped suppliers (Lieferanten)' },
|
||||||
|
{ name: 'Delivery', description: 'Company-scoped goods receipts (Lieferungen)' },
|
||||||
|
{ name: 'Quote', description: 'Company-scoped quotes (Angebote)' },
|
||||||
|
{ name: 'Order', description: 'Company-scoped sales orders (Aufträge)' },
|
||||||
|
{ name: 'Customergroup', description: 'Company-scoped customer groups' },
|
||||||
|
{ name: 'Entity', description: 'Company-scoped entities, addresses, and contacts' },
|
||||||
|
{ name: 'Image', description: 'Content-addressed AVIF by fullHash' },
|
||||||
|
{ name: 'Posterminal', description: 'Company-scoped POS terminals' },
|
||||||
|
{ name: 'Printer', description: 'Company-scoped receipt and label printers' },
|
||||||
|
{ name: 'Ledger', description: 'Append-only ledger transactions' },
|
||||||
|
{ name: 'Stock', description: 'Manual stock additions and removals' },
|
||||||
|
{ name: 'Warehouse', description: 'Company-scoped warehouses' },
|
||||||
|
{ name: 'Serper', description: 'Serper Google image search and thumbnail streaming' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
apis: [
|
||||||
|
path.join(root, 'src/api/openapi.js'),
|
||||||
|
path.join(root, 'src/api/routes/**/*.js'),
|
||||||
|
path.join(root, 'src/v1/**/*.js'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedSpec = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the parsed OpenAPI 3.0 specification object, cached in memory.
|
||||||
|
* @param {boolean} [refresh=false] - Force rebuild of the spec
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function getOpenApiSpec(refresh = false) {
|
||||||
|
if (!cachedSpec || refresh) {
|
||||||
|
cachedSpec = swaggerJsdoc(openApiOptions);
|
||||||
|
}
|
||||||
|
return cachedSpec;
|
||||||
|
}
|
||||||
111
src/api/routes/docs.js
Normal file
111
src/api/routes/docs.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import swaggerUiDist from 'swagger-ui-dist';
|
||||||
|
import { getOpenApiSpec } from '../openapiSpec.js';
|
||||||
|
|
||||||
|
const swaggerDistPath = swaggerUiDist.getAbsoluteFSPath();
|
||||||
|
|
||||||
|
function renderDocsHtml() {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Dash API</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
|
||||||
|
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
|
||||||
|
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
*, *:before, *:after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="./swagger-ui-bundle.js"></script>
|
||||||
|
<script src="./swagger-ui-standalone-preset.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: './openapi.json',
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: 'StandaloneLayout',
|
||||||
|
requestInterceptor: (req) => {
|
||||||
|
req.credentials = 'include';
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers documentation and interactive API tester under /docs/.
|
||||||
|
* @param {import('express').Application} app - Express app
|
||||||
|
* @param {string} [basePath=''] - Base path (e.g. '/api')
|
||||||
|
*/
|
||||||
|
export function register(app, basePath = '') {
|
||||||
|
const docsRouter = express.Router();
|
||||||
|
|
||||||
|
// Redirect /docs (or /api/docs) to /docs/ so relative assets resolve properly
|
||||||
|
docsRouter.use((req, res, next) => {
|
||||||
|
const rawPath = req.originalUrl.split('?')[0];
|
||||||
|
if (rawPath === '/docs' || rawPath === '/api/docs' || rawPath === '/api/docs/') {
|
||||||
|
const query = req.originalUrl.includes('?') ? '?' + req.originalUrl.split('?')[1] : '';
|
||||||
|
return res.redirect(301, '/docs/' + query);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve OpenAPI 3.0 specification JSON
|
||||||
|
docsRouter.get('/openapi.json', (_req, res) => {
|
||||||
|
try {
|
||||||
|
const spec = getOpenApiSpec();
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.send(JSON.stringify(spec, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate OpenAPI spec:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to generate OpenAPI specification' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve HTML UI for /docs/ and /docs/index.html
|
||||||
|
docsRouter.get('/', (_req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(renderDocsHtml());
|
||||||
|
});
|
||||||
|
|
||||||
|
docsRouter.get('/index.html', (_req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(renderDocsHtml());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static assets from swagger-ui-dist (CSS, JS bundles, icons)
|
||||||
|
docsRouter.use(express.static(swaggerDistPath, { index: false }));
|
||||||
|
|
||||||
|
// Mount on /docs
|
||||||
|
app.use('/docs', docsRouter);
|
||||||
|
|
||||||
|
// Also mount on /api/docs and alias /api/openapi.json if basePath is set
|
||||||
|
if (basePath) {
|
||||||
|
app.use(basePath + '/docs', docsRouter);
|
||||||
|
app.get(basePath + '/openapi.json', (_req, res) => res.redirect(301, '/docs/openapi.json'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user