refactor(schema): simplify validation and OpenAPI generation (#2709)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 23:24:26 +02:00
1 parent 4bc5593f9f
commit de1fd95a99
510 files changed
+29494 -36131

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
export function chunkArray<T>(items: ReadonlyArray<T>, chunkSize: number): Array<Array<T>> {
assert(Number.isSafeInteger(chunkSize) && chunkSize > 0, 'Chunk size must be a positive safe integer');
const chunks: Array<Array<T>> = [];
for (let index = 0; index < items.length; index += chunkSize) {
chunks.push(items.slice(index, index + chunkSize));
}
return chunks;
}
@@ -1,10 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
export async function mapWithConcurrency<T, TResult>(
items: ReadonlyArray<T>,
concurrency: number,
mapper: (item: T, index: number) => Promise<TResult>,
): Promise<Array<TResult>> {
assert(Number.isSafeInteger(concurrency) && concurrency > 0, 'Concurrency must be a positive safe integer');
const results = new Array<TResult>(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
+2 -1
View File
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
import type {PremiumCurrency} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
export type Currency = 'USD' | 'EUR' | 'BRL' | 'DKK' | 'INR' | 'NOK' | 'PLN' | 'SEK' | 'TRY';
export type Currency = PremiumCurrency;
export function getCurrency(countryCode: string | null | undefined): Currency {
return getCurrencyPreferences(countryCode)[0];
+18 -9
View File
@@ -1,7 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
import type {HttpClient, RequestOptions, RequestUrlPolicy, StreamResponse} from '@pkgs/http_client/src/HttpClientTypes';
import type {
HttpClient,
RequestOptions,
RequestUrlPolicy,
ResponseStream,
StreamResponse,
} from '@pkgs/http_client/src/HttpClientTypes';
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
const requestUrlPolicy = createPublicInternetRequestUrlPolicy();
@@ -40,7 +46,7 @@ function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
return scopedClient;
}
export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions) {
export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions): Promise<StreamResponse> {
const requestClient = getHttpClientForRequest(options);
return requestClient.sendRequest(opts);
}
@@ -56,7 +62,7 @@ export class ResponseBodyTooLargeError extends Error {
}
}
interface StreamToStringWithLimitOptions {
interface ResponseBodyReadOptions {
maxBytes: number;
headers?: Headers;
url?: string;
@@ -77,7 +83,7 @@ function parseContentLength(value: string | null | undefined): number | null {
}
function createResponseBodyTooLargeError(
options: StreamToStringWithLimitOptions,
options: ResponseBodyReadOptions,
actualBytes: number | null,
): ResponseBodyTooLargeError {
const description = options.description ?? 'Response body';
@@ -91,8 +97,8 @@ function createResponseBodyTooLargeError(
}
export async function streamToBufferWithLimit(
stream: StreamResponse['stream'],
options: StreamToStringWithLimitOptions,
stream: ResponseStream,
options: ResponseBodyReadOptions,
): Promise<Uint8Array> {
if (!stream) {
return new Uint8Array(0);
@@ -124,6 +130,9 @@ export async function streamToBufferWithLimit(
throw abortError;
}
const {done, value} = await reader.read();
if (options.signal?.aborted) {
throw abortError;
}
if (done) {
break;
}
@@ -133,7 +142,7 @@ export async function streamToBufferWithLimit(
totalSize += value.byteLength;
if (totalSize > options.maxBytes) {
const error = createResponseBodyTooLargeError(options, totalSize);
await reader.cancel(error).catch(() => {});
void reader.cancel(error).catch(() => {});
throw error;
}
chunks.push(value);
@@ -155,8 +164,8 @@ export async function streamToBufferWithLimit(
}
export async function streamToStringWithLimit(
stream: StreamResponse['stream'],
options: StreamToStringWithLimitOptions,
stream: ResponseStream,
options: ResponseBodyReadOptions,
): Promise<string> {
const merged = await streamToBufferWithLimit(stream, options);
return new TextDecoder().decode(merged);
@@ -0,0 +1,37 @@
import {describe, expect, it} from 'vitest';
import {chunkArray} from '../ArrayUtils';
describe('chunkArray', () => {
it.each([
{size: 1, expected: [[1], [2], [3], [4], [5]]},
{size: 2, expected: [[1, 2], [3, 4], [5]]},
{size: 5, expected: [[1, 2, 3, 4, 5]]},
{size: 10, expected: [[1, 2, 3, 4, 5]]},
])('partitions input into chunks of at most $size', ({size, expected}) => {
expect(chunkArray([1, 2, 3, 4, 5], size)).toEqual(expected);
});
it('preserves the input and returns independent chunk arrays', () => {
const items = Object.freeze([1, 2, 3]);
const chunks = chunkArray(items, 2);
chunks[0]!.push(4);
expect(items).toEqual([1, 2, 3]);
expect(chunks).toEqual([[1, 2, 4], [3]]);
});
it('returns no chunks for empty input', () => {
expect(chunkArray([], 2)).toEqual([]);
});
it.each([
0,
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER + 1,
])('rejects invalid chunk size %j even for empty input', (size) => {
expect(() => chunkArray([1], size)).toThrow('Chunk size must be a positive safe integer');
expect(() => chunkArray([], size)).toThrow('Chunk size must be a positive safe integer');
});
});
@@ -0,0 +1,117 @@
import {describe, expect, it} from 'vitest';
import {mapWithConcurrency} from '../ConcurrencyUtils';
describe('mapWithConcurrency', () => {
it('limits in-flight work and preserves input order when later items finish first', async () => {
const items = ['a', 'b', 'c', 'd'];
const gates = items.map(() => Promise.withResolvers<number>());
const thirdStarted = Promise.withResolvers<void>();
const fourthStarted = Promise.withResolvers<void>();
const started: Array<[string, number]> = [];
let active = 0;
let peakActive = 0;
const result = mapWithConcurrency(items, 2, async (item, index) => {
started.push([item, index]);
active++;
peakActive = Math.max(peakActive, active);
if (index === 2) thirdStarted.resolve();
if (index === 3) fourthStarted.resolve();
try {
return await gates[index]!.promise;
} finally {
active--;
}
});
expect(started).toEqual([
['a', 0],
['b', 1],
]);
gates[1]!.resolve(20);
await thirdStarted.promise;
expect(started).toEqual([
['a', 0],
['b', 1],
['c', 2],
]);
expect(active).toBe(2);
gates[2]!.resolve(30);
await fourthStarted.promise;
expect(started).toEqual([
['a', 0],
['b', 1],
['c', 2],
['d', 3],
]);
gates[3]!.resolve(40);
gates[0]!.resolve(10);
await expect(result).resolves.toEqual([10, 20, 30, 40]);
expect(peakActive).toBe(2);
expect(active).toBe(0);
});
it.each([1, 2, 10])('maps every item exactly once with concurrency %i', async (concurrency) => {
const indexes: Array<number> = [];
const result = await mapWithConcurrency([10, 20, 30], concurrency, async (item, index) => {
indexes.push(index);
return item + index;
});
expect(indexes).toEqual([0, 1, 2]);
expect(result).toEqual([10, 21, 32]);
});
it('returns an empty array without invoking the mapper', async () => {
await expect(
mapWithConcurrency([], 2, () => {
throw new Error('An empty input must not invoke the mapper');
}),
).resolves.toEqual([]);
});
it.each([
0,
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER + 1,
])('rejects invalid concurrency %j before scheduling work', async (concurrency) => {
let calls = 0;
const mapper = async () => {
calls++;
return 1;
};
await expect(mapWithConcurrency([1], concurrency, mapper)).rejects.toThrow(
'Concurrency must be a positive safe integer',
);
await expect(mapWithConcurrency([], concurrency, mapper)).rejects.toThrow(
'Concurrency must be a positive safe integer',
);
expect(calls).toBe(0);
});
it('propagates a mapper rejection without waiting for another in-flight item', async () => {
const first = Promise.withResolvers<number>();
const second = Promise.withResolvers<number>();
const failure = new Error('Mapper failed');
const result = mapWithConcurrency([first.promise, second.promise], 2, (promise) => promise);
const rejected = expect(result).rejects.toBe(failure);
first.reject(failure);
await rejected;
second.resolve(2);
await second.promise;
});
it('rejects synchronous mapper failures without starting queued serial work', async () => {
const failure = new Error('Mapper failed synchronously');
const started: Array<number> = [];
await expect(
mapWithConcurrency([1, 2], 1, (item) => {
started.push(item);
throw failure;
}),
).rejects.toBe(failure);
expect(started).toEqual([1]);
});
});
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {describe, expect, it, vi} from 'vitest';
import * as FetchUtils from '../FetchUtils';
function createStream(chunks: Array<string>): ReadableStream<Uint8Array> {
@@ -18,31 +18,76 @@ function createStream(chunks: Array<string>): ReadableStream<Uint8Array> {
});
}
describe('FetchUtils', () => {
describe('streamToBufferWithLimit', () => {
it('returns the full response body when it is within the limit', async () => {
const body = await FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
maxBytes: 32,
describe('streamToBufferWithLimit', () => {
it('returns the full response body when it is within the limit', async () => {
const body = await FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
maxBytes: 32,
description: 'Test response',
});
expect(new TextDecoder().decode(body)).toBe('hello world');
});
it('rejects when the declared content-length exceeds the configured limit', async () => {
await expect(
FetchUtils.streamToBufferWithLimit(createStream(['hello']), {
maxBytes: 4,
headers: new Headers({'Content-Length': '5'}),
description: 'Test response',
});
expect(new TextDecoder().decode(body)).toBe('hello world');
});
it('rejects when the declared content-length exceeds the configured limit', async () => {
await expect(
FetchUtils.streamToBufferWithLimit(createStream(['hello']), {
maxBytes: 4,
headers: new Headers({'Content-Length': '5'}),
description: 'Test response',
}),
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
});
it('rejects when the streamed body grows beyond the configured limit', async () => {
await expect(
FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
maxBytes: 5,
description: 'Test response',
}),
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
}),
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
});
it('rejects when the streamed body grows beyond the configured limit', async () => {
await expect(
FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
maxBytes: 5,
description: 'Test response',
}),
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
});
it('rejects an abort during a pending read instead of returning a partial response', async () => {
const pendingRead = Promise.withResolvers<void>();
const abort = new AbortController();
const stream = new ReadableStream<Uint8Array>(
{
start(controller) {
controller.enqueue(new TextEncoder().encode('partial'));
},
pull() {
pendingRead.resolve();
},
},
{highWaterMark: 0},
);
const result = FetchUtils.streamToBufferWithLimit(stream, {maxBytes: 32, signal: abort.signal});
await pendingRead.promise;
abort.abort();
await expect(result).rejects.toMatchObject({name: 'AbortError'});
expect(stream.locked).toBe(false);
});
it('rejects an oversized response without waiting for cancellation to finish', async () => {
vi.useFakeTimers();
const cancelled = Promise.withResolvers<void>();
const cleanup = Promise.withResolvers<void>();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(5));
},
cancel() {
cancelled.resolve();
return cleanup.promise;
},
});
const settled = vi.fn();
const result = FetchUtils.streamToBufferWithLimit(stream, {maxBytes: 4}).then(settled, settled);
try {
await cancelled.promise;
await vi.advanceTimersByTimeAsync(0);
expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(FetchUtils.ResponseBodyTooLargeError));
expect(stream.locked).toBe(false);
} finally {
cleanup.resolve();
await result;
vi.useRealTimers();
}
});
});
+32 -70
View File
@@ -4,81 +4,43 @@ import {describe, expect, it} from 'vitest';
import {toIdString, toSortedIdArray} from '../IdUtils';
describe('toIdString', () => {
it('returns null for null input', () => {
expect(toIdString(null)).toBeNull();
});
it('returns null for undefined input', () => {
expect(toIdString(undefined)).toBeNull();
});
it('converts bigint to string', () => {
expect(toIdString(123456789012345678n)).toBe('123456789012345678');
});
it('converts string to string (passthrough)', () => {
expect(toIdString('123456789012345678')).toBe('123456789012345678');
});
it('handles zero bigint', () => {
expect(toIdString(0n)).toBe('0');
});
it('handles very large bigints', () => {
const large = 999999999999999999999999999999n;
expect(toIdString(large)).toBe(large.toString());
});
it('handles negative bigints', () => {
expect(toIdString(-1n)).toBe('-1');
});
it('preserves numeric string exactly', () => {
expect(toIdString('0')).toBe('0');
expect(toIdString('00123')).toBe('00123');
it.each([
[null, null],
[undefined, null],
[123456789012345678n, '123456789012345678'],
['123456789012345678', '123456789012345678'],
[0n, '0'],
[999999999999999999999999999999n, '999999999999999999999999999999'],
[-1n, '-1'],
['0', '0'],
['00123', '00123'],
])('preserves the exact ID representation of %s', (input, expected) => {
expect(toIdString(input)).toBe(expected);
});
});
describe('toSortedIdArray', () => {
it('returns empty array for null input', () => {
expect(toSortedIdArray(null)).toEqual([]);
it.each([
{input: null, expected: []},
{input: undefined, expected: []},
{input: [], expected: []},
{input: new Set<bigint>(), expected: []},
{input: [300n, 100n, 200n], expected: ['100', '200', '300']},
{input: ['300', '100', '200'], expected: ['100', '200', '300']},
{input: new Set([300n, 100n, 200n]), expected: ['100', '200', '300']},
{input: [42n], expected: ['42']},
{input: new Set([123n]), expected: ['123']},
{input: [100n, 200n, 300n], expected: ['100', '200', '300']},
{input: [300n, 200n, 100n], expected: ['100', '200', '300']},
{input: [2n, 10n, 1n], expected: ['1', '10', '2']},
{input: [100n, 100n, 200n], expected: ['100', '100', '200']},
])('stringifies and lexicographically sorts case %#', ({input, expected}) => {
expect(toSortedIdArray<bigint | string>(input)).toEqual(expected);
});
it('returns empty array for undefined input', () => {
expect(toSortedIdArray(undefined)).toEqual([]);
});
it('returns empty array for empty array input', () => {
expect(toSortedIdArray([])).toEqual([]);
});
it('converts and sorts array of bigints', () => {
it('does not reorder the caller-owned array', () => {
const input = [300n, 100n, 200n];
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('converts and sorts array of strings', () => {
const input = ['300', '100', '200'];
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('converts Set to sorted array', () => {
const input = new Set([300n, 100n, 200n]);
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('handles single element array', () => {
expect(toSortedIdArray([42n])).toEqual(['42']);
});
it('handles already sorted input', () => {
const input = [100n, 200n, 300n];
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('handles reverse sorted input', () => {
const input = [300n, 200n, 100n];
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
});
it('sorts lexicographically (string sort)', () => {
const input = [2n, 10n, 1n];
expect(toSortedIdArray(input)).toEqual(['1', '10', '2']);
});
it('handles duplicates in input array', () => {
const input = [100n, 100n, 200n];
expect(toSortedIdArray(input)).toEqual(['100', '100', '200']);
});
it('handles Set with single element', () => {
const input = new Set([123n]);
expect(toSortedIdArray(input)).toEqual(['123']);
});
it('handles empty Set', () => {
const input = new Set<bigint>();
expect(toSortedIdArray(input)).toEqual([]);
toSortedIdArray(input);
expect(input).toEqual([300n, 100n, 200n]);
});
});
@@ -4,79 +4,40 @@ import {describe, expect, it} from 'vitest';
import {hasVisibleContent, parseString} from '../StringUtils';
describe('parseString', () => {
it('returns trimmed string within max length', () => {
expect(parseString('Hello World', 50)).toBe('Hello World');
});
it('trims whitespace from the string', () => {
expect(parseString(' Hello World ', 50)).toBe('Hello World');
});
it('truncates string exceeding max length', () => {
const result = parseString('This is a very long string that needs truncation', 20);
expect(result.length).toBeLessThanOrEqual(20);
expect(result).toContain('...');
});
it('handles exact max length', () => {
const result = parseString('12345', 5);
expect(result).toBe('12345');
});
it('decodes HTML entities before processing', () => {
expect(parseString('Hello &amp; World', 50)).toBe('Hello & World');
expect(parseString('&lt;script&gt;', 50)).toBe('<script>');
});
it('handles empty string', () => {
expect(parseString('', 50)).toBe('');
});
it('handles string with only whitespace', () => {
expect(parseString(' ', 50)).toBe('');
});
it('handles unicode characters', () => {
expect(parseString('Hello \u{1F600} World', 50)).toBe('Hello \u{1F600} World');
});
it('truncates with ellipsis by default', () => {
const result = parseString('This is a test string for truncation', 15);
expect(result.endsWith('...')).toBe(true);
});
it('handles max length of 0 (lodash truncate returns ellipsis)', () => {
const result = parseString('Hello', 0);
expect(result).toBe('...');
});
it('handles max length of 1 (lodash truncate minimum is ellipsis length)', () => {
const result = parseString('Hello', 1);
expect(result).toBe('...');
});
it('handles max length of 3 (minimum for truncation)', () => {
const result = parseString('Hello', 3);
expect(result).toBe('...');
});
it('handles max length of 4', () => {
const result = parseString('Hello', 4);
expect(result.length).toBeLessThanOrEqual(4);
});
it('preserves string with special characters', () => {
const input = 'Hello\nWorld\tTab';
const result = parseString(input, 50);
expect(result).toContain('\n');
expect(result).toContain('\t');
});
it('handles combined HTML entities and long strings', () => {
const input = '&amp; '.repeat(20);
const result = parseString(input, 30);
expect(result.length).toBeLessThanOrEqual(30);
it.each([
{input: 'Hello World', max: 50, expected: 'Hello World'},
{input: ' Hello World ', max: 50, expected: 'Hello World'},
{input: 'This is a very long string that needs truncation', max: 20, expected: 'This is a very lo...'},
{input: '12345', max: 5, expected: '12345'},
{input: 'Hello &amp; World', max: 50, expected: 'Hello & World'},
{input: '&lt;script&gt;', max: 50, expected: '<script>'},
{input: '', max: 50, expected: ''},
{input: ' ', max: 50, expected: ''},
{input: 'Hello 😀 World', max: 50, expected: 'Hello 😀 World'},
{input: 'This is a test string for truncation', max: 15, expected: 'This is a te...'},
{input: 'Hello', max: 0, expected: '...'},
{input: 'Hello', max: 1, expected: '...'},
{input: 'Hello', max: 3, expected: '...'},
{input: 'Hello', max: 4, expected: 'H...'},
{input: 'Hello\nWorld\tTab', max: 50, expected: 'Hello\nWorld\tTab'},
{input: '&amp;&amp;&amp;abc', max: 5, expected: '&&...'},
{input: '😀abcd', max: 4, expected: '😀...'},
])('decodes, trims, and truncates $input to $expected', ({input, max, expected}) => {
expect(parseString(input, max)).toBe(expected);
});
});
describe('hasVisibleContent', () => {
it('rejects whitespace and invisible-only strings', () => {
expect(hasVisibleContent('')).toBe(false);
expect(hasVisibleContent(' \t\n')).toBe(false);
expect(hasVisibleContent('\u200e \u200b\ufeff')).toBe(false);
expect(hasVisibleContent('\u2800\u3164\u{e0100}')).toBe(false);
it.each([
'',
' \t\n',
'\u200e \u200b\ufeff',
'\u2800\u3164\u{e0100}',
])('rejects whitespace or invisible-only input %j', (input) => {
expect(hasVisibleContent(input)).toBe(false);
});
it('allows any visible character', () => {
expect(hasVisibleContent('hello')).toBe(true);
expect(hasVisibleContent('\u200e hello')).toBe(true);
expect(hasVisibleContent('🙂')).toBe(true);
expect(hasVisibleContent('` `')).toBe(true);
it.each(['hello', '\u200e hello', '🙂', '` `'])('accepts visible input %j', (input) => {
expect(hasVisibleContent(input)).toBe(true);
});
});