171 lines
5.8 KiB
TypeScript
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\/?/, "");
|
|
}
|
|
}
|
|
}
|