f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
163 lines
5.0 KiB
TypeScript
163 lines
5.0 KiB
TypeScript
import { TokenBucketRateLimiter } from '../rate-limiter';
|
|
import { withRetry } from '../retry';
|
|
import { WINT_BASE_URL, WINT_RATE_LIMIT } from './config';
|
|
import { isTimeoutError } from '@/lib/http/fetch-with-timeout';
|
|
|
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
|
|
// WINT error bodies can carry customer data, and provider errors get logged
|
|
// wholesale by callers (provider-data-fetcher). Keep only a short bounded
|
|
// diagnostic on the error object so a full response body never reaches logs.
|
|
const MAX_ERROR_BODY_CHARS = 300;
|
|
|
|
export class WintApiError extends Error {
|
|
public readonly body?: string;
|
|
|
|
constructor(message: string, public readonly statusCode: number, body?: string) {
|
|
super(message);
|
|
this.name = 'WintApiError';
|
|
this.body = body != null ? body.slice(0, MAX_ERROR_BODY_CHARS) : undefined;
|
|
}
|
|
}
|
|
|
|
function isRetryableError(error: unknown): boolean {
|
|
if (isTimeoutError(error)) return true;
|
|
if (error instanceof WintApiError) {
|
|
if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
|
|
return false;
|
|
}
|
|
return error.statusCode === 429 || error.statusCode >= 500;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Every WINT list endpoint answers the same envelope. `Page` echoes the page
|
|
* that was actually served: the pagination loop keys on it (see getPaginated)
|
|
* because we have no documentation guaranteeing the `Page` request param is
|
|
* honored, and a provider that silently ignores it would otherwise loop on
|
|
* page 1 forever (the exact failure mode Björn Lundén shipped with
|
|
* pageRequested/rowsRequested).
|
|
*/
|
|
export interface WintListResponse<T> {
|
|
Items: T[];
|
|
Page: number;
|
|
NumPerPage: number;
|
|
TotalItems: number;
|
|
TotalItemsWithOutFilter?: number;
|
|
}
|
|
|
|
const DEFAULT_PAGE_SIZE = 200;
|
|
|
|
export class WintClient {
|
|
private readonly rateLimiter: TokenBucketRateLimiter;
|
|
private readonly baseUrl: string;
|
|
|
|
constructor(baseUrl?: string) {
|
|
this.baseUrl = baseUrl ?? WINT_BASE_URL;
|
|
this.rateLimiter = new TokenBucketRateLimiter(WINT_RATE_LIMIT, 'ratelimit:wint');
|
|
}
|
|
|
|
// Assumption (unverified against a live account, no securityScheme in the
|
|
// swagger): the JWT from POST /api/Auth/jwt travels as a standard Bearer
|
|
// token. If a live test proves otherwise the change is confined here.
|
|
private authHeaders(accessToken: string): Record<string, string> {
|
|
return {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
};
|
|
}
|
|
|
|
async get<T>(accessToken: string, path: string): Promise<T> {
|
|
return withRetry(
|
|
async () => {
|
|
await this.rateLimiter.acquire();
|
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
headers: this.authHeaders(accessToken),
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new WintApiError(
|
|
`WINT API error: ${response.status} ${response.statusText}`,
|
|
response.status,
|
|
body,
|
|
);
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
},
|
|
{
|
|
maxAttempts: 3,
|
|
initialDelayMs: 1000,
|
|
shouldRetry: isRetryableError,
|
|
},
|
|
);
|
|
}
|
|
|
|
async getPage<T>(
|
|
accessToken: string,
|
|
path: string,
|
|
options?: { page?: number; pageSize?: number },
|
|
): Promise<{ items: T[]; page: number; totalItems: number; pageSize: number }> {
|
|
const page = options?.page ?? 1;
|
|
const pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
|
|
const params = new URLSearchParams();
|
|
params.set('Page', String(page));
|
|
params.set('NumPerPage', String(pageSize));
|
|
const separator = path.includes('?') ? '&' : '?';
|
|
|
|
const response = await this.get<WintListResponse<T>>(
|
|
accessToken,
|
|
`${path}${separator}${params.toString()}`,
|
|
);
|
|
|
|
return {
|
|
items: Array.isArray(response.Items) ? response.Items : [],
|
|
page: response.Page ?? page,
|
|
totalItems: response.TotalItems ?? 0,
|
|
pageSize: response.NumPerPage ?? pageSize,
|
|
};
|
|
}
|
|
|
|
async getPaginated<T>(
|
|
accessToken: string,
|
|
path: string,
|
|
options?: { pageSize?: number },
|
|
): Promise<T[]> {
|
|
const allItems: T[] = [];
|
|
let page = 1;
|
|
|
|
for (;;) {
|
|
const result = await this.getPage<T>(accessToken, path, {
|
|
page,
|
|
pageSize: options?.pageSize,
|
|
});
|
|
|
|
// Page-echo guard: a server that ignores the Page param serves page 1
|
|
// for every request; without this check the loop appends the same items
|
|
// until TotalItems is (never) reached.
|
|
if (result.page !== page) {
|
|
throw new WintApiError(
|
|
`WINT pagination did not honor Page=${page} (served ${result.page}) for ${path}`,
|
|
502,
|
|
);
|
|
}
|
|
|
|
allItems.push(...result.items);
|
|
|
|
const done =
|
|
result.items.length === 0 ||
|
|
allItems.length >= result.totalItems ||
|
|
result.items.length < result.pageSize;
|
|
if (done) break;
|
|
page++;
|
|
}
|
|
|
|
return allItems;
|
|
}
|
|
}
|