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 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-07-15 08:26:00 +02:00
parent a8279a8d5c
commit 94ab66a430
7 changed files with 258 additions and 10 deletions
+2 -1
View File
@@ -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/<resource>`
- `GET /api/<resource>/all`
- `GET /api/<resource>/{id}`
@@ -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í.
+67
View File
@@ -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<void> {
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<SapB1SystemInfo> {
await this.client.login();
const [entitySets, userFields, userTables, userObjects, adminInfo] = await Promise.all([
this.loadSection(() =>
this.client
.get<ODataResponse<{ name: string }>>("/")
.then((document) => document.value.map((entitySet) => entitySet.name).sort())
),
this.loadSection(() =>
this.client.getAll<unknown>("UserFieldsMD", {
select: ["TableName", "FieldID", "Name", "Type", "SubType", "Description"]
})
),
this.loadSection(() =>
this.client.getAll<unknown>("UserTablesMD", {
select: ["TableName", "TableDescription", "TableType"]
})
),
this.loadSection(() =>
this.client.getAll<unknown>("UserObjectsMD", {
select: ["Code", "Name", "TableName", "ObjectType"]
})
),
this.loadSection(() => this.client.post<unknown>("CompanyService_GetAdminInfo"))
]);
return {
serviceLayer: this.client.loginInfo ?? {},
entitySets,
userFields,
userTables,
userObjects,
adminInfo
};
}
private async loadSection<T>(load: () => Promise<T>): Promise<T | SapB1SectionError> {
try {
return await load();
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}
}
+19 -3
View File
@@ -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<SapB1Session>;
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<SapB1Session> {
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,
+5 -1
View File
@@ -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<void> {
return this.sessionManager.logout();
}
+51 -5
View File
@@ -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,
+106
View File
@@ -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<AxiosAdapter>[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 });
});
});