first
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
SAP_B1_BASE_URL=https://sap.example.local:50000
|
||||
SAP_B1_COMPANY_DB=SBODEMOUS
|
||||
SAP_B1_USERNAME=manager
|
||||
SAP_B1_PASSWORD=change-me
|
||||
SAP_B1_LANGUAGE=
|
||||
SAP_B1_TIMEOUT_MS=30000
|
||||
SAP_B1_REJECT_UNAUTHORIZED=true
|
||||
SAP_B1_RETRY_COUNT=2
|
||||
SAP_B1_RETRY_DELAY_MS=250
|
||||
PORT=3000
|
||||
ROOT_PATH=/apps/sap-bo
|
||||
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.env
|
||||
coverage/
|
||||
@@ -1,8 +1,168 @@
|
||||
# SAP Business One
|
||||
# SAP Business One Service Layer Connector
|
||||
|
||||
Node.js TypeScript služba vytvořená přes CSBot Services Portal.
|
||||
Produkční Node.js + TypeScript connector pro SAP Business One Service Layer REST/OData API. Používá pouze Service Layer (`/b1s/v1`), ne SAP DI API.
|
||||
|
||||
## Endpointy
|
||||
Součástí repozitáře je i malý AppFactory HTTP wrapper s endpointy:
|
||||
|
||||
- GET /
|
||||
- GET /health
|
||||
- `GET /`
|
||||
- `GET /health`
|
||||
- `GET /docs`
|
||||
- `GET /openapi.json`
|
||||
|
||||
## Instalace
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
## Konfigurace
|
||||
|
||||
Vytvoř `.env` podle `.env.example`:
|
||||
|
||||
```env
|
||||
SAP_B1_BASE_URL=https://sap.example.local:50000
|
||||
SAP_B1_COMPANY_DB=SBODEMOUS
|
||||
SAP_B1_USERNAME=manager
|
||||
SAP_B1_PASSWORD=change-me
|
||||
SAP_B1_LANGUAGE=
|
||||
SAP_B1_TIMEOUT_MS=30000
|
||||
SAP_B1_REJECT_UNAUTHORIZED=true
|
||||
SAP_B1_RETRY_COUNT=2
|
||||
SAP_B1_RETRY_DELAY_MS=250
|
||||
```
|
||||
|
||||
`SAP_B1_BASE_URL` může být buď root Service Layer hostu, nebo přímo URL končící `/b1s/v1`. Hesla ani session tokeny se nelogují.
|
||||
|
||||
Pro self-signed certifikáty lze v interním prostředí nastavit:
|
||||
|
||||
```env
|
||||
SAP_B1_REJECT_UNAUTHORIZED=false
|
||||
```
|
||||
|
||||
V produkci preferuj důvěryhodný certifikát a ponech `true`.
|
||||
|
||||
## Použití
|
||||
|
||||
### Login a logout
|
||||
|
||||
```ts
|
||||
import { SapBusinessOneServiceLayer, loadSapB1ConfigFromEnv } from "./src";
|
||||
|
||||
const sap = new SapBusinessOneServiceLayer(loadSapB1ConfigFromEnv());
|
||||
|
||||
await sap.login();
|
||||
await sap.logout();
|
||||
```
|
||||
|
||||
Login volá `POST /b1s/v1/Login`, uloží cookies `B1SESSION` a `ROUTEID` a posílá je v dalších requestech. Při expiraci session a odpovědi `401` connector jednou provede re-login a request zopakuje.
|
||||
|
||||
### Vypsání Business Partners
|
||||
|
||||
```ts
|
||||
const partners = await sap.businessPartners.list({
|
||||
select: ["CardCode", "CardName", "CardType"],
|
||||
filter: "CardType eq 'cCustomer'",
|
||||
top: 50,
|
||||
orderby: "CardName asc"
|
||||
});
|
||||
|
||||
console.log(partners.value);
|
||||
```
|
||||
|
||||
### Načtení všech záznamů přes nextLink
|
||||
|
||||
```ts
|
||||
const allItems = await sap.items.listAll({
|
||||
select: ["ItemCode", "ItemName"],
|
||||
top: 100
|
||||
});
|
||||
```
|
||||
|
||||
Connector podporuje starší `odata.nextLink` i novější `@odata.nextLink`.
|
||||
|
||||
### Vytvoření objednávky
|
||||
|
||||
```ts
|
||||
const order = await sap.orders.create({
|
||||
CardCode: "C001",
|
||||
DocDueDate: "2026-07-15",
|
||||
DocumentLines: [
|
||||
{
|
||||
ItemCode: "A00001",
|
||||
Quantity: 2,
|
||||
UnitPrice: 100
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Aktualizace položky
|
||||
|
||||
```ts
|
||||
await sap.items.update("A00001", {
|
||||
ItemName: "Updated item name"
|
||||
});
|
||||
```
|
||||
|
||||
## Resource moduly
|
||||
|
||||
Implementované resource moduly:
|
||||
|
||||
- `businessPartners`
|
||||
- `items`
|
||||
- `orders`
|
||||
- `invoices`
|
||||
- `purchaseOrders`
|
||||
- `deliveryNotes`
|
||||
- `stockTransfers`
|
||||
|
||||
Každý modul má:
|
||||
|
||||
- `list(query?)`
|
||||
- `listAll(query?)`
|
||||
- `get(id)`
|
||||
- `create(data)`
|
||||
- `update(id, data)`
|
||||
- `delete(id)` pouze tam, kde je povolené mazání
|
||||
|
||||
U marketing dokumentů (`Orders`, `Invoices`, `PurchaseOrders`, `DeliveryNotes`) a skladových převodek je delete v connectoru záměrně blokovaný. SAP Business One obvykle řeší rušení dokumentů storno/cancel operacemi podle typu dokladu a nastavení firmy.
|
||||
|
||||
## Zpracování chyb
|
||||
|
||||
Connector převádí chyby na `SapB1Error`:
|
||||
|
||||
- `status` HTTP status
|
||||
- `code` SAP error code, pokud jej Service Layer vrátí
|
||||
- `message` bezpečná chybová zpráva
|
||||
- `retryable` příznak pro dočasné chyby
|
||||
|
||||
Retry se používá pro `408`, `429` a `5xx`. Citlivé hodnoty jako heslo, cookies a session tokeny se při logování redigují.
|
||||
|
||||
## Testy
|
||||
|
||||
Testy používají mockované HTTP odpovědi přes axios adapter nebo fake klienty. Nevolají reálný SAP.
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Pokryté oblasti:
|
||||
|
||||
- autentizace a cookies
|
||||
- logout
|
||||
- automatický re-login po `401`
|
||||
- OData query parametry
|
||||
- resource URL builder
|
||||
|
||||
## 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`.
|
||||
|
||||
## TODO ověřit v konkrétní instalaci SAP Business One
|
||||
|
||||
- Přesné enum hodnoty a povinná pole pro jednotlivé entity se mohou lišit podle lokalizace, add-onů a verze SAP Business One.
|
||||
- U rušení dokladů ověř konkrétní Service Layer akce dostupné pro daný typ dokladu a firemní nastavení.
|
||||
- U velkých datasetů ověř server-side limity stránkování a maximální povolené `$top`.
|
||||
- Ověř, zda konkrétní instalace vrací OData metadata ve starším formátu `odata.*` nebo novějším `@odata.*`.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# SAP Business One Service Layer Connector
|
||||
|
||||
Connector komunikuje výhradně přes SAP Business One Service Layer `/b1s/v1`.
|
||||
|
||||
Hlavní vlastnosti:
|
||||
|
||||
- login/logout přes Service Layer
|
||||
- správa `B1SESSION` a `ROUTEID`
|
||||
- automatický re-login po expiraci session
|
||||
- OData parametry `$select`, `$filter`, `$top`, `$skip`, `$orderby`
|
||||
- stránkování přes `odata.nextLink` a `@odata.nextLink`
|
||||
- retry pro dočasné chyby
|
||||
- zod validace konfigurace a hlavních response tvarů
|
||||
|
||||
Bezpečnostní pravidla:
|
||||
|
||||
- hesla se čtou pouze z environment variables
|
||||
- hesla, cookies a session tokeny se nelogují
|
||||
- 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
|
||||
Generated
+2431
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -1,16 +1,25 @@
|
||||
{
|
||||
"name": "sap-bo",
|
||||
"version": "1.0.0",
|
||||
"description": "Production-ready SAP Business One Service Layer connector for Node.js and TypeScript.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.3"
|
||||
"axios": "^1.7.9",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.18.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.30",
|
||||
"typescript": "^5.4.0"
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SapB1Client, SapB1ClientOptions } from "./client/SapB1Client";
|
||||
import { SapB1Config } from "./config";
|
||||
import { BusinessPartnersResource } from "./resources/BusinessPartners";
|
||||
import { DeliveryNotesResource, InvoicesResource, OrdersResource, PurchaseOrdersResource } from "./resources/Documents";
|
||||
import { ItemsResource } from "./resources/Items";
|
||||
import { StockTransfersResource } from "./resources/StockTransfers";
|
||||
|
||||
export class SapBusinessOneServiceLayer {
|
||||
public readonly client: SapB1Client;
|
||||
public readonly businessPartners: BusinessPartnersResource;
|
||||
public readonly items: ItemsResource;
|
||||
public readonly orders: OrdersResource;
|
||||
public readonly invoices: InvoicesResource;
|
||||
public readonly purchaseOrders: PurchaseOrdersResource;
|
||||
public readonly deliveryNotes: DeliveryNotesResource;
|
||||
public readonly stockTransfers: StockTransfersResource;
|
||||
|
||||
public constructor(config: SapB1Config, options: SapB1ClientOptions = {}) {
|
||||
this.client = new SapB1Client(config, options);
|
||||
this.businessPartners = new BusinessPartnersResource(this.client);
|
||||
this.items = new ItemsResource(this.client);
|
||||
this.orders = new OrdersResource(this.client);
|
||||
this.invoices = new InvoicesResource(this.client);
|
||||
this.purchaseOrders = new PurchaseOrdersResource(this.client);
|
||||
this.deliveryNotes = new DeliveryNotesResource(this.client);
|
||||
this.stockTransfers = new StockTransfersResource(this.client);
|
||||
}
|
||||
|
||||
public login(): Promise<unknown> {
|
||||
return this.client.login();
|
||||
}
|
||||
|
||||
public logout(): Promise<void> {
|
||||
return this.client.logout();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import "../client/axiosTypes";
|
||||
import { AxiosInstance } from "axios";
|
||||
import { z } from "zod";
|
||||
import { SapB1Config } from "../config";
|
||||
import { SapB1Error } from "../errors/SapB1Error";
|
||||
import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger";
|
||||
|
||||
const LoginResponseSchema = z.object({
|
||||
SessionId: z.string().optional(),
|
||||
Version: z.string().optional(),
|
||||
SessionTimeout: z.number().optional()
|
||||
});
|
||||
|
||||
export interface SapB1Session {
|
||||
b1session: string;
|
||||
routeId?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private session?: SapB1Session;
|
||||
private loginPromise?: Promise<SapB1Session>;
|
||||
|
||||
public constructor(
|
||||
private readonly http: AxiosInstance,
|
||||
private readonly config: SapB1Config,
|
||||
private readonly logger: SapB1Logger = noopLogger
|
||||
) {}
|
||||
|
||||
public get currentSession(): SapB1Session | undefined {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
public async getSession(forceRefresh = false): Promise<SapB1Session> {
|
||||
if (!forceRefresh && this.session && !this.isExpired(this.session)) {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
if (!this.loginPromise) {
|
||||
this.loginPromise = this.login().finally(() => {
|
||||
this.loginPromise = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return this.loginPromise;
|
||||
}
|
||||
|
||||
public async login(): Promise<SapB1Session> {
|
||||
this.logger.info("SAP B1 login request", sanitizeLogMeta({ baseUrl: this.config.baseUrl, companyDB: this.config.companyDB }));
|
||||
|
||||
try {
|
||||
const response = await this.http.post(
|
||||
"/Login",
|
||||
{
|
||||
CompanyDB: this.config.companyDB,
|
||||
UserName: this.config.username,
|
||||
Password: this.config.password,
|
||||
Language: this.config.language
|
||||
},
|
||||
{ skipAuth: true }
|
||||
);
|
||||
|
||||
LoginResponseSchema.parse(response.data);
|
||||
const cookies = this.extractCookies(response.headers["set-cookie"]);
|
||||
const b1session = cookies.B1SESSION || response.data.SessionId;
|
||||
|
||||
if (!b1session) {
|
||||
throw new SapB1Error({ message: "SAP B1 login did not return B1SESSION cookie or SessionId" });
|
||||
}
|
||||
|
||||
const timeoutMinutes = typeof response.data.SessionTimeout === "number" ? response.data.SessionTimeout : 30;
|
||||
this.session = {
|
||||
b1session,
|
||||
routeId: cookies.ROUTEID,
|
||||
expiresAt: Date.now() + Math.max(timeoutMinutes - 1, 1) * 60_000
|
||||
};
|
||||
|
||||
return this.session;
|
||||
} catch (error) {
|
||||
if (error instanceof SapB1Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new SapB1Error({ message: "SAP B1 login failed", cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
if (!this.session) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.http.post("/Logout", undefined, { skipAuth: false });
|
||||
} finally {
|
||||
this.session = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public buildCookieHeader(session: SapB1Session): string {
|
||||
const parts = [`B1SESSION=${session.b1session}`];
|
||||
|
||||
if (session.routeId) {
|
||||
parts.push(`ROUTEID=${session.routeId}`);
|
||||
}
|
||||
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
public clearSession(): void {
|
||||
this.session = undefined;
|
||||
}
|
||||
|
||||
private isExpired(session: SapB1Session): boolean {
|
||||
return session.expiresAt !== undefined && session.expiresAt <= Date.now();
|
||||
}
|
||||
|
||||
private extractCookies(setCookieHeader: string[] | string | undefined): Record<string, string> {
|
||||
const headers = Array.isArray(setCookieHeader) ? setCookieHeader : setCookieHeader ? [setCookieHeader] : [];
|
||||
const cookies: Record<string, string> = {};
|
||||
|
||||
for (const header of headers) {
|
||||
const [nameValue] = header.split(";");
|
||||
const separatorIndex = nameValue.indexOf("=");
|
||||
|
||||
if (separatorIndex > 0) {
|
||||
cookies[nameValue.slice(0, separatorIndex)] = nameValue.slice(separatorIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import "./axiosTypes";
|
||||
import https from "node:https";
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, Method } from "axios";
|
||||
import { z } from "zod";
|
||||
import { SessionManager } from "../auth/SessionManager";
|
||||
import { SapB1Config, SapB1ConfigSchema } from "../config";
|
||||
import { SapB1Error } from "../errors/SapB1Error";
|
||||
import { ODataQuery, ODataResponse } from "../types/odata";
|
||||
import { buildODataParams, extractNextLink } from "./odata";
|
||||
import { SapB1Logger, noopLogger, sanitizeLogMeta } from "../utils/logger";
|
||||
import { sleep } from "../utils/sleep";
|
||||
|
||||
export interface SapB1ClientOptions {
|
||||
logger?: SapB1Logger;
|
||||
http?: AxiosInstance;
|
||||
}
|
||||
|
||||
export class SapB1Client {
|
||||
private readonly http: AxiosInstance;
|
||||
private readonly logger: SapB1Logger;
|
||||
public readonly sessionManager: SessionManager;
|
||||
public readonly config: SapB1Config;
|
||||
|
||||
public constructor(config: SapB1Config, options: SapB1ClientOptions = {}) {
|
||||
this.config = SapB1ConfigSchema.parse(config);
|
||||
this.logger = options.logger ?? noopLogger;
|
||||
this.http =
|
||||
options.http ??
|
||||
axios.create({
|
||||
baseURL: this.buildServiceLayerBaseUrl(this.config.baseUrl),
|
||||
timeout: this.config.timeout,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: this.config.rejectUnauthorized })
|
||||
});
|
||||
this.sessionManager = new SessionManager(this.http, this.config, this.logger);
|
||||
}
|
||||
|
||||
public login(): Promise<unknown> {
|
||||
return this.sessionManager.getSession(true);
|
||||
}
|
||||
|
||||
public logout(): Promise<void> {
|
||||
return this.sessionManager.logout();
|
||||
}
|
||||
|
||||
public get<T>(path: string, query?: ODataQuery, schema?: z.ZodType<T>): Promise<T> {
|
||||
return this.request<T>("GET", path, undefined, { params: buildODataParams(query), schema });
|
||||
}
|
||||
|
||||
public post<T>(path: string, data?: unknown, schema?: z.ZodType<T>): Promise<T> {
|
||||
return this.request<T>("POST", path, data, { schema });
|
||||
}
|
||||
|
||||
public patch<T>(path: string, data?: unknown, schema?: z.ZodType<T>): Promise<T> {
|
||||
return this.request<T>("PATCH", path, data, { schema });
|
||||
}
|
||||
|
||||
public delete<T>(path: string, schema?: z.ZodType<T>): Promise<T> {
|
||||
return this.request<T>("DELETE", path, undefined, { schema });
|
||||
}
|
||||
|
||||
public async getAll<T>(path: string, query: ODataQuery = {}, schema?: z.ZodType<T>): Promise<T[]> {
|
||||
const items: T[] = [];
|
||||
let nextPath: string | undefined = path;
|
||||
let nextQuery: ODataQuery | undefined = query;
|
||||
|
||||
while (nextPath) {
|
||||
const response: ODataResponse<T> = await this.get<ODataResponse<T>>(nextPath, nextQuery);
|
||||
const value = schema ? z.array(schema).parse(response.value) : response.value;
|
||||
items.push(...value);
|
||||
const nextLink: string | undefined = extractNextLink(response);
|
||||
nextPath = nextLink ? this.normalizeNextLink(nextLink) : undefined;
|
||||
nextQuery = undefined;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
data?: unknown,
|
||||
options: AxiosRequestConfig & { schema?: z.ZodType<T> } = {}
|
||||
): Promise<T> {
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
...options,
|
||||
method,
|
||||
url: path,
|
||||
data,
|
||||
headers: {
|
||||
...(options.headers || {})
|
||||
}
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt <= this.config.retryCount; attempt += 1) {
|
||||
try {
|
||||
if (!requestConfig.skipAuth) {
|
||||
const session = await this.sessionManager.getSession();
|
||||
requestConfig.headers = {
|
||||
...(requestConfig.headers || {}),
|
||||
Cookie: this.sessionManager.buildCookieHeader(session)
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug("SAP B1 request", sanitizeLogMeta({ method, url: path, attempt }));
|
||||
const response = await this.http.request(requestConfig);
|
||||
return options.schema ? options.schema.parse(response.data) : (response.data as T);
|
||||
} catch (error) {
|
||||
const sapError = this.toSapB1Error(error, method, path);
|
||||
const canRelogin = sapError.status === 401 && !requestConfig.skipAuth && attempt === 0;
|
||||
|
||||
if (canRelogin) {
|
||||
this.sessionManager.clearSession();
|
||||
await this.sessionManager.getSession(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!requestConfig.skipRetry && sapError.retryable && attempt < this.config.retryCount) {
|
||||
await sleep(this.config.retryDelayMs * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw sapError;
|
||||
}
|
||||
}
|
||||
|
||||
throw new SapB1Error({ message: "SAP B1 request failed after retries", method, url: path });
|
||||
}
|
||||
|
||||
private toSapB1Error(error: unknown, method: Method, path: string): SapB1Error {
|
||||
if (error instanceof SapB1Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError<{ error?: { code?: string; message?: { value?: string } | string } }>;
|
||||
const status = axiosError.response?.status;
|
||||
const sapError = axiosError.response?.data?.error;
|
||||
const message =
|
||||
typeof sapError?.message === "string"
|
||||
? sapError.message
|
||||
: sapError?.message?.value || axiosError.message || "SAP B1 request failed";
|
||||
|
||||
return new SapB1Error({
|
||||
status,
|
||||
code: sapError?.code,
|
||||
message,
|
||||
method,
|
||||
url: path,
|
||||
retryable: status === 408 || status === 429 || (status !== undefined && status >= 500),
|
||||
cause: error
|
||||
});
|
||||
}
|
||||
|
||||
return new SapB1Error({ message: "SAP B1 request failed", method, url: path, cause: error });
|
||||
}
|
||||
|
||||
private buildServiceLayerBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return trimmed.endsWith("/b1s/v1") ? trimmed : `${trimmed}/b1s/v1`;
|
||||
}
|
||||
|
||||
private normalizeNextLink(nextLink: string): string {
|
||||
try {
|
||||
const parsed = new URL(nextLink);
|
||||
return parsed.pathname.replace(/^.*\/b1s\/v1\/?/, "");
|
||||
} catch {
|
||||
return nextLink.replace(/^\/?b1s\/v1\/?/, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "axios";
|
||||
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
skipAuth?: boolean;
|
||||
skipRetry?: boolean;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ODataQuery, ODataQuerySchema } from "../types/odata";
|
||||
|
||||
export function buildODataParams(query: ODataQuery = {}): Record<string, string | number> {
|
||||
const parsed = ODataQuerySchema.parse(query);
|
||||
const params: Record<string, string | number> = {};
|
||||
|
||||
if (parsed.select?.length) {
|
||||
params.$select = parsed.select.join(",");
|
||||
}
|
||||
|
||||
if (parsed.filter) {
|
||||
params.$filter = parsed.filter;
|
||||
}
|
||||
|
||||
if (parsed.top !== undefined) {
|
||||
params.$top = parsed.top;
|
||||
}
|
||||
|
||||
if (parsed.skip !== undefined) {
|
||||
params.$skip = parsed.skip;
|
||||
}
|
||||
|
||||
if (parsed.orderby) {
|
||||
params.$orderby = Array.isArray(parsed.orderby) ? parsed.orderby.join(",") : parsed.orderby;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function extractNextLink<T>(response: {
|
||||
"odata.nextLink"?: string;
|
||||
"@odata.nextLink"?: string;
|
||||
}): string | undefined {
|
||||
return response["@odata.nextLink"] || response["odata.nextLink"];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import "dotenv/config";
|
||||
import { z } from "zod";
|
||||
|
||||
const booleanFromEnv = z
|
||||
.union([z.boolean(), z.string()])
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === undefined || value === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
||||
});
|
||||
|
||||
export const SapB1ConfigSchema = z.object({
|
||||
baseUrl: z.string().url(),
|
||||
companyDB: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
language: z.string().min(1).optional(),
|
||||
timeout: z.number().int().positive().default(30_000),
|
||||
rejectUnauthorized: z.boolean().default(true),
|
||||
retryCount: z.number().int().min(0).default(2),
|
||||
retryDelayMs: z.number().int().min(0).default(250)
|
||||
});
|
||||
|
||||
export type SapB1Config = z.infer<typeof SapB1ConfigSchema>;
|
||||
|
||||
export function loadSapB1ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): SapB1Config {
|
||||
const rejectUnauthorized = booleanFromEnv.parse(env.SAP_B1_REJECT_UNAUTHORIZED);
|
||||
|
||||
return SapB1ConfigSchema.parse({
|
||||
baseUrl: env.SAP_B1_BASE_URL,
|
||||
companyDB: env.SAP_B1_COMPANY_DB,
|
||||
username: env.SAP_B1_USERNAME,
|
||||
password: env.SAP_B1_PASSWORD,
|
||||
language: env.SAP_B1_LANGUAGE || undefined,
|
||||
timeout: env.SAP_B1_TIMEOUT_MS ? Number(env.SAP_B1_TIMEOUT_MS) : undefined,
|
||||
rejectUnauthorized,
|
||||
retryCount: env.SAP_B1_RETRY_COUNT ? Number(env.SAP_B1_RETRY_COUNT) : undefined,
|
||||
retryDelayMs: env.SAP_B1_RETRY_DELAY_MS ? Number(env.SAP_B1_RETRY_DELAY_MS) : undefined
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface SapB1ErrorDetails {
|
||||
status?: number;
|
||||
code?: string;
|
||||
message?: string;
|
||||
method?: string;
|
||||
url?: string;
|
||||
retryable?: boolean;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class SapB1Error extends Error {
|
||||
public readonly status?: number;
|
||||
public readonly code?: string;
|
||||
public readonly method?: string;
|
||||
public readonly url?: string;
|
||||
public readonly retryable: boolean;
|
||||
public override readonly cause?: unknown;
|
||||
|
||||
public constructor(details: SapB1ErrorDetails) {
|
||||
super(details.message || "SAP Business One Service Layer request failed");
|
||||
this.name = "SapB1Error";
|
||||
this.status = details.status;
|
||||
this.code = details.code;
|
||||
this.method = details.method;
|
||||
this.url = details.url;
|
||||
this.retryable = details.retryable ?? false;
|
||||
this.cause = details.cause;
|
||||
}
|
||||
}
|
||||
+123
-13
@@ -1,35 +1,145 @@
|
||||
import express from "express";
|
||||
import { SapBusinessOneServiceLayer } from "./SapBusinessOneServiceLayer";
|
||||
|
||||
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";
|
||||
|
||||
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";
|
||||
|
||||
function openApiDocument(basePath = "") {
|
||||
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."
|
||||
},
|
||||
servers: [{ url: basePath || "/" }],
|
||||
paths: {
|
||||
"/": {
|
||||
get: {
|
||||
summary: "Service metadata",
|
||||
responses: {
|
||||
"200": { description: "Service status" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
get: {
|
||||
summary: "Health check",
|
||||
responses: {
|
||||
"200": { description: "Service is ready to accept traffic" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.json": {
|
||||
get: {
|
||||
summary: "OpenAPI schema",
|
||||
responses: {
|
||||
"200": { description: "OpenAPI document" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function docsHtml(basePath = "") {
|
||||
const openApiUrl = `${basePath}/openapi.json`.replace("//", "/");
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SAP Business One connector docs</title>
|
||||
<style>
|
||||
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; }
|
||||
a { color: #0f766e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<ul>
|
||||
<li><a href="${openApiUrl}">OpenAPI JSON</a></li>
|
||||
<li><code>GET ${basePath || ""}/health</code></li>
|
||||
</ul>
|
||||
<button type="button" id="try-health">Try health</button>
|
||||
<button type="button" id="try-openapi">Try OpenAPI</button>
|
||||
<pre id="result" aria-live="polite"></pre>
|
||||
<script>
|
||||
const result = document.getElementById("result");
|
||||
async function show(url) {
|
||||
const response = await fetch(url);
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const body = contentType.includes("application/json") ? await response.json() : await response.text();
|
||||
result.textContent = JSON.stringify({ status: response.status, body }, null, 2);
|
||||
}
|
||||
document.getElementById("try-health").addEventListener("click", () => show("${basePath}/health".replace("//", "/")));
|
||||
document.getElementById("try-openapi").addEventListener("click", () => show("${openApiUrl}"));
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function serviceMetadata() {
|
||||
return {
|
||||
name: serviceName,
|
||||
service: serviceId,
|
||||
status: "ok"
|
||||
};
|
||||
}
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.json({
|
||||
name: "SAP Business One",
|
||||
service: "sap-bo",
|
||||
status: "ok"
|
||||
});
|
||||
res.json(serviceMetadata());
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
app.get("/openapi.json", (_req, res) => {
|
||||
res.json(openApiDocument(rootPath));
|
||||
});
|
||||
|
||||
app.get("/docs", (_req, res) => {
|
||||
res.type("html").send(docsHtml(rootPath));
|
||||
});
|
||||
|
||||
if (rootPath) {
|
||||
app.get(rootPath, (_req, res) => {
|
||||
res.json({
|
||||
name: "SAP Business One",
|
||||
service: "sap-bo",
|
||||
status: "ok"
|
||||
});
|
||||
res.json(serviceMetadata());
|
||||
});
|
||||
|
||||
app.get(rootPath + "/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
app.get(rootPath + "/openapi.json", (_req, res) => {
|
||||
res.json(openApiDocument(rootPath));
|
||||
});
|
||||
|
||||
app.get(rootPath + "/docs", (_req, res) => {
|
||||
res.type("html").send(docsHtml(rootPath));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(port, "0.0.0.0", () => {
|
||||
console.log("sap-bo listening on port " + port);
|
||||
});
|
||||
if (require.main === module) {
|
||||
app.listen(port, "0.0.0.0", () => {
|
||||
console.log(serviceId + " listening on port " + port);
|
||||
});
|
||||
}
|
||||
|
||||
export { app };
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SapB1Client } from "../client/SapB1Client";
|
||||
import { BusinessPartner, BusinessPartnerCreateDto, BusinessPartnerUpdateDto } from "../types/entities";
|
||||
import { BusinessPartnerSchema } from "../types/schemas";
|
||||
import { Resource } from "./Resource";
|
||||
|
||||
export class BusinessPartnersResource extends Resource<
|
||||
BusinessPartner,
|
||||
BusinessPartnerCreateDto,
|
||||
BusinessPartnerUpdateDto,
|
||||
string
|
||||
> {
|
||||
public constructor(client: SapB1Client) {
|
||||
super(client, "BusinessPartners", BusinessPartnerSchema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SapB1Client } from "../client/SapB1Client";
|
||||
import { MarketingDocument, MarketingDocumentCreateDto, MarketingDocumentUpdateDto } from "../types/entities";
|
||||
import { MarketingDocumentSchema } from "../types/schemas";
|
||||
import { Resource } from "./Resource";
|
||||
|
||||
export class OrdersResource extends Resource<MarketingDocument, MarketingDocumentCreateDto, MarketingDocumentUpdateDto, number> {
|
||||
public constructor(client: SapB1Client) {
|
||||
super(client, "Orders", MarketingDocumentSchema, false);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvoicesResource extends Resource<MarketingDocument, MarketingDocumentCreateDto, MarketingDocumentUpdateDto, number> {
|
||||
public constructor(client: SapB1Client) {
|
||||
super(client, "Invoices", MarketingDocumentSchema, false);
|
||||
}
|
||||
}
|
||||
|
||||
export class PurchaseOrdersResource extends Resource<
|
||||
MarketingDocument,
|
||||
MarketingDocumentCreateDto,
|
||||
MarketingDocumentUpdateDto,
|
||||
number
|
||||
> {
|
||||
public constructor(client: SapB1Client) {
|
||||
super(client, "PurchaseOrders", MarketingDocumentSchema, false);
|
||||
}
|
||||
}
|
||||
|
||||
export class DeliveryNotesResource extends Resource<
|
||||
MarketingDocument,
|
||||
MarketingDocumentCreateDto,
|
||||
MarketingDocumentUpdateDto,
|
||||
number
|
||||
> {
|
||||
public constructor(client: SapB1Client) {
|
||||
super(client, "DeliveryNotes", MarketingDocumentSchema, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SapB1Client } from "../client/SapB1Client";
|
||||
import { Item, ItemCreateDto, ItemUpdateDto } from "../types/entities";
|
||||
import { ItemSchema } from "../types/schemas";
|
||||
import { Resource } from "./Resource";
|
||||
|
||||
export class ItemsResource extends Resource<Item, ItemCreateDto, ItemUpdateDto, string> {
|
||||
public constructor(client: SapB1Client) {
|
||||
super(client, "Items", ItemSchema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod";
|
||||
import { SapB1Client } from "../client/SapB1Client";
|
||||
import { ODataQuery, ODataResponse } from "../types/odata";
|
||||
|
||||
export class Resource<TEntity, TCreate, TUpdate, TKey extends string | number = string | number> {
|
||||
public constructor(
|
||||
protected readonly client: SapB1Client,
|
||||
protected readonly path: string,
|
||||
protected readonly schema: z.ZodType<TEntity>,
|
||||
private readonly supportsDelete = true
|
||||
) {}
|
||||
|
||||
public list(query: ODataQuery = {}): Promise<ODataResponse<TEntity>> {
|
||||
return this.client.get<ODataResponse<TEntity>>(this.path, query);
|
||||
}
|
||||
|
||||
public listAll(query: ODataQuery = {}): Promise<TEntity[]> {
|
||||
return this.client.getAll<TEntity>(this.path, query, this.schema);
|
||||
}
|
||||
|
||||
public get(id: TKey): Promise<TEntity> {
|
||||
return this.client.get<TEntity>(`${this.path}(${this.formatKey(id)})`, undefined, this.schema);
|
||||
}
|
||||
|
||||
public create(data: TCreate): Promise<TEntity> {
|
||||
return this.client.post<TEntity>(this.path, data, this.schema);
|
||||
}
|
||||
|
||||
public update(id: TKey, data: TUpdate): Promise<void> {
|
||||
return this.client.patch<void>(`${this.path}(${this.formatKey(id)})`, data);
|
||||
}
|
||||
|
||||
public delete(id: TKey): Promise<void> {
|
||||
if (!this.supportsDelete) {
|
||||
throw new Error(`${this.path} does not support delete through this connector`);
|
||||
}
|
||||
|
||||
return this.client.delete<void>(`${this.path}(${this.formatKey(id)})`);
|
||||
}
|
||||
|
||||
protected formatKey(id: TKey): string {
|
||||
return typeof id === "number" ? String(id) : `'${String(id).replace(/'/g, "''")}'`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SapB1Client } from "../client/SapB1Client";
|
||||
import { StockTransfer, StockTransferCreateDto, StockTransferUpdateDto } from "../types/entities";
|
||||
import { StockTransferSchema } from "../types/schemas";
|
||||
import { Resource } from "./Resource";
|
||||
|
||||
export class StockTransfersResource extends Resource<StockTransfer, StockTransferCreateDto, StockTransferUpdateDto, number> {
|
||||
public constructor(client: SapB1Client) {
|
||||
super(client, "StockTransfers", StockTransferSchema, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export interface BusinessPartner {
|
||||
CardCode: string;
|
||||
CardName?: string;
|
||||
CardType?: "cCustomer" | "cSupplier" | "cLid";
|
||||
Phone1?: string;
|
||||
EmailAddress?: string;
|
||||
Currency?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type BusinessPartnerCreateDto = Pick<BusinessPartner, "CardCode"> & Partial<BusinessPartner>;
|
||||
export type BusinessPartnerUpdateDto = Partial<Omit<BusinessPartner, "CardCode">>;
|
||||
|
||||
export interface Item {
|
||||
ItemCode: string;
|
||||
ItemName?: string;
|
||||
ItemsGroupCode?: number;
|
||||
InventoryItem?: "tYES" | "tNO";
|
||||
SalesItem?: "tYES" | "tNO";
|
||||
PurchaseItem?: "tYES" | "tNO";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ItemCreateDto = Pick<Item, "ItemCode"> & Partial<Item>;
|
||||
export type ItemUpdateDto = Partial<Omit<Item, "ItemCode">>;
|
||||
|
||||
export interface DocumentLine {
|
||||
ItemCode?: string;
|
||||
Quantity?: number;
|
||||
UnitPrice?: number;
|
||||
WarehouseCode?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MarketingDocument {
|
||||
DocEntry: number;
|
||||
DocNum?: number;
|
||||
CardCode?: string;
|
||||
DocDate?: string;
|
||||
DocDueDate?: string;
|
||||
TaxDate?: string;
|
||||
DocumentLines?: DocumentLine[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type MarketingDocumentCreateDto = Omit<Partial<MarketingDocument>, "DocEntry" | "DocNum"> & {
|
||||
CardCode: string;
|
||||
DocumentLines?: DocumentLine[];
|
||||
};
|
||||
export type MarketingDocumentUpdateDto = Partial<Omit<MarketingDocument, "DocEntry" | "DocNum">>;
|
||||
|
||||
export interface StockTransferLine {
|
||||
ItemCode?: string;
|
||||
Quantity?: number;
|
||||
WarehouseCode?: string;
|
||||
FromWarehouseCode?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface StockTransfer {
|
||||
DocEntry: number;
|
||||
DocNum?: number;
|
||||
DocDate?: string;
|
||||
FromWarehouse?: string;
|
||||
ToWarehouse?: string;
|
||||
StockTransferLines?: StockTransferLine[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type StockTransferCreateDto = Omit<Partial<StockTransfer>, "DocEntry" | "DocNum"> & {
|
||||
StockTransferLines?: StockTransferLine[];
|
||||
};
|
||||
export type StockTransferUpdateDto = Partial<Omit<StockTransfer, "DocEntry" | "DocNum">>;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface ODataQuery {
|
||||
select?: string[];
|
||||
filter?: string;
|
||||
top?: number;
|
||||
skip?: number;
|
||||
orderby?: string | string[];
|
||||
}
|
||||
|
||||
export interface ODataResponse<T> {
|
||||
value: T[];
|
||||
"odata.metadata"?: string;
|
||||
"odata.nextLink"?: string;
|
||||
"@odata.context"?: string;
|
||||
"@odata.nextLink"?: string;
|
||||
}
|
||||
|
||||
export const ODataQuerySchema = z.object({
|
||||
select: z.array(z.string().min(1)).optional(),
|
||||
filter: z.string().min(1).optional(),
|
||||
top: z.number().int().positive().optional(),
|
||||
skip: z.number().int().min(0).optional(),
|
||||
orderby: z.union([z.string().min(1), z.array(z.string().min(1))]).optional()
|
||||
});
|
||||
|
||||
export const ODataResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
|
||||
z.object({
|
||||
value: z.array(itemSchema),
|
||||
"odata.metadata": z.string().optional(),
|
||||
"odata.nextLink": z.string().optional(),
|
||||
"@odata.context": z.string().optional(),
|
||||
"@odata.nextLink": z.string().optional()
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UnknownRecordSchema = z.object({}).catchall(z.unknown());
|
||||
|
||||
export const BusinessPartnerSchema = UnknownRecordSchema.extend({
|
||||
CardCode: z.string()
|
||||
});
|
||||
|
||||
export const ItemSchema = UnknownRecordSchema.extend({
|
||||
ItemCode: z.string()
|
||||
});
|
||||
|
||||
export const MarketingDocumentSchema = UnknownRecordSchema.extend({
|
||||
DocEntry: z.number()
|
||||
});
|
||||
|
||||
export const StockTransferSchema = UnknownRecordSchema.extend({
|
||||
DocEntry: z.number()
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface SapB1Logger {
|
||||
debug(message: string, meta?: Record<string, unknown>): void;
|
||||
info(message: string, meta?: Record<string, unknown>): void;
|
||||
warn(message: string, meta?: Record<string, unknown>): void;
|
||||
error(message: string, meta?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export const noopLogger: SapB1Logger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined
|
||||
};
|
||||
|
||||
export function sanitizeLogMeta(meta: Record<string, unknown>): Record<string, unknown> {
|
||||
const forbidden = new Set(["password", "b1session", "routeid", "cookie", "authorization"]);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(meta).map(([key, value]) => {
|
||||
if (forbidden.has(key.toLowerCase())) {
|
||||
return [key, "[redacted]"];
|
||||
}
|
||||
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "../src/auth/SessionManager";
|
||||
import { SapB1Config } from "../src/config";
|
||||
|
||||
const config: SapB1Config = {
|
||||
baseUrl: "https://sap.example.local:50000",
|
||||
companyDB: "SBODEMOUS",
|
||||
username: "manager",
|
||||
password: "secret",
|
||||
timeout: 30_000,
|
||||
rejectUnauthorized: true,
|
||||
retryCount: 2,
|
||||
retryDelayMs: 0
|
||||
};
|
||||
|
||||
describe("SessionManager", () => {
|
||||
it("logs in and stores B1SESSION and ROUTEID cookies", async () => {
|
||||
const http = {
|
||||
post: vi.fn().mockResolvedValue({
|
||||
data: { SessionId: "fallback", SessionTimeout: 30 },
|
||||
headers: {
|
||||
"set-cookie": ["B1SESSION=abc123; Path=/b1s/v1", "ROUTEID=.node1; Path=/b1s/v1"]
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const sessionManager = new SessionManager(http as never, config);
|
||||
const session = await sessionManager.getSession();
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
"/Login",
|
||||
{
|
||||
CompanyDB: "SBODEMOUS",
|
||||
UserName: "manager",
|
||||
Password: "secret",
|
||||
Language: undefined
|
||||
},
|
||||
{ skipAuth: true }
|
||||
);
|
||||
expect(session.b1session).toBe("abc123");
|
||||
expect(session.routeId).toBe(".node1");
|
||||
expect(sessionManager.buildCookieHeader(session)).toBe("B1SESSION=abc123; ROUTEID=.node1");
|
||||
});
|
||||
|
||||
it("logs out and clears the current session", async () => {
|
||||
const http = {
|
||||
post: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { SessionId: "abc123" },
|
||||
headers: {}
|
||||
})
|
||||
.mockResolvedValueOnce({ data: {}, headers: {} })
|
||||
};
|
||||
|
||||
const sessionManager = new SessionManager(http as never, config);
|
||||
await sessionManager.getSession();
|
||||
await sessionManager.logout();
|
||||
|
||||
expect(http.post).toHaveBeenLastCalledWith("/Logout", undefined, { skipAuth: false });
|
||||
expect(sessionManager.currentSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import axios, { AxiosAdapter, AxiosResponse } from "axios";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SapB1Client } from "../src/client/SapB1Client";
|
||||
import { SapB1Config } from "../src/config";
|
||||
|
||||
const config: SapB1Config = {
|
||||
baseUrl: "https://sap.example.local:50000",
|
||||
companyDB: "SBODEMOUS",
|
||||
username: "manager",
|
||||
password: "secret",
|
||||
timeout: 30_000,
|
||||
rejectUnauthorized: true,
|
||||
retryCount: 1,
|
||||
retryDelayMs: 0
|
||||
};
|
||||
|
||||
function response(data: unknown, requestConfig: Parameters<AxiosAdapter>[0], status = 200): AxiosResponse {
|
||||
return {
|
||||
data,
|
||||
status,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: requestConfig
|
||||
};
|
||||
}
|
||||
|
||||
describe("SapB1Client", () => {
|
||||
it("adds session cookies to resource requests", async () => {
|
||||
const urls: string[] = [];
|
||||
const cookies: unknown[] = [];
|
||||
const http = axios.create({
|
||||
adapter: async (requestConfig) => {
|
||||
urls.push(String(requestConfig.url));
|
||||
cookies.push(requestConfig.headers?.Cookie);
|
||||
|
||||
if (requestConfig.url === "/Login") {
|
||||
return {
|
||||
...response({ SessionId: "abc123", SessionTimeout: 30 }, requestConfig),
|
||||
headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1", "ROUTEID=.node1; Path=/b1s/v1"] }
|
||||
};
|
||||
}
|
||||
|
||||
return response({ value: [{ CardCode: "C001" }] }, requestConfig);
|
||||
}
|
||||
});
|
||||
|
||||
const client = new SapB1Client(config, { http });
|
||||
const result = await client.get("BusinessPartners", { top: 1 });
|
||||
|
||||
expect(result).toEqual({ value: [{ CardCode: "C001" }] });
|
||||
expect(urls).toEqual(["/Login", "BusinessPartners"]);
|
||||
expect(cookies[1]).toBe("B1SESSION=abc123; ROUTEID=.node1");
|
||||
});
|
||||
|
||||
it("re-logins once after a 401 response", async () => {
|
||||
let resourceCalls = 0;
|
||||
let loginCalls = 0;
|
||||
const http = axios.create({
|
||||
adapter: async (requestConfig) => {
|
||||
if (requestConfig.url === "/Login") {
|
||||
loginCalls += 1;
|
||||
return {
|
||||
...response({ SessionId: `session-${loginCalls}`, SessionTimeout: 30 }, requestConfig),
|
||||
headers: { "set-cookie": [`B1SESSION=session-${loginCalls}; Path=/b1s/v1`] }
|
||||
};
|
||||
}
|
||||
|
||||
resourceCalls += 1;
|
||||
|
||||
if (resourceCalls === 1) {
|
||||
return Promise.reject({
|
||||
isAxiosError: true,
|
||||
message: "Unauthorized",
|
||||
response: { status: 401, data: { error: { message: { value: "Session expired" } } } }
|
||||
});
|
||||
}
|
||||
|
||||
return response({ value: [] }, requestConfig);
|
||||
}
|
||||
});
|
||||
|
||||
const client = new SapB1Client(config, { http });
|
||||
await expect(client.get("Items")).resolves.toEqual({ value: [] });
|
||||
expect(loginCalls).toBe(2);
|
||||
expect(resourceCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildODataParams, extractNextLink } from "../src/client/odata";
|
||||
|
||||
describe("OData helpers", () => {
|
||||
it("builds SAP Service Layer OData query parameters", () => {
|
||||
expect(
|
||||
buildODataParams({
|
||||
select: ["CardCode", "CardName"],
|
||||
filter: "CardType eq 'cCustomer'",
|
||||
top: 25,
|
||||
skip: 50,
|
||||
orderby: ["CardName asc", "CardCode desc"]
|
||||
})
|
||||
).toEqual({
|
||||
$select: "CardCode,CardName",
|
||||
$filter: "CardType eq 'cCustomer'",
|
||||
$top: 25,
|
||||
$skip: 50,
|
||||
$orderby: "CardName asc,CardCode desc"
|
||||
});
|
||||
});
|
||||
|
||||
it("supports old and new nextLink field names", () => {
|
||||
expect(extractNextLink({ "@odata.nextLink": "Items?$skip=20" })).toBe("Items?$skip=20");
|
||||
expect(extractNextLink({ "odata.nextLink": "Items?$skip=40" })).toBe("Items?$skip=40");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BusinessPartnersResource } from "../src/resources/BusinessPartners";
|
||||
import { OrdersResource } from "../src/resources/Documents";
|
||||
|
||||
describe("resources", () => {
|
||||
it("formats string keys for SAP Service Layer entity URLs", async () => {
|
||||
const client = {
|
||||
get: vi.fn().mockResolvedValue({ CardCode: "C'001" })
|
||||
};
|
||||
const resource = new BusinessPartnersResource(client as never);
|
||||
|
||||
await resource.get("C'001");
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith("BusinessPartners('C''001')", undefined, expect.anything());
|
||||
});
|
||||
|
||||
it("blocks delete for marketing documents by default", async () => {
|
||||
const resource = new OrdersResource({} as never);
|
||||
|
||||
await expect(() => resource.delete(1)).toThrow("Orders does not support delete through this connector");
|
||||
});
|
||||
});
|
||||
+7
-2
@@ -5,6 +5,11 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["tests", "dist", "node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user