import express, { Request, Response } from "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; sampleUpdate: Record; } const app = express(); app.set("trust proxy", true); const port = Number(process.env.PORT || 3000); 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", 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(req: Request, action: (sap: SapBusinessOneServiceLayer) => Promise): Promise { 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) { 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(req, (sap) => routeResource(sap, route).list(parseODataQuery(req))); res.json(result); }) ); app.get( `${base}/all`, asyncHandler(async (req, res) => { const result = await withSap(req, (sap) => routeResource(sap, route).listAll(parseODataQuery(req))); res.json({ value: result }); }) ); app.get( `${base}/:id`, asyncHandler(async (req, res) => { const result = await withSap(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(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) { return { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true }, example } } }; } function openApiDocument(basePath = "") { const paths: Record = { "/": { 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 = { get: { tags: [route.tag], summary: `Get ${route.sapEntitySet} by ${route.idName}`, description: `Maps to SAP Service Layer GET /b1s/v1/${route.sapEntitySet}().`, 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}().`, 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}(). 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 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//openapi.json behind the AppFactory reverse proxy. const specUrl = withRootPath("/openapi.json"); return ` SAP Business One connector API
`; } app.get("/", (_req, res) => { res.json(serviceMetadata()); }); app.get("/health", (_req, res) => { res.json({ status: "ok" }); }); app.get("/docs", (_req, res) => { res.type("html").send(renderDocsHtml()); }); // 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)); }); addResourceEndpoints(); if (require.main === module) { app.listen(port, "0.0.0.0", () => { console.log(serviceId + " listening on port " + port); }); } export { app };