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:
@@ -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í.
|
||||
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user