From a7aedfce10305059f52b56be06631769940c1158 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:55:04 +0200 Subject: [PATCH] upt1 --- README.md | 32 +- .../sap-business-one-service-layer.md | 15 +- src/index.ts | 703 ++++++++++++++++-- 3 files changed, 702 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 9230ee6..2050a0e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ Součástí repozitáře je i malý AppFactory HTTP wrapper s endpointy: - `GET /health` - `GET /docs` - `GET /openapi.json` +- `POST /api/session/login` +- `POST /api/session/logout` +- `GET /api/` +- `GET /api//all` +- `GET /api//{id}` +- `POST /api/` +- `PATCH /api//{id}` +- `DELETE /api//{id}` pouze pro obecně mazatelné resource ## Instalace @@ -17,7 +25,7 @@ npm run build npm test ``` -## Konfigurace +## Konfigurace knihovny 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`. +## 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: +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í ### Login a logout @@ -158,7 +184,9 @@ Pokryté oblasti: ## 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 diff --git a/documentation/sap-business-one-service-layer.md b/documentation/sap-business-one-service-layer.md index 89f3dd6..ffccbaf 100644 --- a/documentation/sap-business-one-service-layer.md +++ b/documentation/sap-business-one-service-layer.md @@ -7,6 +7,7 @@ Hlavní vlastnosti: - login/logout přes Service Layer - správa `B1SESSION` a `ROUTEID` - 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` - stránkování přes `odata.nextLink` a `@odata.nextLink` - retry pro dočasné chyby @@ -14,7 +15,19 @@ Hlavní vlastnosti: 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 a session tokeny se nevrací v běžných response - 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 + +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ě diff --git a/src/index.ts b/src/index.ts index a722d3d..83ab4eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ -import express from "express"; +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"; @@ -10,43 +15,556 @@ 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(); 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(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: "Health and documentation wrapper for the SAP Business One Service Layer TypeScript connector." + 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 || "/" }], - paths: { - "/": { - get: { - summary: "Service metadata", - responses: { - "200": { description: "Service status" } - } - } + 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" } }, - "/health": { - get: { - summary: "Health check", - responses: { - "200": { description: "Service is ready to accept traffic" } + schemas: { + ODataResponse: { + type: "object", + properties: { + value: { type: "array", items: { type: "object", additionalProperties: true } }, + "odata.nextLink": { type: "string" }, + "@odata.nextLink": { type: "string" } } - } - }, - "/openapi.json": { - get: { - summary: "OpenAPI schema", - responses: { - "200": { description: "OpenAPI document" } + }, + 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 = "") { const openApiUrl = `${basePath}/openapi.json`.replace("//", "/"); + const resources = resourceRoutes + .map( + (route) => ` + ${route.tag} + GET ${basePath}/api/${route.slug} + GET ${basePath}/api/${route.slug}/{id} + POST ${basePath}/api/${route.slug} + PATCH ${basePath}/api/${route.slug}/{id} + ${route.deleteSupported ? `DELETE ${basePath}/api/${route.slug}/{id}` : "not generally safe"} + ` + ) + .join(""); return ` @@ -66,42 +596,121 @@ function docsHtml(basePath = "") {

SAP Business One connector

-

This service exposes AppFactory health and OpenAPI metadata. The connector itself is used as a TypeScript library.

- - - +

HTTP API over SAP Business One Service Layer. SAP credentials are supplied in X-SAP-B1-* request headers and are never logged or returned.

+

OpenAPI JSON

+ +

SAP credentials headers

+
+ + + + + + + +
+ +

Session

+ + + + +

Resources

+ + + + + ${resources} +
ResourceListGetCreateUpdateDelete
+ +

Try request

+
+ + + +
+ +

+
     
   
 `;
 }
 
-function serviceMetadata() {
-  return {
-    name: serviceName,
-    service: serviceId,
-    status: "ok"
-  };
-}
-
 app.get("/", (_req, res) => {
   res.json(serviceMetadata());
 });
@@ -118,6 +727,8 @@ app.get("/docs", (_req, res) => {
   res.type("html").send(docsHtml(rootPath));
 });
 
+addResourceEndpoints();
+
 if (rootPath) {
   app.get(rootPath, (_req, res) => {
     res.json(serviceMetadata());
@@ -134,6 +745,8 @@ if (rootPath) {
   app.get(rootPath + "/docs", (_req, res) => {
     res.type("html").send(docsHtml(rootPath));
   });
+
+  addResourceEndpoints(rootPath);
 }
 
 if (require.main === module) {