Files
sap-bo/src/client/SapB1Client.ts
T
JiriUhlir 9c08dacc69 first
2026-06-29 09:47:05 +02:00

171 lines
5.8 KiB
TypeScript

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\/?/, "");
}
}
}