This commit is contained in:
JiriUhlir
2026-06-29 10:01:03 +02:00
parent a7aedfce10
commit 5281d56998
4 changed files with 72 additions and 146 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ 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`. 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` a `/openapi.json` dokumentují všechny obecně implementované endpointy a obsahují povinné `X-SAP-B1-*` hlavičky. `/docs` je Swagger UI napojené na `/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` respektuje `ROOT_PATH`, takže za AppFactory proxy volá například `/apps/sap-bo/api/business-partners`, ne root doménu.
## TODO ověřit v konkrétní instalaci SAP Business One ## TODO ověřit v konkrétní instalaci SAP Business One
+44
View File
@@ -11,11 +11,13 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.18.3", "express": "^4.18.3",
"swagger-ui-express": "^5.0.1",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.11.30", "@types/node": "^20.11.30",
"@types/swagger-ui-express": "^4.1.8",
"typescript": "^5.4.0", "typescript": "^5.4.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
@@ -354,6 +356,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -515,6 +524,17 @@
"@types/node": "*" "@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": { "node_modules/@vitest/expect": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
@@ -2111,6 +2131,30 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+2
View File
@@ -14,11 +14,13 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.18.3", "express": "^4.18.3",
"swagger-ui-express": "^5.0.1",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.11.30", "@types/node": "^20.11.30",
"@types/swagger-ui-express": "^4.1.8",
"typescript": "^5.4.0", "typescript": "^5.4.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }
+25 -145
View File
@@ -1,4 +1,5 @@
import express, { Request, Response } from "express"; import express, { Request, Response } from "express";
import swaggerUi from "swagger-ui-express";
import { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer"; import { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer";
import { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client"; import { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
import { buildODataParams, extractNextLink } from "./client/odata"; import { buildODataParams, extractNextLink } from "./client/odata";
@@ -531,6 +532,14 @@ function openApiDocument(basePath = "") {
"HTTP API and TypeScript connector for SAP Business One Service Layer. SAP credentials are supplied per request in X-SAP-B1-* headers." "HTTP API and TypeScript connector for SAP Business One Service Layer. SAP credentials are supplied per request in X-SAP-B1-* headers."
}, },
servers: [{ url: basePath || "/" }], servers: [{ url: basePath || "/" }],
security: [
{
SapB1BaseUrl: [],
SapB1CompanyDB: [],
SapB1Username: [],
SapB1Password: []
}
],
tags: [ tags: [
{ name: "Service" }, { name: "Service" },
{ name: "Session" }, { name: "Session" },
@@ -572,143 +581,19 @@ function openApiDocument(basePath = "") {
}; };
} }
function docsHtml(basePath = "") { function swaggerOptions(basePath = "") {
const openApiUrl = `${basePath}/openapi.json`.replace("//", "/"); return {
const resources = resourceRoutes customSiteTitle: "SAP Business One connector API",
.map( swaggerOptions: {
(route) => `<tr> url: `${basePath}/openapi.json`.replace("//", "/"),
<td>${route.tag}</td> displayRequestDuration: true,
<td><code>GET ${basePath}/api/${route.slug}</code></td> persistAuthorization: true,
<td><code>GET ${basePath}/api/${route.slug}/{id}</code></td> tryItOutEnabled: true,
<td><code>POST ${basePath}/api/${route.slug}</code></td> filter: true,
<td><code>PATCH ${basePath}/api/${route.slug}/{id}</code></td> tagsSorter: "alpha",
<td>${route.deleteSupported ? `<code>DELETE ${basePath}/api/${route.slug}/{id}</code>` : "not generally safe"}</td> operationsSorter: "method"
</tr>` }
) };
.join("");
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 docs</title>
<style>
body { font-family: Arial, sans-serif; margin: 2rem; line-height: 1.5; color: #1f2937; }
code, pre { background: #f3f4f6; border-radius: 4px; padding: 0.2rem 0.35rem; }
a, button { color: #0f766e; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
th, td { border: 1px solid #d1d5db; padding: 0.5rem; text-align: left; vertical-align: top; }
th { background: #f9fafb; }
input, textarea, select, button { font: inherit; margin: 0.25rem 0; padding: 0.35rem; }
textarea { width: min(760px, 100%); height: 9rem; display: block; }
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
.field { display: grid; gap: 0.2rem; }
</style>
</head>
<body>
<h1>SAP Business One connector</h1>
<p>HTTP API over SAP Business One Service Layer. SAP credentials are supplied in X-SAP-B1-* request headers and are never logged or returned.</p>
<p><a href="${openApiUrl}">OpenAPI JSON</a></p>
<h2>SAP credentials headers</h2>
<div class="row">
<label class="field">Base URL
<input id="sap-base-url" size="36" placeholder="https://sap-host:50000">
</label>
<label class="field">Company DB
<input id="sap-company-db" size="18" placeholder="SBODEMO">
</label>
<label class="field">Username
<input id="sap-username" size="18">
</label>
<label class="field">Password
<input id="sap-password" type="password" size="18">
</label>
<label class="field">Language
<input id="sap-language" size="10">
</label>
<label class="field">Reject unauthorized
<select id="sap-reject-unauthorized">
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label class="field">Timeout ms
<input id="sap-timeout-ms" size="10" value="30000">
</label>
</div>
<h2>Session</h2>
<button type="button" data-method="POST" data-url="${basePath}/api/session/login">Login</button>
<button type="button" data-method="POST" data-url="${basePath}/api/session/logout">Logout</button>
<button type="button" data-method="GET" data-url="${basePath}/health">Health</button>
<h2>Resources</h2>
<table>
<thead>
<tr><th>Resource</th><th>List</th><th>Get</th><th>Create</th><th>Update</th><th>Delete</th></tr>
</thead>
<tbody>${resources}</tbody>
</table>
<h2>Try request</h2>
<div class="row">
<label class="field">Method
<select id="method">
<option>GET</option>
<option>POST</option>
<option>PATCH</option>
<option>DELETE</option>
</select>
</label>
<label class="field">Path
<input id="path" size="48" value="${basePath}/api/business-partners?$top=10">
</label>
<button type="button" id="send">Send</button>
</div>
<label>JSON body</label>
<textarea id="body">{}</textarea>
<pre id="result" aria-live="polite"></pre>
<script>
const result = document.getElementById("result");
const method = document.getElementById("method");
const path = document.getElementById("path");
const body = document.getElementById("body");
function sapHeaders() {
const headers = {
"X-SAP-B1-BaseUrl": document.getElementById("sap-base-url").value,
"X-SAP-B1-CompanyDB": document.getElementById("sap-company-db").value,
"X-SAP-B1-Username": document.getElementById("sap-username").value,
"X-SAP-B1-Password": document.getElementById("sap-password").value,
"X-SAP-B1-Reject-Unauthorized": document.getElementById("sap-reject-unauthorized").value,
"X-SAP-B1-Timeout-Ms": document.getElementById("sap-timeout-ms").value
};
const language = document.getElementById("sap-language").value;
if (language) {
headers["X-SAP-B1-Language"] = language;
}
return headers;
}
async function sendRequest(requestMethod, requestUrl, requestBody) {
const options = { method: requestMethod, headers: sapHeaders() };
if (requestMethod !== "GET" && requestMethod !== "DELETE") {
options.headers["content-type"] = "application/json";
options.body = requestBody || "{}";
}
const response = await fetch(requestUrl, options);
const contentType = response.headers.get("content-type") || "";
const payload = response.status === 204 ? null : contentType.includes("application/json") ? await response.json() : await response.text();
result.textContent = JSON.stringify({ status: response.status, body: payload }, null, 2);
}
document.querySelectorAll("[data-method][data-url]").forEach((button) => {
button.addEventListener("click", () => sendRequest(button.dataset.method, button.dataset.url));
});
document.getElementById("send").addEventListener("click", () => sendRequest(method.value, path.value, body.value));
</script>
</body>
</html>`;
} }
app.get("/", (_req, res) => { app.get("/", (_req, res) => {
@@ -723,12 +608,10 @@ app.get("/openapi.json", (_req, res) => {
res.json(openApiDocument(rootPath)); res.json(openApiDocument(rootPath));
}); });
app.get("/docs", (_req, res) => {
res.type("html").send(docsHtml(rootPath));
});
addResourceEndpoints(); addResourceEndpoints();
app.use("/docs", swaggerUi.serveFiles(undefined, swaggerOptions("")), swaggerUi.setup(undefined, swaggerOptions("")));
if (rootPath) { if (rootPath) {
app.get(rootPath, (_req, res) => { app.get(rootPath, (_req, res) => {
res.json(serviceMetadata()); res.json(serviceMetadata());
@@ -742,11 +625,8 @@ if (rootPath) {
res.json(openApiDocument(rootPath)); res.json(openApiDocument(rootPath));
}); });
app.get(rootPath + "/docs", (_req, res) => {
res.type("html").send(docsHtml(rootPath));
});
addResourceEndpoints(rootPath); addResourceEndpoints(rootPath);
app.use(rootPath + "/docs", swaggerUi.serveFiles(undefined, swaggerOptions(rootPath)), swaggerUi.setup(undefined, swaggerOptions(rootPath)));
} }
if (require.main === module) { if (require.main === module) {