first
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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\/?/, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user