This commit is contained in:
JiriUhlir
2026-06-29 11:46:55 +02:00
parent f308a1bc29
commit e9d5f6bd36
5 changed files with 70 additions and 107 deletions
+6 -6
View File
@@ -186,12 +186,12 @@ Pokryté oblasti:
Aplikace poslouchá na `0.0.0.0` a portu `PORT` s výchozí hodnotou `3000`. `ROOT_PATH` se používá pro dokumentaci a testovací requesty za reverse proxy, například `/apps/sap-bo`.
`/docs` je Swagger UI, které načítá OpenAPI dokument přes relativní endpoint
`openapi.json` (servírovaný jako `/docs/openapi.json`, alias `/openapi.json`). Díky tomu
se dokument načte lokálně i za AppFactory proxy bez hardcodování prefixu. V Swaggeru použij
`Authorize` pro vyplnění povinných `X-SAP-B1-*` hlaviček a potom `Try it out` u konkrétní
operace. OpenAPI `servers` se nastaví z `ROOT_PATH` (fallback `X-Forwarded-Prefix`, jinak
`/`), takže za proxy volá například `/apps/sap-bo/api/business-partners`, ne root doménu.
`/docs` je Swagger UI servírované přímo jako HTML (stejně jako sousední `google-service`),
bez statického middleware proto nevzniká redirect `/docs``/docs/`, který by za proxy
zahodil prefix. Assety se načítají z CDN, spec URL je `ROOT_PATH + /openapi.json`. V Swaggeru
použij `Authorize` pro vyplnění povinných `X-SAP-B1-*` hlaviček a potom `Try it out` u
konkrétní operace. OpenAPI `servers` se nastaví z `ROOT_PATH` (jinak `/`), takže za proxy
volá například `/apps/sap-bo/api/business-partners`, ne root doménu.
## TODO ověřit v konkrétní instalaci SAP Business One
+12 -10
View File
@@ -50,19 +50,21 @@ HTTP API hlavičky:
Aplikace běží za AppFactory proxy na `/apps/<app-id>` (Caddy `handle_path` prefix před
předáním do containeru odstraní, takže container vidí routy bez prefixu a z requestu
veřejnou cestu nelze odvodit). Stejný přístup jako sousední služba `idoklad`:
veřejnou cestu nelze odvodit). Stejný přístup jako sousední služba `google-service`:
- OpenAPI dokument se servíruje **pod stejným `/docs` prefixem** jako UI na
`GET /docs/openapi.json` (plus alias `GET /openapi.json` pro přímý přístup).
- Swagger UI načítá dokument přes **relativní** endpoint `openapi.json`, takže se
v prohlížeči vyhodnotí jako `{prefix}/docs/openapi.json` lokálně i za proxy, bez
hardcodování `/apps/<app-id>`.
- `servers[0].url` se nastaví z `ROOT_PATH` (fallback hlavička `X-Forwarded-Prefix`,
nakonec `/`), takže Swagger `Try it out` volá `…/apps/<app-id>/api/<resource>`.
- `/docs` se servíruje **přímo jako HTML** (`GET /docs`), bez `swagger-ui-express` a bez
statického middleware proto **nevzniká žádný redirect `/docs` → `/docs/`**, který by za
proxy zahodil prefix `/apps/<app-id>` (to byla příčina bílé stránky).
- Swagger UI assety se načítají z CDN (`unpkg.com/swagger-ui-dist@5`).
- Spec URL je `ROOT_PATH + /openapi.json`, takže odkazuje na veřejné
`/apps/<app-id>/openapi.json`.
- `servers[0].url` se nastaví z `ROOT_PATH` (jinak `/`), takže Swagger `Try it out` volá
`…/apps/<app-id>/api/<resource>`.
| Kontrola | Lokálně (bez ROOT_PATH) | S `ROOT_PATH=/apps/sap-bo` |
|---|---|---|
| `GET /health` | 200 | 200 |
| `GET /docs` (Swagger UI) | 200 | 200 |
| `GET /docs/openapi.json` | 200 | 200 |
| `GET /docs` (Swagger UI) | 200, žádný redirect | 200, žádný redirect |
| `GET /openapi.json` | 200 | 200 |
| spec URL v `/docs` | `/openapi.json` | `/apps/sap-bo/openapi.json` |
| `servers[0].url` v OpenAPI | `/` | `/apps/sap-bo` |
-44
View File
@@ -11,13 +11,11 @@
"axios": "^1.7.9",
"dotenv": "^16.4.7",
"express": "^4.18.3",
"swagger-ui-express": "^5.0.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.11.30",
"@types/swagger-ui-express": "^4.1.8",
"typescript": "^5.4.0",
"vitest": "^4.1.9"
}
@@ -356,13 +354,6 @@
"dev": true,
"license": "MIT"
},
"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/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -524,17 +515,6 @@
"@types/node": "*"
}
},
"node_modules/@types/swagger-ui-express": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz",
"integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/serve-static": "*"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
@@ -2131,30 +2111,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/swagger-ui-dist": {
"version": "5.32.8",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
-2
View File
@@ -14,13 +14,11 @@
"axios": "^1.7.9",
"dotenv": "^16.4.7",
"express": "^4.18.3",
"swagger-ui-express": "^5.0.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.11.30",
"@types/swagger-ui-express": "^4.1.8",
"typescript": "^5.4.0",
"vitest": "^4.1.9"
}
+52 -45
View File
@@ -1,5 +1,4 @@
import express, { Request, Response } from "express";
import swaggerUi from "swagger-ui-express";
import { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer";
import { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
import { buildODataParams, extractNextLink } from "./client/odata";
@@ -38,11 +37,25 @@ interface ResourceRoute {
}
const app = express();
app.set("trust proxy", true);
const port = Number(process.env.PORT || 3000);
const rootPath = process.env.ROOT_PATH || "";
const rootPath = normalizeRootPath(process.env.ROOT_PATH || "");
const serviceName = "SAP Business One";
const serviceId = "sap-bo";
function normalizeRootPath(value: string): string {
if (!value) {
return "";
}
const withLeadingSlash = value.startsWith("/") ? value : "/" + value;
return withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
}
function withRootPath(path: string): string {
return rootPath + path;
}
const resourceRoutes: ResourceRoute[] = [
{
slug: "business-partners",
@@ -581,31 +594,42 @@ function openApiDocument(basePath = "") {
};
}
function resolveBasePath(req: Request): string {
// Behind the AppFactory reverse proxy the public prefix is /apps/<app-id>, but the
// handle_path route strips it before the request reaches this container, so it cannot be
// read from the request path. ROOT_PATH is the sanctioned source; X-Forwarded-Prefix is a
// fallback for proxies that forward it. Empty when running without a proxy.
const forwardedPrefix = getHeader(req, "x-forwarded-prefix");
return (rootPath || forwardedPrefix || "").replace(/\/+$/, "");
}
function swaggerUiOptions() {
return {
customSiteTitle: "SAP Business One connector API",
swaggerOptions: {
// Relative endpoint (same approach as the sibling idoklad service): the browser resolves
// it against the docs page, i.e. {prefix}/docs/openapi.json, so the spec loads locally and
// behind the AppFactory proxy without hardcoding the /apps/<app-id> prefix.
url: "openapi.json",
displayRequestDuration: true,
function renderDocsHtml(): string {
// Same approach as the sibling google-service: serve the Swagger UI page directly at /docs
// (no static-file middleware, so no /docs -> /docs/ redirect that would drop the proxy prefix),
// load the UI assets from CDN, and point the spec URL at ROOT_PATH + /openapi.json so it resolves
// to the public /apps/<app-id>/openapi.json behind the AppFactory reverse proxy.
const specUrl = withRootPath("/openapi.json");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SAP Business One connector API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
body { margin: 0; background: #f7f7f7; }
.topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.ui = SwaggerUIBundle({
url: ${JSON.stringify(specUrl)},
dom_id: "#swagger-ui",
deepLinking: true,
persistAuthorization: true,
tryItOutEnabled: true,
filter: true,
displayRequestDuration: true,
tagsSorter: "alpha",
operationsSorter: "method"
}
};
});
</script>
</body>
</html>`;
}
app.get("/", (_req, res) => {
@@ -616,33 +640,16 @@ app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
// OpenAPI document served under the same /docs prefix as the UI so the relative UI endpoint
// resolves correctly locally and behind the reverse-proxy prefix. servers[0].url advertises the
// public prefix (ROOT_PATH) so Swagger UI "Try it out" targets {prefix}/api/..., not the host root.
app.get("/docs/openapi.json", (req, res) => {
res.json(openApiDocument(resolveBasePath(req)));
app.get("/docs", (_req, res) => {
res.type("html").send(renderDocsHtml());
});
// Documented root alias for direct access to the OpenAPI document.
app.get("/openapi.json", (req, res) => {
res.json(openApiDocument(resolveBasePath(req)));
// servers[0].url advertises the public prefix (ROOT_PATH) so Swagger UI "Try it out" targets
// {prefix}/api/..., not the host root.
app.get("/openapi.json", (_req, res) => {
res.json(openApiDocument(rootPath));
});
// swagger-ui-express's static handler redirects /docs -> absolute "/docs/", which behind the
// AppFactory handle_path proxy drops the /apps/<app-id> prefix and breaks the page. Intercept
// the no-slash form first and redirect to the ROOT_PATH-prefixed URL (mirrors idoklad's
// UsePathBase), so the trailing-slash form keeps the public prefix.
app.get("/docs", (req, res, next) => {
// Express non-strict routing matches both /docs and /docs/ here; only the no-slash form
// needs the redirect (otherwise /docs/ would redirect to itself in a loop).
if (req.path.endsWith("/")) {
return next();
}
res.redirect(301, `${resolveBasePath(req)}/docs/`);
});
app.use("/docs", swaggerUi.serve, swaggerUi.setup(undefined, swaggerUiOptions()));
addResourceEndpoints();
if (require.main === module) {