Files
sap-bo/src/index.ts
T
JiriUhlir c907ed5ae9 cc upt
2026-06-29 11:13:10 +02:00

642 lines
20 KiB
TypeScript

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";
import { loadSapB1ConfigFromEnv, SapB1Config, SapB1ConfigSchema } from "./config";
import { SapB1Error } from "./errors/SapB1Error";
import { ODataQuery } from "./types/odata";
export { SessionManager, SapB1Session } from "./auth/SessionManager";
export { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
export { buildODataParams, extractNextLink } from "./client/odata";
export { loadSapB1ConfigFromEnv, SapB1Config, SapB1ConfigSchema } from "./config";
export { SapB1Error } from "./errors/SapB1Error";
export { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer";
export * from "./types/entities";
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 port = Number(process.env.PORT || 3000);
const rootPath = process.env.ROOT_PATH || "";
const serviceName = "SAP Business One";
const serviceId = "sap-bo";
const resourceRoutes: ResourceRoute[] = [
{
slug: "business-partners",
tag: "BusinessPartners",
property: "businessPartners",
sapEntitySet: "BusinessPartners",
idName: "CardCode",
idType: "string",
deleteSupported: true,
sampleCreate: { CardCode: "C001", CardName: "Example customer", CardType: "cCustomer" },
sampleUpdate: { CardName: "Updated customer name" }
},
{
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: {
tags: ["Service"],
summary: "Service metadata",
responses: {
"200": { description: "Service status" }
}
}
},
"/health": {
get: {
tags: ["Service"],
summary: "Health check",
responses: {
"200": { description: "Service is ready to accept traffic" }
}
}
},
"/openapi.json": {
get: {
tags: ["Service"],
summary: "OpenAPI schema",
responses: {
"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 || "/" }],
security: [
{
SapB1BaseUrl: [],
SapB1CompanyDB: [],
SapB1Username: [],
SapB1Password: []
}
],
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" }
}
}
}
}
}
}
};
}
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,
persistAuthorization: true,
tryItOutEnabled: true,
filter: true,
tagsSorter: "alpha",
operationsSorter: "method"
}
};
}
app.get("/", (_req, res) => {
res.json(serviceMetadata());
});
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)));
});
// Documented root alias for direct access to the OpenAPI document.
app.get("/openapi.json", (req, res) => {
res.json(openApiDocument(resolveBasePath(req)));
});
app.use("/docs", swaggerUi.serve, swaggerUi.setup(undefined, swaggerUiOptions()));
addResourceEndpoints();
if (require.main === module) {
app.listen(port, "0.0.0.0", () => {
console.log(serviceId + " listening on port " + port);
});
}
export { app };