upt1
This commit is contained in:
@@ -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ě
|
||||||
|
|||||||
+658
-45
@@ -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,43 +15,556 @@ 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";
|
||||||
|
|
||||||
|
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 = "") {
|
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 {
|
return {
|
||||||
openapi: "3.0.3",
|
openapi: "3.0.3",
|
||||||
info: {
|
info: {
|
||||||
title: "SAP Business One connector service",
|
title: "SAP Business One connector service",
|
||||||
version: "1.0.0",
|
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 || "/" }],
|
servers: [{ url: basePath || "/" }],
|
||||||
paths: {
|
tags: [
|
||||||
"/": {
|
{ name: "Service" },
|
||||||
get: {
|
{ name: "Session" },
|
||||||
summary: "Service metadata",
|
...resourceRoutes.map((route) => ({ name: route.tag, description: `SAP Service Layer ${route.sapEntitySet}` }))
|
||||||
responses: {
|
],
|
||||||
"200": { description: "Service status" }
|
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": {
|
schemas: {
|
||||||
get: {
|
ODataResponse: {
|
||||||
summary: "Health check",
|
type: "object",
|
||||||
responses: {
|
properties: {
|
||||||
"200": { description: "Service is ready to accept traffic" }
|
value: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
|
"odata.nextLink": { type: "string" },
|
||||||
|
"@odata.nextLink": { type: "string" }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
SapB1Error: {
|
||||||
"/openapi.json": {
|
type: "object",
|
||||||
get: {
|
properties: {
|
||||||
summary: "OpenAPI schema",
|
error: {
|
||||||
responses: {
|
type: "object",
|
||||||
"200": { description: "OpenAPI document" }
|
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;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
}
|
}
|
||||||
document.getElementById("try-health").addEventListener("click", () => show("${basePath}/health".replace("//", "/")));
|
async function sendRequest(requestMethod, requestUrl, requestBody) {
|
||||||
document.getElementById("try-openapi").addEventListener("click", () => show("${openApiUrl}"));
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user