From 94ab66a430692fe4f9578edff9998cb4b0ad1fc4 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:26:00 +0200 Subject: [PATCH] Add GET /api/system/info and return SAP version from login - capture Version + SessionTimeout from the Service Layer login response (SessionManager.loginInfo, survives logout) and return them from POST /api/session/login - new GET /api/system/info: entity sets (service document), UserFieldsMD/UserTablesMD/UserObjectsMD customizations and CompanyService_GetAdminInfo; unreadable sections come back as { error } instead of failing the whole request - OpenAPI: new System tag, examples for both endpoints - tests for getSystemInfo section isolation and loginInfo retention Co-Authored-By: Claude Fable 5 --- README.md | 3 +- .../sap-business-one-service-layer.md | 8 ++ src/SapBusinessOneServiceLayer.ts | 67 +++++++++++ src/auth/SessionManager.ts | 22 +++- src/client/SapB1Client.ts | 6 +- src/index.ts | 56 ++++++++- tests/systemInfo.test.ts | 106 ++++++++++++++++++ 7 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 tests/systemInfo.test.ts diff --git a/README.md b/README.md index 436e6ac..cb8bbd4 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ 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/login` (vrací i verzi Service Layeru a session timeout) - `POST /api/session/logout` +- `GET /api/system/info` (verze SAP, dostupné entity sety, UDF/UDT/UDO, admin info) - `GET /api/` - `GET /api//all` - `GET /api//{id}` diff --git a/documentation/sap-business-one-service-layer.md b/documentation/sap-business-one-service-layer.md index 9d3a712..8f06cc3 100644 --- a/documentation/sap-business-one-service-layer.md +++ b/documentation/sap-business-one-service-layer.md @@ -43,6 +43,14 @@ HTTP API hlavičky: takže funguje i při `SAP_B1_RETRY_COUNT=0`. - **Logout** – `POST /b1s/v1/Logout` se posílá **s aktivní session cookie**, jinak by Service Layer nevěděl, kterou session ukončit, a nechal by ji běžet až do timeoutu. +- **Verze a system info** – login response obsahuje `Version` (např. `1000230` = SAP B1 + 10.0 PL 23) a `SessionTimeout`; connector si je ukládá (`client.loginInfo`, přežije + logout) a HTTP endpoint `POST /api/session/login` je vrací v odpovědi. + `GET /api/system/info` navíc vrátí přehled instalace: service document (`GET /b1s/v1/` + → dostupné entity sety), `UserFieldsMD` (UDF), `UserTablesMD` (UDT), `UserObjectsMD` + (UDO) a `CompanyService_GetAdminInfo`. Sekce, na kterou SAP uživatel nemá práva, se + vrátí jako `{ "error": "…" }`, aby jedno chybějící oprávnění neshodilo celý přehled + (chyba je v odpovědi vidět, nejde o tiché selhání). - **Stránkování** – následuje `odata.nextLink` i `@odata.nextLink`. U absolutních nextLinků se zachová i query string (`$skip` apod.), takže `listAll` nezacyklí. diff --git a/src/SapBusinessOneServiceLayer.ts b/src/SapBusinessOneServiceLayer.ts index c4e5194..bcfb4a4 100644 --- a/src/SapBusinessOneServiceLayer.ts +++ b/src/SapBusinessOneServiceLayer.ts @@ -1,9 +1,24 @@ +import { SapB1LoginInfo } from "./auth/SessionManager"; 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"; +import { ODataResponse } from "./types/odata"; + +export interface SapB1SectionError { + error: string; +} + +export interface SapB1SystemInfo { + serviceLayer: SapB1LoginInfo; + entitySets: string[] | SapB1SectionError; + userFields: unknown[] | SapB1SectionError; + userTables: unknown[] | SapB1SectionError; + userObjects: unknown[] | SapB1SectionError; + adminInfo: unknown | SapB1SectionError; +} export class SapBusinessOneServiceLayer { public readonly client: SapB1Client; @@ -33,4 +48,56 @@ export class SapBusinessOneServiceLayer { public logout(): Promise { return this.client.logout(); } + + /** + * One-shot overview of the connected SAP Business One installation: Service Layer + * version (from the login response), available entity sets (service document) and + * the customization metadata (UserFieldsMD/UserTablesMD/UserObjectsMD) plus company + * admin info. Sections the SAP user cannot read come back as { error } so one missing + * permission does not fail the whole overview. + */ + public async getSystemInfo(): Promise { + await this.client.login(); + + const [entitySets, userFields, userTables, userObjects, adminInfo] = await Promise.all([ + this.loadSection(() => + this.client + .get>("/") + .then((document) => document.value.map((entitySet) => entitySet.name).sort()) + ), + this.loadSection(() => + this.client.getAll("UserFieldsMD", { + select: ["TableName", "FieldID", "Name", "Type", "SubType", "Description"] + }) + ), + this.loadSection(() => + this.client.getAll("UserTablesMD", { + select: ["TableName", "TableDescription", "TableType"] + }) + ), + this.loadSection(() => + this.client.getAll("UserObjectsMD", { + select: ["Code", "Name", "TableName", "ObjectType"] + }) + ), + this.loadSection(() => this.client.post("CompanyService_GetAdminInfo")) + ]); + + return { + serviceLayer: this.client.loginInfo ?? {}, + entitySets, + userFields, + userTables, + userObjects, + adminInfo + }; + } + + private async loadSection(load: () => Promise): Promise { + try { + return await load(); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + } } diff --git a/src/auth/SessionManager.ts b/src/auth/SessionManager.ts index befb1ca..6d09038 100644 --- a/src/auth/SessionManager.ts +++ b/src/auth/SessionManager.ts @@ -17,9 +17,15 @@ export interface SapB1Session { expiresAt?: number; } +export interface SapB1LoginInfo { + version?: string; + sessionTimeoutMinutes?: number; +} + export class SessionManager { private session?: SapB1Session; private loginPromise?: Promise; + private lastLoginInfo?: SapB1LoginInfo; public constructor( private readonly http: AxiosInstance, @@ -31,6 +37,12 @@ export class SessionManager { return this.session; } + // Survives logout on purpose: the login/logout roundtrip in the HTTP layer still + // needs to report which Service Layer version answered. + public get loginInfo(): SapB1LoginInfo | undefined { + return this.lastLoginInfo; + } + public async getSession(forceRefresh = false): Promise { if (!forceRefresh && this.session && !this.isExpired(this.session)) { return this.session; @@ -61,15 +73,19 @@ export class SessionManager { { skipAuth: true } ); - LoginResponseSchema.parse(response.data); + const loginData = LoginResponseSchema.parse(response.data); const cookies = this.extractCookies(response.headers["set-cookie"]); - const b1session = cookies.B1SESSION || response.data.SessionId; + const b1session = cookies.B1SESSION || loginData.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; + const timeoutMinutes = typeof loginData.SessionTimeout === "number" ? loginData.SessionTimeout : 30; + this.lastLoginInfo = { + version: loginData.Version, + sessionTimeoutMinutes: loginData.SessionTimeout + }; this.session = { b1session, routeId: cookies.ROUTEID, diff --git a/src/client/SapB1Client.ts b/src/client/SapB1Client.ts index ef02a70..a00909c 100644 --- a/src/client/SapB1Client.ts +++ b/src/client/SapB1Client.ts @@ -2,7 +2,7 @@ import "./axiosTypes"; import https from "node:https"; import axios, { AxiosInstance, AxiosRequestConfig, Method } from "axios"; import { z } from "zod"; -import { SessionManager } from "../auth/SessionManager"; +import { SapB1LoginInfo, SessionManager } from "../auth/SessionManager"; import { SapB1Config, SapB1ConfigSchema } from "../config"; import { SapB1Error, toSapB1Error } from "../errors/SapB1Error"; import { ODataQuery, ODataResponse } from "../types/odata"; @@ -38,6 +38,10 @@ export class SapB1Client { return this.sessionManager.getSession(true); } + public get loginInfo(): SapB1LoginInfo | undefined { + return this.sessionManager.loginInfo; + } + public logout(): Promise { return this.sessionManager.logout(); } diff --git a/src/index.ts b/src/index.ts index e09e694..0c492c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,12 @@ 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, SapB1LoginInfo } 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 { SapBusinessOneServiceLayer, SapB1SystemInfo, SapB1SectionError } from "./SapBusinessOneServiceLayer"; export * from "./types/entities"; export * from "./types/odata"; @@ -252,8 +252,11 @@ function addResourceEndpoints(prefix = "") { app.post( `${prefix}/api/session/login`, asyncHandler(async (req, res) => { - await withSap(req, (sap) => sap.login()); - res.json({ status: "ok" }); + const serviceLayer = await withSap(req, async (sap) => { + await sap.login(); + return sap.client.loginInfo ?? {}; + }); + res.json({ status: "ok", ...serviceLayer }); }) ); @@ -265,6 +268,14 @@ function addResourceEndpoints(prefix = "") { }) ); + app.get( + `${prefix}/api/system/info`, + asyncHandler(async (req, res) => { + const result = await withSap(req, (sap) => sap.getSystemInfo()); + res.json(result); + }) + ); + for (const route of resourceRoutes) { const base = `${prefix}/api/${route.slug}`; @@ -445,7 +456,14 @@ function openApiDocument(basePath = "") { "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" }, + "200": { + description: "Session is available; returns SAP Service Layer version and session timeout", + content: { + "application/json": { + example: { status: "ok", version: "1000230", sessionTimeoutMinutes: 30 } + } + } + }, "502": { description: "SAP login failed" } } } @@ -459,6 +477,33 @@ function openApiDocument(basePath = "") { "204": { description: "Session closed or was not active" } } } + }, + "/api/system/info": { + get: { + tags: ["System"], + summary: "SAP Business One system and customization overview", + description: + "Logs in and returns the Service Layer version (from the login response), the list of available entity sets (service document), user-defined fields (UserFieldsMD), user-defined tables (UserTablesMD), user-defined objects (UserObjectsMD) and company admin info (CompanyService_GetAdminInfo). Sections the SAP user cannot read are returned as { \"error\": \"...\" } instead of failing the whole request.", + parameters: sapCredentialParameters(), + responses: { + "200": { + description: "System overview", + content: { + "application/json": { + example: { + serviceLayer: { version: "1000230", sessionTimeoutMinutes: 30 }, + entitySets: ["BusinessPartners", "Items", "Orders"], + userFields: [{ TableName: "OCRD", FieldID: 0, Name: "MyField", Type: "db_Alpha", Description: "Custom field" }], + userTables: [{ TableName: "MY_TABLE", TableDescription: "Custom table", TableType: "bott_NoObject" }], + userObjects: [{ Code: "MY_UDO", Name: "My UDO", TableName: "MY_TABLE", ObjectType: "boud_Document" }], + adminInfo: { error: "No permission (example of a section the SAP user cannot read)" } + } + } + } + }, + "502": { description: "SAP login failed" } + } + } } }; @@ -556,6 +601,7 @@ function openApiDocument(basePath = "") { tags: [ { name: "Service" }, { name: "Session" }, + { name: "System", description: "SAP version and customization overview" }, ...resourceRoutes.map((route) => ({ name: route.tag, description: `SAP Service Layer ${route.sapEntitySet}` })) ], paths, diff --git a/tests/systemInfo.test.ts b/tests/systemInfo.test.ts new file mode 100644 index 0000000..bb12352 --- /dev/null +++ b/tests/systemInfo.test.ts @@ -0,0 +1,106 @@ +import axios, { AxiosAdapter, AxiosResponse } from "axios"; +import { describe, expect, it } from "vitest"; +import { SapBusinessOneServiceLayer } from "../src/SapBusinessOneServiceLayer"; +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: 0, + retryDelayMs: 0 +}; + +function response(data: unknown, requestConfig: Parameters[0], status = 200): AxiosResponse { + return { + data, + status, + statusText: "OK", + headers: {}, + config: requestConfig + }; +} + +describe("getSystemInfo", () => { + it("collects version, entity sets and customization metadata, isolating section errors", async () => { + const http = axios.create({ + adapter: async (requestConfig) => { + const url = String(requestConfig.url); + + if (url === "/Login") { + return { + ...response({ SessionId: "abc123", Version: "1000230", SessionTimeout: 30 }, requestConfig), + headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1"] } + }; + } + + if (url === "/Logout") { + return response({}, requestConfig); + } + + if (url === "/") { + return response( + { value: [{ name: "Items", url: "Items" }, { name: "BusinessPartners", url: "BusinessPartners" }] }, + requestConfig + ); + } + + if (url === "UserFieldsMD") { + return response({ value: [{ TableName: "OCRD", FieldID: 0, Name: "MyField", Type: "db_Alpha" }] }, requestConfig); + } + + if (url === "UserTablesMD") { + return response({ value: [{ TableName: "MY_TABLE", TableDescription: "Custom table" }] }, requestConfig); + } + + if (url === "UserObjectsMD") { + return response({ value: [] }, requestConfig); + } + + if (url === "CompanyService_GetAdminInfo") { + return Promise.reject({ + isAxiosError: true, + message: "Forbidden", + response: { status: 403, data: { error: { message: { value: "No permission" } } } } + }); + } + + throw new Error(`Unexpected request URL: ${url}`); + } + }); + + const sap = new SapBusinessOneServiceLayer(config, { http }); + const info = await sap.getSystemInfo(); + + expect(info.serviceLayer).toEqual({ version: "1000230", sessionTimeoutMinutes: 30 }); + expect(info.entitySets).toEqual(["BusinessPartners", "Items"]); + expect(info.userFields).toEqual([{ TableName: "OCRD", FieldID: 0, Name: "MyField", Type: "db_Alpha" }]); + expect(info.userTables).toEqual([{ TableName: "MY_TABLE", TableDescription: "Custom table" }]); + expect(info.userObjects).toEqual([]); + expect(info.adminInfo).toEqual({ error: "No permission" }); + }); + + it("keeps login info available after logout so the login endpoint can report the version", async () => { + const http = axios.create({ + adapter: async (requestConfig) => { + if (requestConfig.url === "/Login") { + return { + ...response({ SessionId: "abc123", Version: "1000230", SessionTimeout: 30 }, requestConfig), + headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1"] } + }; + } + + return response({}, requestConfig); + } + }); + + const sap = new SapBusinessOneServiceLayer(config, { http }); + await sap.login(); + await sap.logout(); + + expect(sap.client.loginInfo).toEqual({ version: "1000230", sessionTimeoutMinutes: 30 }); + }); +});