This commit is contained in:
JiriUhlir
2026-06-29 09:55:04 +02:00
parent 9c08dacc69
commit a7aedfce10
3 changed files with 702 additions and 48 deletions
+30 -2
View File
@@ -8,6 +8,14 @@ Součástí repozitáře je i malý AppFactory HTTP wrapper s endpointy:
- `GET /health` - `GET /health`
- `GET /docs` - `GET /docs`
- `GET /openapi.json` - `GET /openapi.json`
- `POST /api/session/login`
- `POST /api/session/logout`
- `GET /api/<resource>`
- `GET /api/<resource>/all`
- `GET /api/<resource>/{id}`
- `POST /api/<resource>`
- `PATCH /api/<resource>/{id}`
- `DELETE /api/<resource>/{id}` pouze pro obecně mazatelné resource
## Instalace ## Instalace
@@ -17,7 +25,7 @@ npm run build
npm test npm test
``` ```
## Konfigurace ## Konfigurace knihovny
Vytvoř `.env` podle `.env.example`: Vytvoř `.env` podle `.env.example`:
@@ -43,6 +51,24 @@ SAP_B1_REJECT_UNAUTHORIZED=false
V produkci preferuj důvěryhodný certifikát a ponech `true`. V produkci preferuj důvěryhodný certifikát a ponech `true`.
## HTTP API credentials
Podle AppFactory pravidel se SAP credentials pro HTTP API předávají v request headers, ne v JSON body:
```http
X-SAP-B1-BaseUrl: https://sap.example.local:50000
X-SAP-B1-CompanyDB: SBODEMOUS
X-SAP-B1-Username: manager
X-SAP-B1-Password: <secret>
X-SAP-B1-Language: 3
X-SAP-B1-Reject-Unauthorized: true
X-SAP-B1-Timeout-Ms: 30000
```
Povinné jsou `X-SAP-B1-BaseUrl`, `X-SAP-B1-CompanyDB`, `X-SAP-B1-Username` a `X-SAP-B1-Password`.
Heslo se nesmí zapisovat do README, logů ani běžných response. `/docs` obsahují interaktivní formulář, který tyto hodnoty posílá jako hlavičky.
## Použití ## Použití
### Login a logout ### Login a logout
@@ -158,7 +184,9 @@ Pokryté oblasti:
## AppFactory ## AppFactory
Aplikace poslouchá na `0.0.0.0` a portu `PORT` s výchozí hodnotou `3000`. `ROOT_PATH` se používá pro dokumentaci 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.
## TODO ověřit v konkrétní instalaci SAP Business One ## TODO ověřit v konkrétní instalaci SAP Business One
@@ -7,6 +7,7 @@ Hlavní vlastnosti:
- login/logout přes Service Layer - login/logout přes Service Layer
- správa `B1SESSION` a `ROUTEID` - správa `B1SESSION` a `ROUTEID`
- automatický re-login po expiraci session - automatický re-login po expiraci session
- HTTP API s credentials předávanými v `X-SAP-B1-*` hlavičkách podle AppFactory pravidel
- OData parametry `$select`, `$filter`, `$top`, `$skip`, `$orderby` - OData parametry `$select`, `$filter`, `$top`, `$skip`, `$orderby`
- stránkování přes `odata.nextLink` a `@odata.nextLink` - stránkování přes `odata.nextLink` a `@odata.nextLink`
- retry pro dočasné chyby - retry pro dočasné chyby
@@ -14,7 +15,19 @@ Hlavní vlastnosti:
Bezpečnostní pravidla: Bezpečnostní pravidla:
- hesla se čtou pouze z environment variables - při knihovním použití se konfigurace může číst z environment variables
- při HTTP API použití se SAP credentials posílají v request headers
- hesla, cookies a session tokeny se nelogují - hesla, cookies a session tokeny se nelogují
- hesla a session tokeny se nevrací v běžných response
- testy nepoužívají reálný SAP přístup - testy nepoužívají reálný SAP přístup
- pro self-signed certifikáty je dostupné `rejectUnauthorized`, ale produkčně se doporučuje důvěryhodný certifikát - pro self-signed certifikáty je dostupné `rejectUnauthorized`, ale produkčně se doporučuje důvěryhodný certifikát
HTTP API hlavičky:
- `X-SAP-B1-BaseUrl`
- `X-SAP-B1-CompanyDB`
- `X-SAP-B1-Username`
- `X-SAP-B1-Password`
- `X-SAP-B1-Language` volitelně
- `X-SAP-B1-Reject-Unauthorized` volitelně
- `X-SAP-B1-Timeout-Ms` volitelně
+646 -33
View File
@@ -1,5 +1,10 @@
import express from "express"; import express, { Request, Response } from "express";
import { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer"; import { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer";
import { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
import { buildODataParams, extractNextLink } from "./client/odata";
import { loadSapB1ConfigFromEnv, SapB1Config, SapB1ConfigSchema } from "./config";
import { SapB1Error } from "./errors/SapB1Error";
import { ODataQuery } from "./types/odata";
export { SessionManager, SapB1Session } from "./auth/SessionManager"; export { SessionManager, SapB1Session } from "./auth/SessionManager";
export { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client"; export { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
@@ -10,24 +15,390 @@ export { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer";
export * from "./types/entities"; export * from "./types/entities";
export * from "./types/odata"; export * from "./types/odata";
type ResourceName =
| "businessPartners"
| "items"
| "orders"
| "invoices"
| "purchaseOrders"
| "deliveryNotes"
| "stockTransfers";
interface ResourceRoute {
slug: string;
tag: string;
property: ResourceName;
sapEntitySet: string;
idName: string;
idType: "string" | "number";
deleteSupported: boolean;
sampleCreate: Record<string, unknown>;
sampleUpdate: Record<string, unknown>;
}
const app = express(); const app = express();
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const rootPath = process.env.ROOT_PATH || ""; const rootPath = process.env.ROOT_PATH || "";
const serviceName = "SAP Business One"; const serviceName = "SAP Business One";
const serviceId = "sap-bo"; const serviceId = "sap-bo";
function openApiDocument(basePath = "") { const resourceRoutes: ResourceRoute[] = [
return { {
openapi: "3.0.3", slug: "business-partners",
info: { tag: "BusinessPartners",
title: "SAP Business One connector service", property: "businessPartners",
version: "1.0.0", sapEntitySet: "BusinessPartners",
description: "Health and documentation wrapper for the SAP Business One Service Layer TypeScript connector." idName: "CardCode",
idType: "string",
deleteSupported: true,
sampleCreate: { CardCode: "C001", CardName: "Example customer", CardType: "cCustomer" },
sampleUpdate: { CardName: "Updated customer name" }
}, },
servers: [{ url: basePath || "/" }], {
paths: { slug: "items",
tag: "Items",
property: "items",
sapEntitySet: "Items",
idName: "ItemCode",
idType: "string",
deleteSupported: true,
sampleCreate: { ItemCode: "A00001", ItemName: "Example item", InventoryItem: "tYES" },
sampleUpdate: { ItemName: "Updated item name" }
},
{
slug: "orders",
tag: "Orders",
property: "orders",
sapEntitySet: "Orders",
idName: "DocEntry",
idType: "number",
deleteSupported: false,
sampleCreate: { CardCode: "C001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
sampleUpdate: { Comments: "Updated by connector" }
},
{
slug: "invoices",
tag: "Invoices",
property: "invoices",
sapEntitySet: "Invoices",
idName: "DocEntry",
idType: "number",
deleteSupported: false,
sampleCreate: { CardCode: "C001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
sampleUpdate: { Comments: "Updated by connector" }
},
{
slug: "purchase-orders",
tag: "PurchaseOrders",
property: "purchaseOrders",
sapEntitySet: "PurchaseOrders",
idName: "DocEntry",
idType: "number",
deleteSupported: false,
sampleCreate: { CardCode: "V001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
sampleUpdate: { Comments: "Updated by connector" }
},
{
slug: "delivery-notes",
tag: "DeliveryNotes",
property: "deliveryNotes",
sapEntitySet: "DeliveryNotes",
idName: "DocEntry",
idType: "number",
deleteSupported: false,
sampleCreate: { CardCode: "C001", DocumentLines: [{ ItemCode: "A00001", Quantity: 1 }] },
sampleUpdate: { Comments: "Updated by connector" }
},
{
slug: "stock-transfers",
tag: "StockTransfers",
property: "stockTransfers",
sapEntitySet: "StockTransfers",
idName: "DocEntry",
idType: "number",
deleteSupported: false,
sampleCreate: { StockTransferLines: [{ ItemCode: "A00001", Quantity: 1, FromWarehouseCode: "01", WarehouseCode: "02" }] },
sampleUpdate: { Comments: "Updated by connector" }
}
];
app.use(express.json({ limit: "2mb" }));
function getHeader(req: Request, name: string): string | undefined {
const value = req.get(name);
return value && value.trim() ? value.trim() : undefined;
}
function buildConfigFromRequest(req: Request): SapB1Config {
return SapB1ConfigSchema.parse({
baseUrl: getHeader(req, "X-SAP-B1-BaseUrl"),
companyDB: getHeader(req, "X-SAP-B1-CompanyDB"),
username: getHeader(req, "X-SAP-B1-Username"),
password: getHeader(req, "X-SAP-B1-Password"),
language: getHeader(req, "X-SAP-B1-Language"),
timeout: getHeader(req, "X-SAP-B1-Timeout-Ms") ? Number(getHeader(req, "X-SAP-B1-Timeout-Ms")) : undefined,
rejectUnauthorized: getHeader(req, "X-SAP-B1-Reject-Unauthorized")
? getHeader(req, "X-SAP-B1-Reject-Unauthorized") !== "false"
: undefined,
retryCount: getHeader(req, "X-SAP-B1-Retry-Count") ? Number(getHeader(req, "X-SAP-B1-Retry-Count")) : undefined,
retryDelayMs: getHeader(req, "X-SAP-B1-Retry-Delay-Ms") ? Number(getHeader(req, "X-SAP-B1-Retry-Delay-Ms")) : undefined
});
}
function getSap(req: Request): SapBusinessOneServiceLayer {
return new SapBusinessOneServiceLayer(buildConfigFromRequest(req));
}
async function withSap<T>(req: Request, action: (sap: SapBusinessOneServiceLayer) => Promise<T>): Promise<T> {
const sap = getSap(req);
try {
return await action(sap);
} finally {
await sap.logout().catch(() => undefined);
}
}
function parseODataQuery(req: Request): ODataQuery {
const query: ODataQuery = {};
const select = req.query.$select ?? req.query.select;
const filter = req.query.$filter ?? req.query.filter;
const top = req.query.$top ?? req.query.top;
const skip = req.query.$skip ?? req.query.skip;
const orderby = req.query.$orderby ?? req.query.orderby;
if (typeof select === "string" && select.trim()) {
query.select = select.split(",").map((item) => item.trim()).filter(Boolean);
}
if (typeof filter === "string" && filter.trim()) {
query.filter = filter;
}
if (typeof top === "string" && top.trim()) {
query.top = Number(top);
}
if (typeof skip === "string" && skip.trim()) {
query.skip = Number(skip);
}
if (typeof orderby === "string" && orderby.trim()) {
query.orderby = orderby;
}
return query;
}
function parseId(route: ResourceRoute, rawId: string): string | number {
return route.idType === "number" ? Number(rawId) : rawId;
}
function routeResource(sap: SapBusinessOneServiceLayer, route: ResourceRoute) {
return sap[route.property];
}
function asyncHandler(handler: (req: Request, res: Response) => Promise<void>) {
return (req: Request, res: Response) => {
handler(req, res).catch((error) => sendError(res, error));
};
}
function sendError(res: Response, error: unknown): void {
if (error instanceof SapB1Error) {
res.status(error.status || 502).json({
error: {
type: error.name,
code: error.code,
message: error.message,
retryable: error.retryable
}
});
return;
}
if (error instanceof Error) {
res.status(400).json({ error: { type: error.name, message: error.message } });
return;
}
res.status(500).json({ error: { type: "Error", message: "Unexpected error" } });
}
function serviceMetadata() {
return {
name: serviceName,
service: serviceId,
status: "ok",
resources: resourceRoutes.map((route) => route.slug)
};
}
function addResourceEndpoints(prefix = "") {
app.post(
`${prefix}/api/session/login`,
asyncHandler(async (req, res) => {
await withSap(req, (sap) => sap.login());
res.json({ status: "ok" });
})
);
app.post(
`${prefix}/api/session/logout`,
asyncHandler(async (req, res) => {
await getSap(req).logout();
res.status(204).send();
})
);
for (const route of resourceRoutes) {
const base = `${prefix}/api/${route.slug}`;
app.get(
base,
asyncHandler(async (req, res) => {
const result = await withSap<unknown>(req, (sap) => routeResource(sap, route).list(parseODataQuery(req)));
res.json(result);
})
);
app.get(
`${base}/all`,
asyncHandler(async (req, res) => {
const result = await withSap<unknown[]>(req, (sap) => routeResource(sap, route).listAll(parseODataQuery(req)));
res.json({ value: result });
})
);
app.get(
`${base}/:id`,
asyncHandler(async (req, res) => {
const result = await withSap<unknown>(req, (sap) => routeResource(sap, route).get(parseId(route, req.params.id) as never));
res.json(result);
})
);
app.post(
base,
asyncHandler(async (req, res) => {
const result = await withSap<unknown>(req, (sap) => routeResource(sap, route).create(req.body as never));
res.status(201).json(result);
})
);
app.patch(
`${base}/:id`,
asyncHandler(async (req, res) => {
await withSap(req, (sap) => routeResource(sap, route).update(parseId(route, req.params.id) as never, req.body as never));
res.status(204).send();
})
);
if (route.deleteSupported) {
app.delete(
`${base}/:id`,
asyncHandler(async (req, res) => {
await withSap(req, (sap) => routeResource(sap, route).delete(parseId(route, req.params.id) as never));
res.status(204).send();
})
);
}
}
}
function commonParameters(route: ResourceRoute) {
return [
{
name: "id",
in: "path",
required: true,
schema: { type: route.idType },
description: `${route.idName} in SAP Business One ${route.sapEntitySet}.`
}
];
}
function sapCredentialParameters() {
return [
{
name: "X-SAP-B1-BaseUrl",
in: "header",
required: true,
schema: { type: "string", format: "uri" },
description: "SAP Business One Service Layer base URL, for example https://sap-host:50000."
},
{
name: "X-SAP-B1-CompanyDB",
in: "header",
required: true,
schema: { type: "string" },
description: "SAP Business One company database."
},
{
name: "X-SAP-B1-Username",
in: "header",
required: true,
schema: { type: "string" },
description: "SAP Business One user name."
},
{
name: "X-SAP-B1-Password",
in: "header",
required: true,
schema: { type: "string", format: "password" },
description: "SAP Business One password. Never logged or returned."
},
{
name: "X-SAP-B1-Language",
in: "header",
required: false,
schema: { type: "string" },
description: "Optional SAP language code."
},
{
name: "X-SAP-B1-Reject-Unauthorized",
in: "header",
required: false,
schema: { type: "boolean", default: true },
description: "Set false only for internal self-signed certificates."
},
{
name: "X-SAP-B1-Timeout-Ms",
in: "header",
required: false,
schema: { type: "integer", default: 30000 },
description: "Request timeout in milliseconds."
}
];
}
function odataParameters() {
return [
{ name: "$select", in: "query", required: false, schema: { type: "string" }, description: "Comma separated OData field list." },
{ name: "$filter", in: "query", required: false, schema: { type: "string" }, description: "OData filter expression." },
{ name: "$top", in: "query", required: false, schema: { type: "integer", minimum: 1 }, description: "Maximum records to return." },
{ name: "$skip", in: "query", required: false, schema: { type: "integer", minimum: 0 }, description: "Records to skip." },
{ name: "$orderby", in: "query", required: false, schema: { type: "string" }, description: "OData order by expression." }
];
}
function jsonBody(example: Record<string, unknown>) {
return {
required: true,
content: {
"application/json": {
schema: { type: "object", additionalProperties: true },
example
}
}
};
}
function openApiDocument(basePath = "") {
const paths: Record<string, unknown> = {
"/": { "/": {
get: { get: {
tags: ["Service"],
summary: "Service metadata", summary: "Service metadata",
responses: { responses: {
"200": { description: "Service status" } "200": { description: "Service status" }
@@ -36,6 +407,7 @@ function openApiDocument(basePath = "") {
}, },
"/health": { "/health": {
get: { get: {
tags: ["Service"],
summary: "Health check", summary: "Health check",
responses: { responses: {
"200": { description: "Service is ready to accept traffic" } "200": { description: "Service is ready to accept traffic" }
@@ -44,11 +416,157 @@ function openApiDocument(basePath = "") {
}, },
"/openapi.json": { "/openapi.json": {
get: { get: {
tags: ["Service"],
summary: "OpenAPI schema", summary: "OpenAPI schema",
responses: { responses: {
"200": { description: "OpenAPI document" } "200": { description: "OpenAPI document" }
} }
} }
},
"/api/session/login": {
post: {
tags: ["Session"],
summary: "Open SAP Business One Service Layer session",
description:
"Uses SAP credentials from X-SAP-B1-* request headers. Credentials are never accepted in the request body, logged, or returned.",
parameters: sapCredentialParameters(),
responses: {
"200": { description: "Session is available" },
"502": { description: "SAP login failed" }
}
}
},
"/api/session/logout": {
post: {
tags: ["Session"],
summary: "Logout from SAP Business One Service Layer",
parameters: sapCredentialParameters(),
responses: {
"204": { description: "Session closed or was not active" }
}
}
}
};
for (const route of resourceRoutes) {
const base = `/api/${route.slug}`;
paths[base] = {
get: {
tags: [route.tag],
summary: `List ${route.sapEntitySet}`,
description: `Maps to SAP Service Layer GET /b1s/v1/${route.sapEntitySet}.`,
parameters: [...sapCredentialParameters(), ...odataParameters()],
responses: {
"200": { description: "OData response with value array and optional nextLink" }
}
},
post: {
tags: [route.tag],
summary: `Create ${route.sapEntitySet} record`,
description: `Maps to SAP Service Layer POST /b1s/v1/${route.sapEntitySet}.`,
parameters: sapCredentialParameters(),
requestBody: jsonBody(route.sampleCreate),
responses: {
"201": { description: "Created SAP object" }
}
}
};
paths[`${base}/all`] = {
get: {
tags: [route.tag],
summary: `Load all ${route.sapEntitySet} pages`,
description: "Follows odata.nextLink/@odata.nextLink until all pages are loaded. Use carefully for large datasets.",
parameters: [...sapCredentialParameters(), ...odataParameters()],
responses: {
"200": { description: "Object with value array containing all loaded records" }
}
}
};
const itemOperations: Record<string, unknown> = {
get: {
tags: [route.tag],
summary: `Get ${route.sapEntitySet} by ${route.idName}`,
description: `Maps to SAP Service Layer GET /b1s/v1/${route.sapEntitySet}(<id>).`,
parameters: [...sapCredentialParameters(), ...commonParameters(route)],
responses: {
"200": { description: "SAP object" },
"404": { description: "Object was not found by SAP Service Layer" }
}
},
patch: {
tags: [route.tag],
summary: `Update ${route.sapEntitySet} by ${route.idName}`,
description: `Maps to SAP Service Layer PATCH /b1s/v1/${route.sapEntitySet}(<id>).`,
parameters: [...sapCredentialParameters(), ...commonParameters(route)],
requestBody: jsonBody(route.sampleUpdate),
responses: {
"204": { description: "Updated" }
}
}
};
if (route.deleteSupported) {
itemOperations.delete = {
tags: [route.tag],
summary: `Delete ${route.sapEntitySet} by ${route.idName}`,
description: `Maps to SAP Service Layer DELETE /b1s/v1/${route.sapEntitySet}(<id>). Availability still depends on SAP object state and permissions.`,
parameters: [...sapCredentialParameters(), ...commonParameters(route)],
responses: {
"204": { description: "Deleted" }
}
};
}
paths[`${base}/{id}`] = itemOperations;
}
return {
openapi: "3.0.3",
info: {
title: "SAP Business One connector service",
version: "1.0.0",
description:
"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 || "/" }],
tags: [
{ name: "Service" },
{ name: "Session" },
...resourceRoutes.map((route) => ({ name: route.tag, description: `SAP Service Layer ${route.sapEntitySet}` }))
],
paths,
components: {
securitySchemes: {
SapB1BaseUrl: { type: "apiKey", in: "header", name: "X-SAP-B1-BaseUrl" },
SapB1CompanyDB: { type: "apiKey", in: "header", name: "X-SAP-B1-CompanyDB" },
SapB1Username: { type: "apiKey", in: "header", name: "X-SAP-B1-Username" },
SapB1Password: { type: "apiKey", in: "header", name: "X-SAP-B1-Password" }
},
schemas: {
ODataResponse: {
type: "object",
properties: {
value: { type: "array", items: { type: "object", additionalProperties: true } },
"odata.nextLink": { type: "string" },
"@odata.nextLink": { type: "string" }
}
},
SapB1Error: {
type: "object",
properties: {
error: {
type: "object",
properties: {
type: { type: "string" },
code: { type: "string" },
message: { type: "string" },
retryable: { type: "boolean" }
}
}
}
}
} }
} }
}; };
@@ -56,6 +574,18 @@ function openApiDocument(basePath = "") {
function docsHtml(basePath = "") { function docsHtml(basePath = "") {
const openApiUrl = `${basePath}/openapi.json`.replace("//", "/"); const openApiUrl = `${basePath}/openapi.json`.replace("//", "/");
const resources = resourceRoutes
.map(
(route) => `<tr>
<td>${route.tag}</td>
<td><code>GET ${basePath}/api/${route.slug}</code></td>
<td><code>GET ${basePath}/api/${route.slug}/{id}</code></td>
<td><code>POST ${basePath}/api/${route.slug}</code></td>
<td><code>PATCH ${basePath}/api/${route.slug}/{id}</code></td>
<td>${route.deleteSupported ? `<code>DELETE ${basePath}/api/${route.slug}/{id}</code>` : "not generally safe"}</td>
</tr>`
)
.join("");
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
@@ -66,42 +596,121 @@ function docsHtml(basePath = "") {
<style> <style>
body { font-family: Arial, sans-serif; margin: 2rem; line-height: 1.5; color: #1f2937; } 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; } code, pre { background: #f3f4f6; border-radius: 4px; padding: 0.2rem 0.35rem; }
a { color: #0f766e; } 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> </style>
</head> </head>
<body> <body>
<h1>SAP Business One connector</h1> <h1>SAP Business One connector</h1>
<p>This service exposes AppFactory health and OpenAPI metadata. The connector itself is used as a TypeScript library.</p> <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>
<ul> <p><a href="${openApiUrl}">OpenAPI JSON</a></p>
<li><a href="${openApiUrl}">OpenAPI JSON</a></li>
<li><code>GET ${basePath || ""}/health</code></li> <h2>SAP credentials headers</h2>
</ul> <div class="row">
<button type="button" id="try-health">Try health</button> <label class="field">Base URL
<button type="button" id="try-openapi">Try OpenAPI</button> <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> <pre id="result" aria-live="polite"></pre>
<script> <script>
const result = document.getElementById("result"); const result = document.getElementById("result");
async function show(url) { const method = document.getElementById("method");
const response = await fetch(url); const path = document.getElementById("path");
const contentType = response.headers.get("content-type") || ""; const body = document.getElementById("body");
const body = contentType.includes("application/json") ? await response.json() : await response.text(); function sapHeaders() {
result.textContent = JSON.stringify({ status: response.status, body }, null, 2); 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;
} }
document.getElementById("try-health").addEventListener("click", () => show("${basePath}/health".replace("//", "/"))); return headers;
document.getElementById("try-openapi").addEventListener("click", () => show("${openApiUrl}")); }
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> </script>
</body> </body>
</html>`; </html>`;
} }
function serviceMetadata() {
return {
name: serviceName,
service: serviceId,
status: "ok"
};
}
app.get("/", (_req, res) => { app.get("/", (_req, res) => {
res.json(serviceMetadata()); res.json(serviceMetadata());
}); });
@@ -118,6 +727,8 @@ app.get("/docs", (_req, res) => {
res.type("html").send(docsHtml(rootPath)); res.type("html").send(docsHtml(rootPath));
}); });
addResourceEndpoints();
if (rootPath) { if (rootPath) {
app.get(rootPath, (_req, res) => { app.get(rootPath, (_req, res) => {
res.json(serviceMetadata()); res.json(serviceMetadata());
@@ -134,6 +745,8 @@ if (rootPath) {
app.get(rootPath + "/docs", (_req, res) => { app.get(rootPath + "/docs", (_req, res) => {
res.type("html").send(docsHtml(rootPath)); res.type("html").send(docsHtml(rootPath));
}); });
addResourceEndpoints(rootPath);
} }
if (require.main === module) { if (require.main === module) {