claude overtook and fixes
This commit is contained in:
@@ -31,3 +31,27 @@ HTTP API hlavičky:
|
|||||||
- `X-SAP-B1-Language` volitelně
|
- `X-SAP-B1-Language` volitelně
|
||||||
- `X-SAP-B1-Reject-Unauthorized` volitelně
|
- `X-SAP-B1-Reject-Unauthorized` volitelně
|
||||||
- `X-SAP-B1-Timeout-Ms` volitelně
|
- `X-SAP-B1-Timeout-Ms` volitelně
|
||||||
|
|
||||||
|
## Detaily komunikace se Service Layer
|
||||||
|
|
||||||
|
- **Login** – `POST /b1s/v1/Login` s `{ CompanyDB, UserName, Password, Language? }`.
|
||||||
|
`Language` se posílá jako celé číslo (Service Layer očekává `Edm.Int32`); nenumerická
|
||||||
|
hodnota se vynechá. Z odpovědi se čtou cookies `B1SESSION` a `ROUTEID` (s fallbackem na
|
||||||
|
`SessionId` v body) a `SessionTimeout` (minuty) pro výpočet expirace.
|
||||||
|
- **Autentizované requesty** – posílají `Cookie: B1SESSION=…; ROUTEID=…`. Při `401`
|
||||||
|
connector jednou provede re-login a request zopakuje; re-login nesnižuje retry budget,
|
||||||
|
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.
|
||||||
|
- **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í.
|
||||||
|
|
||||||
|
## Reverse proxy a Swagger
|
||||||
|
|
||||||
|
Aplikace běží za AppFactory proxy na `/apps/<app-id>` (Caddy `handle_path` prefix před
|
||||||
|
předáním do containeru odstraní). Proto:
|
||||||
|
|
||||||
|
- OpenAPI `servers` obsahuje `ROOT_PATH` (`/apps/<app-id>`), takže Swagger `Try it out`
|
||||||
|
volá `…/apps/<app-id>/api/<resource>`.
|
||||||
|
- Swagger UI načítá OpenAPI dokument z `ROOT_PATH + /openapi.json`, ne z kořene domény.
|
||||||
|
- Lokálně bez `ROOT_PATH` se vše chová relativně ke kořeni (`/openapi.json`, server `/`).
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export class SessionManager {
|
|||||||
CompanyDB: this.config.companyDB,
|
CompanyDB: this.config.companyDB,
|
||||||
UserName: this.config.username,
|
UserName: this.config.username,
|
||||||
Password: this.config.password,
|
Password: this.config.password,
|
||||||
Language: this.config.language
|
// Service Layer expects Language as an integer language code (Edm.Int32).
|
||||||
|
Language: this.normalizeLanguage(this.config.language)
|
||||||
},
|
},
|
||||||
{ skipAuth: true }
|
{ skipAuth: true }
|
||||||
);
|
);
|
||||||
@@ -90,8 +91,15 @@ export class SessionManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cookieHeader = this.buildCookieHeader(this.session);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.http.post("/Logout", undefined, { skipAuth: false });
|
// Logout must carry the active session cookie, otherwise the Service Layer
|
||||||
|
// cannot identify which session to terminate and leaves it open until timeout.
|
||||||
|
await this.http.post("/Logout", undefined, {
|
||||||
|
skipAuth: true,
|
||||||
|
headers: { Cookie: cookieHeader }
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.session = undefined;
|
this.session = undefined;
|
||||||
}
|
}
|
||||||
@@ -111,6 +119,15 @@ export class SessionManager {
|
|||||||
this.session = undefined;
|
this.session = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeLanguage(language: string | undefined): number | undefined {
|
||||||
|
if (language === undefined || language === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(language);
|
||||||
|
return Number.isInteger(numeric) ? numeric : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private isExpired(session: SapB1Session): boolean {
|
private isExpired(session: SapB1Session): boolean {
|
||||||
return session.expiresAt !== undefined && session.expiresAt <= Date.now();
|
return session.expiresAt !== undefined && session.expiresAt <= Date.now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export class SapB1Client {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let reloginAttempted = false;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= this.config.retryCount; attempt += 1) {
|
for (let attempt = 0; attempt <= this.config.retryCount; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
if (!requestConfig.skipAuth) {
|
if (!requestConfig.skipAuth) {
|
||||||
@@ -106,11 +108,15 @@ export class SapB1Client {
|
|||||||
return options.schema ? options.schema.parse(response.data) : (response.data as T);
|
return options.schema ? options.schema.parse(response.data) : (response.data as T);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const sapError = this.toSapB1Error(error, method, path);
|
const sapError = this.toSapB1Error(error, method, path);
|
||||||
const canRelogin = sapError.status === 401 && !requestConfig.skipAuth && attempt === 0;
|
const canRelogin = sapError.status === 401 && !requestConfig.skipAuth && !reloginAttempted;
|
||||||
|
|
||||||
if (canRelogin) {
|
if (canRelogin) {
|
||||||
|
// A single re-login on session expiry must not eat into the retry budget,
|
||||||
|
// so step the counter back and retry the request with a fresh session.
|
||||||
|
reloginAttempted = true;
|
||||||
this.sessionManager.clearSession();
|
this.sessionManager.clearSession();
|
||||||
await this.sessionManager.getSession(true);
|
await this.sessionManager.getSession(true);
|
||||||
|
attempt -= 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +168,7 @@ export class SapB1Client {
|
|||||||
private normalizeNextLink(nextLink: string): string {
|
private normalizeNextLink(nextLink: string): string {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(nextLink);
|
const parsed = new URL(nextLink);
|
||||||
return parsed.pathname.replace(/^.*\/b1s\/v1\/?/, "");
|
return parsed.pathname.replace(/^.*\/b1s\/v1\/?/, "") + parsed.search;
|
||||||
} catch {
|
} catch {
|
||||||
return nextLink.replace(/^\/?b1s\/v1\/?/, "");
|
return nextLink.replace(/^\/?b1s\/v1\/?/, "");
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -610,7 +610,12 @@ app.get("/openapi.json", (_req, res) => {
|
|||||||
|
|
||||||
addResourceEndpoints();
|
addResourceEndpoints();
|
||||||
|
|
||||||
app.use("/docs", swaggerUi.serveFiles(undefined, swaggerOptions("")), swaggerUi.setup(undefined, swaggerOptions("")));
|
// Behind the AppFactory reverse proxy the container is reached via /apps/<app-id> and
|
||||||
|
// the prefix is stripped (handle_path) before requests arrive here. The Swagger UI page is
|
||||||
|
// therefore served at the public /apps/<app-id>/docs, so the OpenAPI document URL and the
|
||||||
|
// "Try it out" base path must carry ROOT_PATH; otherwise the browser would resolve them
|
||||||
|
// against the bare origin and bypass the app.
|
||||||
|
app.use("/docs", swaggerUi.serveFiles(undefined, swaggerOptions(rootPath)), swaggerUi.setup(undefined, swaggerOptions(rootPath)));
|
||||||
|
|
||||||
if (rootPath) {
|
if (rootPath) {
|
||||||
app.get(rootPath, (_req, res) => {
|
app.get(rootPath, (_req, res) => {
|
||||||
|
|||||||
+27
-1
@@ -42,6 +42,29 @@ describe("SessionManager", () => {
|
|||||||
expect(sessionManager.buildCookieHeader(session)).toBe("B1SESSION=abc123; ROUTEID=.node1");
|
expect(sessionManager.buildCookieHeader(session)).toBe("B1SESSION=abc123; ROUTEID=.node1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends the language as an integer code to the Login action", async () => {
|
||||||
|
const http = {
|
||||||
|
post: vi.fn().mockResolvedValue({
|
||||||
|
data: { SessionId: "abc123", SessionTimeout: 30 },
|
||||||
|
headers: {}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionManager = new SessionManager(http as never, { ...config, language: "3" });
|
||||||
|
await sessionManager.getSession();
|
||||||
|
|
||||||
|
expect(http.post).toHaveBeenCalledWith(
|
||||||
|
"/Login",
|
||||||
|
{
|
||||||
|
CompanyDB: "SBODEMOUS",
|
||||||
|
UserName: "manager",
|
||||||
|
Password: "secret",
|
||||||
|
Language: 3
|
||||||
|
},
|
||||||
|
{ skipAuth: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("logs out and clears the current session", async () => {
|
it("logs out and clears the current session", async () => {
|
||||||
const http = {
|
const http = {
|
||||||
post: vi
|
post: vi
|
||||||
@@ -57,7 +80,10 @@ describe("SessionManager", () => {
|
|||||||
await sessionManager.getSession();
|
await sessionManager.getSession();
|
||||||
await sessionManager.logout();
|
await sessionManager.logout();
|
||||||
|
|
||||||
expect(http.post).toHaveBeenLastCalledWith("/Logout", undefined, { skipAuth: false });
|
expect(http.post).toHaveBeenLastCalledWith("/Logout", undefined, {
|
||||||
|
skipAuth: true,
|
||||||
|
headers: { Cookie: "B1SESSION=abc123" }
|
||||||
|
});
|
||||||
expect(sessionManager.currentSession).toBeUndefined();
|
expect(sessionManager.currentSession).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,4 +84,71 @@ describe("SapB1Client", () => {
|
|||||||
expect(loginCalls).toBe(2);
|
expect(loginCalls).toBe(2);
|
||||||
expect(resourceCalls).toBe(2);
|
expect(resourceCalls).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-logins after a 401 even when retries are disabled", 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, retryCount: 0 }, { http });
|
||||||
|
await expect(client.get("Items")).resolves.toEqual({ value: [] });
|
||||||
|
expect(loginCalls).toBe(2);
|
||||||
|
expect(resourceCalls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows an absolute nextLink while preserving its query string", async () => {
|
||||||
|
const requestedUrls: string[] = [];
|
||||||
|
const http = axios.create({
|
||||||
|
adapter: async (requestConfig) => {
|
||||||
|
if (requestConfig.url === "/Login") {
|
||||||
|
return {
|
||||||
|
...response({ SessionId: "abc123", SessionTimeout: 30 }, requestConfig),
|
||||||
|
headers: { "set-cookie": ["B1SESSION=abc123; Path=/b1s/v1"] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedUrls.push(String(requestConfig.url));
|
||||||
|
|
||||||
|
if (requestConfig.url === "Items") {
|
||||||
|
return response(
|
||||||
|
{
|
||||||
|
value: [{ ItemCode: "A1" }],
|
||||||
|
"odata.nextLink": "https://sap.example.local:50000/b1s/v1/Items?$skip=20"
|
||||||
|
},
|
||||||
|
requestConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response({ value: [{ ItemCode: "A2" }] }, requestConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new SapB1Client(config, { http });
|
||||||
|
const all = await client.getAll<{ ItemCode: string }>("Items");
|
||||||
|
|
||||||
|
expect(all).toEqual([{ ItemCode: "A1" }, { ItemCode: "A2" }]);
|
||||||
|
expect(requestedUrls).toEqual(["Items", "Items?$skip=20"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user