feat(sync): harden the Hiboutik sync, share rate limits across instances, update dependencies (#4)
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import { BrandHiboutikDto } from '../models/hiboutikDTO/brand.hiboutik.dto';
|
||||
import { BrandHiboutikDto, brandHiboutikSchema } from '../models/hiboutikDTO/brand.hiboutik.dto';
|
||||
|
||||
/** Reads brands from the Hiboutik API. */
|
||||
@Injectable()
|
||||
export class BrandHiboutikRepository {
|
||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||
|
||||
/** Walked twice when it spans several pages: it drives the deletion of brands (see `getAllPagesForSync`). */
|
||||
async getBrands(): Promise<BrandHiboutikDto[]> {
|
||||
return this.hiboutikClient.getAllPages<BrandHiboutikDto>('/brands', {
|
||||
return this.hiboutikClient.getAllPagesForSync('/brands', brandHiboutikSchema, (brand) => brand.brand_id, {
|
||||
order_by: 'brand_id',
|
||||
sort: 'ASC',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CategoryHiboutikDto } from '../models/hiboutikDTO/category.hiboutik.dto';
|
||||
import { CategoryHiboutikDto, categoryHiboutikSchema } from '../models/hiboutikDTO/category.hiboutik.dto';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
|
||||
/** Reads categories from the Hiboutik API. */
|
||||
@@ -7,10 +7,13 @@ import { HiboutikClient } from './hiboutik.client';
|
||||
export class CategoryHiboutikRepository {
|
||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||
|
||||
/** Sorted by parent id, which does not guarantee that a parent comes before its children. */
|
||||
/**
|
||||
* Sorted by id (unique, so that the pages are stable), a parent may come after its children. Walked twice when it
|
||||
* spans several pages: it drives the deletion of categories (see `getAllPagesForSync`).
|
||||
*/
|
||||
async getCategories(): Promise<CategoryHiboutikDto[]> {
|
||||
return this.hiboutikClient.getAllPages<CategoryHiboutikDto>('/categories', {
|
||||
order_by: 'category_id_parent',
|
||||
return this.hiboutikClient.getAllPagesForSync('/categories', categoryHiboutikSchema, (category) => category.category_id, {
|
||||
order_by: 'category_id',
|
||||
sort: 'ASC',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import {
|
||||
CustomerAddressDetailsHiboutikDto,
|
||||
customerAddressDetailsHiboutikSchema,
|
||||
CustomerHiboutikDto,
|
||||
customerHiboutikSchema,
|
||||
FullCustomerHiboutikDto,
|
||||
fullCustomerHiboutikSchema,
|
||||
} from '../models/hiboutikDTO/customer.hiboutik.dto';
|
||||
|
||||
/** Reads customers from the Hiboutik API. */
|
||||
@@ -11,29 +14,39 @@ import {
|
||||
export class CustomerHiboutikRepository {
|
||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||
|
||||
/** Customer summaries only: use {@link getCustomerById} for addresses and sales. */
|
||||
/**
|
||||
* Customer summaries only: use {@link getCustomerById} for addresses and sales. Sorted by id so that the pages are
|
||||
* stable, but a customer created or deleted while they are walked can still shift the following ones.
|
||||
*/
|
||||
async getCustomers(): Promise<CustomerHiboutikDto[]> {
|
||||
return this.hiboutikClient.getAllPages<CustomerHiboutikDto>('/customers');
|
||||
return this.hiboutikClient.getAllPages('/customers', customerHiboutikSchema, {
|
||||
order_by: 'customers_id',
|
||||
sort: 'ASC',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Full customer, with its addresses and sales (Hiboutik answers with a one-element array).
|
||||
* @throws if the customer does not exist.
|
||||
* Full customer, with its addresses and sales.
|
||||
* @returns `null` when Hiboutik says the customer does not exist (404 or empty answer).
|
||||
* @throws on any other failure: an unknown state is never taken for a deleted customer.
|
||||
*/
|
||||
async findCustomerById(id: number): Promise<FullCustomerHiboutikDto | null> {
|
||||
return this.hiboutikClient.getOne(`/customer/${id}`, fullCustomerHiboutikSchema);
|
||||
}
|
||||
|
||||
/** Same as {@link findCustomerById}, but a missing customer throws. */
|
||||
async getCustomerById(id: number): Promise<FullCustomerHiboutikDto> {
|
||||
const data = await this.hiboutikClient.get<FullCustomerHiboutikDto[]>('/customer/' + String(id));
|
||||
const customer = data[0];
|
||||
const customer = await this.findCustomerById(id);
|
||||
if (!customer) throw new Error(`Hiboutik customer ${id} not found`);
|
||||
return customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address with its company details (Hiboutik answers with a one-element array).
|
||||
* Address with its company details.
|
||||
* @throws if the address does not exist.
|
||||
*/
|
||||
async getAddressById(id: number): Promise<CustomerAddressDetailsHiboutikDto> {
|
||||
const data = await this.hiboutikClient.get<CustomerAddressDetailsHiboutikDto[]>('/customers_addresses/' + String(id));
|
||||
const address = data[0];
|
||||
const address = await this.hiboutikClient.getOne(`/customers_addresses/${id}`, customerAddressDetailsHiboutikSchema);
|
||||
if (!address) throw new Error(`Hiboutik address ${id} not found`);
|
||||
return address;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { AxiosError, AxiosHeaders } from 'axios';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import { z } from 'zod';
|
||||
import { HiboutikClient, HiboutikRateLimitError } from './hiboutik.client';
|
||||
|
||||
describe('HiboutikClient', () => {
|
||||
const httpService = { get: jest.fn() };
|
||||
const configService = { get: jest.fn((key: string) => (key === 'HIBOUTIK_BASE_URL' ? 'https://shop.hiboutik.test/api' : 'x')) };
|
||||
const schema = z.object({ id: z.coerce.number() });
|
||||
let client: HiboutikClient;
|
||||
|
||||
const answer = (data: unknown) => of({ data });
|
||||
const httpError = (status: number) =>
|
||||
throwError(
|
||||
() =>
|
||||
new AxiosError(`Request failed with status code ${status}`, undefined, undefined, undefined, {
|
||||
status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: null,
|
||||
}),
|
||||
);
|
||||
const page = (size: number) => Array.from({ length: size }, (_, index) => ({ id: index }));
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -18,30 +32,42 @@ describe('HiboutikClient', () => {
|
||||
it('returns the list of a 2xx answer, empty or not', async () => {
|
||||
httpService.get.mockReturnValueOnce(answer([{ id: 1 }])).mockReturnValueOnce(answer([]));
|
||||
|
||||
await expect(client.getList('/x')).resolves.toEqual([{ id: 1 }]);
|
||||
await expect(client.getList('/x')).resolves.toEqual([]);
|
||||
await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]);
|
||||
await expect(client.getList('/x', schema)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a 2xx answer without body as an empty list', async () => {
|
||||
httpService.get.mockReturnValueOnce(answer('')).mockReturnValueOnce(answer(null));
|
||||
|
||||
await expect(client.getList('/x')).resolves.toEqual([]);
|
||||
await expect(client.getList('/x')).resolves.toEqual([]);
|
||||
await expect(client.getList('/x', schema)).resolves.toEqual([]);
|
||||
await expect(client.getList('/x', schema)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a 2xx answer that is not a list, without leaking its content', async () => {
|
||||
httpService.get.mockReturnValueOnce(answer({ error: 'secret details' }));
|
||||
|
||||
const result = client.getList('/x');
|
||||
const result = client.getList('/x', schema);
|
||||
|
||||
await expect(result).rejects.toThrow('a list was expected');
|
||||
await expect(result).rejects.not.toThrow('secret details');
|
||||
});
|
||||
|
||||
it('lets HTTP errors through', async () => {
|
||||
httpService.get.mockReturnValueOnce(throwError(() => new Error('Request failed with status code 500')));
|
||||
it('validates and normalizes each item', async () => {
|
||||
httpService.get.mockReturnValueOnce(answer([{ id: '1', extra: true }]));
|
||||
|
||||
await expect(client.getList('/x')).rejects.toThrow('500');
|
||||
await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it('rejects the whole list when an item is invalid, so that nothing is deleted', async () => {
|
||||
httpService.get.mockReturnValueOnce(answer([{ id: 1 }, { id: 'abc' }]));
|
||||
|
||||
await expect(client.getList('/x', schema)).rejects.toThrow('Unexpected answer from Hiboutik /x: invalid 1.id');
|
||||
});
|
||||
|
||||
it('lets HTTP errors through', async () => {
|
||||
httpService.get.mockReturnValueOnce(httpError(500));
|
||||
|
||||
await expect(client.getList('/x', schema)).rejects.toThrow('500');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,18 +75,140 @@ describe('HiboutikClient', () => {
|
||||
it('walks the pages until a short one', async () => {
|
||||
httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(answer(page(10)));
|
||||
|
||||
const results = await client.getAllPages('/x');
|
||||
const results = await client.getAllPages('/x', schema);
|
||||
|
||||
expect(results).toHaveLength(260);
|
||||
expect(httpService.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails as a whole when a later page fails, instead of returning a truncated list', async () => {
|
||||
httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(httpError(502));
|
||||
|
||||
await expect(client.getAllPages('/x', schema)).rejects.toThrow('502');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllPagesForSync', () => {
|
||||
const idOf = (item: { id: number }) => item.id;
|
||||
const ids = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => ({ id: from + index }));
|
||||
|
||||
it('walks a list that fits in one page only once', async () => {
|
||||
httpService.get.mockReturnValueOnce(answer(page(10)));
|
||||
|
||||
await expect(client.getAllPagesForSync('/x', schema, idOf)).resolves.toHaveLength(10);
|
||||
expect(httpService.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('walks a multi-page list twice and keeps an item skipped by a page shift in one walk', async () => {
|
||||
// First walk: item 10 is deleted right after page 1 is read, so item 250 moves to page 1 and page 2 starts at 251
|
||||
httpService.get.mockReturnValueOnce(answer(ids(0, 249))).mockReturnValueOnce(answer(ids(251, 260)));
|
||||
// Second walk: the list without item 10
|
||||
const withoutTen = ids(0, 250).filter(({ id }) => id !== 10);
|
||||
httpService.get.mockReturnValueOnce(answer(withoutTen)).mockReturnValueOnce(answer(ids(251, 260)));
|
||||
|
||||
const results = await client.getAllPagesForSync('/x', schema, idOf);
|
||||
|
||||
expect(httpService.get).toHaveBeenCalledTimes(4);
|
||||
// Item 250 is kept (it would otherwise be deleted); item 10 is only deleted at the next sync
|
||||
expect(results.map(idOf).sort((a, b) => a - b)).toEqual(ids(0, 260).map(idOf));
|
||||
});
|
||||
|
||||
it('fails as a whole when the second walk fails', async () => {
|
||||
httpService.get
|
||||
.mockReturnValueOnce(answer(page(250)))
|
||||
.mockReturnValueOnce(throwError(() => new Error('Request failed with status code 502')));
|
||||
.mockReturnValueOnce(answer(page(10)))
|
||||
.mockReturnValueOnce(httpError(502));
|
||||
|
||||
await expect(client.getAllPages('/x')).rejects.toThrow('502');
|
||||
await expect(client.getAllPagesForSync('/x', schema, idOf)).rejects.toThrow('502');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limit (429)', () => {
|
||||
let sleep: jest.SpyInstance;
|
||||
const tooManyRequests = (retryAfter?: string) =>
|
||||
throwError(
|
||||
() =>
|
||||
new AxiosError('Request failed with status code 429', undefined, undefined, undefined, {
|
||||
status: 429,
|
||||
statusText: '',
|
||||
headers: retryAfter === undefined ? {} : { 'retry-after': retryAfter },
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: null,
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
sleep = jest.spyOn(client as unknown as { sleep: (ms: number) => Promise<void> }, 'sleep').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('retries with exponential backoff, then succeeds', async () => {
|
||||
httpService.get
|
||||
.mockReturnValueOnce(tooManyRequests())
|
||||
.mockReturnValueOnce(tooManyRequests())
|
||||
.mockReturnValueOnce(answer([{ id: 1 }]));
|
||||
|
||||
await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]);
|
||||
expect(sleep.mock.calls).toEqual([[2_000], [4_000]]);
|
||||
});
|
||||
|
||||
it('waits as asked by Retry-After, capped at 60 s', async () => {
|
||||
httpService.get
|
||||
.mockReturnValueOnce(tooManyRequests('5'))
|
||||
.mockReturnValueOnce(tooManyRequests('3600'))
|
||||
.mockReturnValueOnce(answer([]));
|
||||
|
||||
await client.getList('/x', schema);
|
||||
|
||||
expect(sleep.mock.calls).toEqual([[5_000], [60_000]]);
|
||||
});
|
||||
|
||||
it('gives up with a HiboutikRateLimitError after 3 retries', async () => {
|
||||
httpService.get.mockReturnValue(tooManyRequests());
|
||||
|
||||
await expect(client.getList('/x', schema)).rejects.toThrow(HiboutikRateLimitError);
|
||||
expect(httpService.get).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('does not retry other errors', async () => {
|
||||
httpService.get.mockReturnValueOnce(httpError(500));
|
||||
|
||||
await expect(client.getList('/x', schema)).rejects.toThrow('500');
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not take a rate limited single resource for a missing one', async () => {
|
||||
httpService.get.mockReturnValue(tooManyRequests());
|
||||
|
||||
await expect(client.getOne('/x/7', schema)).rejects.toThrow(HiboutikRateLimitError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOne', () => {
|
||||
it('returns the single element of the answer', async () => {
|
||||
httpService.get.mockReturnValueOnce(answer([{ id: '7' }]));
|
||||
|
||||
await expect(client.getOne('/x/7', schema)).resolves.toEqual({ id: 7 });
|
||||
});
|
||||
|
||||
it('returns null when Hiboutik says the resource does not exist (404 or empty answer)', async () => {
|
||||
httpService.get.mockReturnValueOnce(httpError(404)).mockReturnValueOnce(answer([])).mockReturnValueOnce(answer(''));
|
||||
|
||||
await expect(client.getOne('/x/7', schema)).resolves.toBeNull();
|
||||
await expect(client.getOne('/x/7', schema)).resolves.toBeNull();
|
||||
await expect(client.getOne('/x/7', schema)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('throws on any other failure: an unknown state is never taken for a missing resource', async () => {
|
||||
httpService.get
|
||||
.mockReturnValueOnce(httpError(500))
|
||||
.mockReturnValueOnce(httpError(401))
|
||||
.mockReturnValueOnce(throwError(() => new Error('timeout of 30000ms exceeded')))
|
||||
.mockReturnValueOnce(answer({ error: 'x' }));
|
||||
|
||||
await expect(client.getOne('/x/7', schema)).rejects.toThrow('500');
|
||||
await expect(client.getOne('/x/7', schema)).rejects.toThrow('401');
|
||||
await expect(client.getOne('/x/7', schema)).rejects.toThrow('timeout');
|
||||
await expect(client.getOne('/x/7', schema)).rejects.toThrow('a list was expected');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { z } from 'zod';
|
||||
import { parseHiboutik } from '../models/hiboutikDTO/hiboutik.schema';
|
||||
|
||||
/** Results per page of every paginated Hiboutik endpoint (see "Results per page" in the API docs). */
|
||||
const HIBOUTIK_PAGE_SIZE = 250;
|
||||
|
||||
/** Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment. */
|
||||
/** Retries of a request answered 429 (too many requests), with exponential backoff: 2 s, 4 s, 8 s. */
|
||||
const RATE_LIMIT_RETRIES = 3;
|
||||
const RATE_LIMIT_BASE_DELAY_MS = 2_000;
|
||||
/** Cap of the wait asked by a `Retry-After` header: beyond, waiting holds the sync for nothing (monthly quota). */
|
||||
const RATE_LIMIT_MAX_DELAY_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Hiboutik still answers 429 after the retries: the rate limit (30 concurrent calls) or the monthly quota (10,000
|
||||
* calls per shop) is reached. The synchronization must stop: every next call would fail the same way.
|
||||
*/
|
||||
export class HiboutikRateLimitError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Hiboutik rate limit reached on ${path} (still 429 after ${RATE_LIMIT_RETRIES} retries): monthly quota exhausted?`);
|
||||
this.name = 'HiboutikRateLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Rethrows a {@link HiboutikRateLimitError}: for the per-item `catch` blocks of the sync, which must not swallow it. */
|
||||
export function rethrowIfRateLimited(error: unknown): void {
|
||||
if (error instanceof HiboutikRateLimitError) throw error;
|
||||
}
|
||||
|
||||
/** Wait asked by a `Retry-After` header (seconds or HTTP date), if any. */
|
||||
function retryAfterMs(header: unknown): number | undefined {
|
||||
if (typeof header !== 'string' || header.trim() === '') return undefined;
|
||||
const seconds = Number(header);
|
||||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
||||
const date = Date.parse(header);
|
||||
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment.
|
||||
* Every answer is validated against a zod schema (see `hiboutik.schema.ts`): a malformed one throws, like an HTTP error.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HiboutikClient {
|
||||
private readonly baseUrl: string;
|
||||
@@ -22,45 +59,109 @@ export class HiboutikClient {
|
||||
this.baseUrl = this.configService.get<string>('HIBOUTIK_BASE_URL') ?? '';
|
||||
}
|
||||
|
||||
/** GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the response body. */
|
||||
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<T>(`${this.baseUrl}${path}`, {
|
||||
headers: { Authorization: this.authHeader },
|
||||
params,
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
/**
|
||||
* GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the raw response body. A 429 is retried with
|
||||
* backoff (or after the `Retry-After` delay), then turns into a {@link HiboutikRateLimitError}.
|
||||
*/
|
||||
private async get(path: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<unknown>(`${this.baseUrl}${path}`, {
|
||||
headers: { Authorization: this.authHeader },
|
||||
params,
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (!isAxiosError(error) || error.response?.status !== 429) throw error;
|
||||
if (attempt >= RATE_LIMIT_RETRIES) throw new HiboutikRateLimitError(path);
|
||||
|
||||
const delay = retryAfterMs(error.response.headers['retry-after']) ?? RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt;
|
||||
await this.sleep(Math.min(delay, RATE_LIMIT_MAX_DELAY_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Separate method so that tests can skip the waits. */
|
||||
protected sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET on a list endpoint. HTTP errors (4xx/5xx) throw, a 2xx answer without body is an empty list, and any other
|
||||
* non-list body throws: an empty list triggers deletions in the sync, so it must never be inferred from a malformed answer.
|
||||
* non-list body or invalid item throws: an empty list triggers deletions in the sync, so it must never be inferred
|
||||
* from a malformed answer.
|
||||
*/
|
||||
async getList<T>(path: string, params?: Record<string, unknown>): Promise<T[]> {
|
||||
const data = await this.get<T[] | '' | null | undefined>(path, params);
|
||||
async getList<T extends z.ZodType>(path: string, itemSchema: T, params?: Record<string, unknown>): Promise<z.output<T>[]> {
|
||||
const data = await this.get(path, params);
|
||||
if (data === '' || data == null) return [];
|
||||
if (!Array.isArray(data)) throw new Error(`Unexpected answer from Hiboutik ${path}: a list was expected`);
|
||||
return data;
|
||||
return parseHiboutik(z.array(itemSchema), data, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results.
|
||||
* Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results.
|
||||
*/
|
||||
async getAllPages<T>(path: string, baseParams: Record<string, unknown> = {}): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
async getAllPages<T extends z.ZodType>(path: string, itemSchema: T, baseParams: Record<string, unknown> = {}): Promise<z.output<T>[]> {
|
||||
return (await this.walkPages(path, itemSchema, baseParams)).items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link getAllPages}, for a list whose content drives deletions. An item created or deleted in Hiboutik while
|
||||
* the pages are walked shifts the following pages, so an item can be skipped. When the list spans several pages, it
|
||||
* is walked a second time and both walks are merged by id (the second one wins): an item is missing from the result
|
||||
* only if both walks missed it. A list that fits in one page costs no extra call.
|
||||
*/
|
||||
async getAllPagesForSync<T extends z.ZodType>(
|
||||
path: string,
|
||||
itemSchema: T,
|
||||
idOf: (item: z.output<T>) => number,
|
||||
baseParams: Record<string, unknown> = {},
|
||||
): Promise<z.output<T>[]> {
|
||||
const firstWalk = await this.walkPages(path, itemSchema, baseParams);
|
||||
if (firstWalk.nbPages <= 1) return firstWalk.items;
|
||||
|
||||
const secondWalk = await this.walkPages(path, itemSchema, baseParams);
|
||||
const byId = new Map<number, z.output<T>>();
|
||||
for (const item of [...firstWalk.items, ...secondWalk.items]) byId.set(idOf(item), item);
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/** Walks the pages of a paginated endpoint. `nbPages` counts the pages holding results. */
|
||||
private async walkPages<T extends z.ZodType>(
|
||||
path: string,
|
||||
itemSchema: T,
|
||||
baseParams: Record<string, unknown>,
|
||||
): Promise<{ items: z.output<T>[]; nbPages: number }> {
|
||||
const items: z.output<T>[] = [];
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const pageData = await this.getList<T>(path, { ...baseParams, p: page });
|
||||
const pageData = await this.getList(path, itemSchema, { ...baseParams, p: page });
|
||||
if (pageData.length === 0) break;
|
||||
results.push(...pageData);
|
||||
items.push(...pageData);
|
||||
// A page smaller than the page size is the last one: no need to request an empty page
|
||||
if (pageData.length < HIBOUTIK_PAGE_SIZE) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return results;
|
||||
return { items, nbPages: Math.ceil(items.length / HIBOUTIK_PAGE_SIZE) };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET on a single-resource endpoint (Hiboutik answers with a one-element array).
|
||||
* @returns `null` when Hiboutik says the resource does not exist: a 404, or a 2xx answer that is empty.
|
||||
* @throws on any other HTTP error or malformed answer: an unknown state is never taken for a missing resource.
|
||||
*/
|
||||
async getOne<T extends z.ZodType>(path: string, schema: T): Promise<z.output<T> | null> {
|
||||
try {
|
||||
const [item] = await this.getList(path, schema);
|
||||
return item ?? null;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response?.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import { ProductHiboutikDto } from '../models/hiboutikDTO/product.hiboutik.dto';
|
||||
import { ProductHiboutikDto, productHiboutikSchema } from '../models/hiboutikDTO/product.hiboutik.dto';
|
||||
|
||||
/** Reads products from the Hiboutik API. */
|
||||
@Injectable()
|
||||
export class ProductHiboutikRepository {
|
||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||
|
||||
/** All products, archived ones included, with their sizes and per-size rules. */
|
||||
/**
|
||||
* All products, archived ones included, with their sizes and per-size rules. Walked twice when it spans several
|
||||
* pages: it drives the deletion of products (see `getAllPagesForSync`).
|
||||
*/
|
||||
async getProducts(): Promise<ProductHiboutikDto[]> {
|
||||
return this.hiboutikClient.getAllPages<ProductHiboutikDto>('/products', {
|
||||
return this.hiboutikClient.getAllPagesForSync('/products', productHiboutikSchema, (product) => product.product_id, {
|
||||
order_by: 'product_id',
|
||||
sort: 'ASC',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import { SaleHiboutikDto } from '../models/hiboutikDTO/sale.hiboutik.dto';
|
||||
import { SaleHiboutikDto, saleHiboutikSchema } from '../models/hiboutikDTO/sale.hiboutik.dto';
|
||||
|
||||
/** Reads sales from the Hiboutik API. */
|
||||
@Injectable()
|
||||
@@ -8,12 +8,11 @@ export class SaleHiboutikRepository {
|
||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||
|
||||
/**
|
||||
* Full sale, with its lines (Hiboutik answers with a one-element array).
|
||||
* Full sale, with its lines.
|
||||
* @throws if the sale does not exist.
|
||||
*/
|
||||
async getSaleById(id: number): Promise<SaleHiboutikDto> {
|
||||
const data = await this.hiboutikClient.get<SaleHiboutikDto[]>('/sales/' + String(id));
|
||||
const sale = data[0];
|
||||
const sale = await this.hiboutikClient.getOne(`/sales/${id}`, saleHiboutikSchema);
|
||||
if (!sale) throw new Error(`Hiboutik sale ${id} not found`);
|
||||
return sale;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import { StockHiboutikDto } from '../models/hiboutikDTO/stock.hiboutik.dto';
|
||||
import { StockHiboutikDto, stockHiboutikSchema } from '../models/hiboutikDTO/stock.hiboutik.dto';
|
||||
|
||||
/** Reads available stocks from the Hiboutik API. */
|
||||
@Injectable()
|
||||
@@ -9,12 +9,6 @@ export class StockHiboutikRepository {
|
||||
|
||||
/** Stock of every product and size, all warehouses combined. Not paginated. */
|
||||
async getStocks(): Promise<StockHiboutikDto[]> {
|
||||
const stocks = await this.hiboutikClient.getList<StockHiboutikDto>('/stock_available/all_wh', {});
|
||||
// Hiboutik may return ids as strings: normalize them so strict comparisons work downstream
|
||||
return stocks.map((stock) => ({
|
||||
...stock,
|
||||
product_id: Number(stock.product_id),
|
||||
product_size: Number(stock.product_size),
|
||||
}));
|
||||
return this.hiboutikClient.getList('/stock_available/all_wh', stockHiboutikSchema);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import { SupplierHiboutikDto } from '../models/hiboutikDTO/supplier.hiboutik.dto';
|
||||
import { SupplierHiboutikDto, supplierHiboutikSchema } from '../models/hiboutikDTO/supplier.hiboutik.dto';
|
||||
|
||||
/** Reads suppliers from the Hiboutik API. */
|
||||
@Injectable()
|
||||
@@ -9,7 +9,7 @@ export class SupplierHiboutikRepository {
|
||||
|
||||
/** `/suppliers` is not paginated: everything comes in one call. */
|
||||
async getSuppliers(): Promise<SupplierHiboutikDto[]> {
|
||||
return this.hiboutikClient.getList<SupplierHiboutikDto>('/suppliers', {
|
||||
return this.hiboutikClient.getList('/suppliers', supplierHiboutikSchema, {
|
||||
order_by: 'supplier_id',
|
||||
sort: 'ASC',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user