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 { return this.sessionManager.getSession(true); } public logout(): Promise { return this.sessionManager.logout(); } public get(path: string, query?: ODataQuery, schema?: z.ZodType): Promise { return this.request("GET", path, undefined, { params: buildODataParams(query), schema }); } public post(path: string, data?: unknown, schema?: z.ZodType): Promise { return this.request("POST", path, data, { schema }); } public patch(path: string, data?: unknown, schema?: z.ZodType): Promise { return this.request("PATCH", path, data, { schema }); } public delete(path: string, schema?: z.ZodType): Promise { return this.request("DELETE", path, undefined, { schema }); } public async getAll(path: string, query: ODataQuery = {}, schema?: z.ZodType): Promise { const items: T[] = []; let nextPath: string | undefined = path; let nextQuery: ODataQuery | undefined = query; while (nextPath) { const response: ODataResponse = await this.get>(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( method: Method, path: string, data?: unknown, options: AxiosRequestConfig & { schema?: z.ZodType } = {} ): Promise { 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\/?/, ""); } } }