feat(utils): add generic buildQueryString utility

- Add type-safe buildQueryString function to /utils
- Support primitives, arrays, and optional values
- Proper URL encoding for special characters
- Add comprehensive tests (25 test cases, all passing)
- Update Google Fonts API client to use shared utility
- Update Fontshare API client to use shared utility
- Export utility from /utils/index.ts

Benefits:
- DRY - Single source of truth for query string logic
- Type-safe - Proper TypeScript support with QueryParams type
- Tested - Comprehensive test coverage
- Maintainable - One place to fix bugs
This commit is contained in:
Ilia Mashkov
2026-01-06 15:00:31 +03:00
parent 29d1cc0cdc
commit 9abec4210c
9 changed files with 303 additions and 84 deletions

View File

@@ -12,11 +12,13 @@ import type {
FontshareFont,
} from '$entities/Font';
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/utils';
import type { QueryParams } from '$shared/utils';
/**
* Fontshare API parameters
*/
export interface FontshareParams {
export interface FontshareParams extends QueryParams {
/**
* Filter by categories (e.g., ["Sans", "Serif", "Display"])
*/
@@ -47,36 +49,6 @@ export interface FontshareResponse extends FontshareApiModel {
// Response structure matches FontshareApiModel
}
/**
* Build query string from parameters
*/
function buildQueryString(params: FontshareParams): string {
const searchParams = new URLSearchParams();
if (params.categories?.length) {
searchParams.append('categories', params.categories.join(','));
}
if (params.tags?.length) {
searchParams.append('tags', params.tags.join(','));
}
if (params.page) {
searchParams.append('page', String(params.page));
}
if (params.limit) {
searchParams.append('limit', String(params.limit));
}
if (params.search) {
searchParams.append('search', params.search);
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
/**
* Fetch fonts from Fontshare API
*

View File

@@ -8,11 +8,13 @@
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/utils';
import type { QueryParams } from '$shared/utils';
/**
* Google Fonts API parameters
*/
export interface GoogleFontsParams {
export interface GoogleFontsParams extends QueryParams {
/**
* Google Fonts API key (optional for public endpoints)
*/
@@ -66,40 +68,6 @@ export interface GoogleFontItem {
*/
const GOOGLE_FONTS_API_URL = 'https://fonts.googleapis.com/v2/fonts' as const;
/**
* Build query string from parameters
*/
function buildQueryString(params: GoogleFontsParams): string {
const searchParams = new URLSearchParams();
if (params.key) {
searchParams.append('key', params.key);
}
if (params.family) {
searchParams.append('family', params.family);
}
if (params.category) {
searchParams.append('category', params.category);
}
if (params.subset) {
searchParams.append('subset', params.subset);
}
if (params.sort) {
searchParams.append('sort', params.sort);
}
if (params.capability) {
searchParams.append('capability', params.capability);
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
/**
* Fetch fonts from Google Fonts API
*

View File

@@ -7,33 +7,33 @@
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './googleFonts';
} from './google/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './googleFonts';
} from './google/googleFonts';
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './fontshare';
} from './fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './fontshare';
} from './fontshare/fontshare';
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize';
} from './normalize/normalize';
export type {
FontFeatures,
FontMetadata,
FontStyleUrls,
UnifiedFont,
UnifiedFontVariant,
} from './normalize';
} from './normalize/normalize';

View File

@@ -1,17 +1,17 @@
import type { FontshareFont } from '$entities/Font';
import type { GoogleFontItem } from '$entities/Font/api/googleFonts';
import type { UnifiedFont } from '$entities/Font/api/normalize';
import {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from '$entities/Font/api/normalize';
import {
describe,
expect,
it,
} from 'vitest';
import type { GoogleFontItem } from '../google/googleFonts';
import type { UnifiedFont } from './normalize';
import {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './normalize';
describe('Font Normalization', () => {
describe('normalizeGoogleFont', () => {

View File

@@ -8,10 +8,7 @@
*/
import type { UnifiedFont } from '$entities/Font/api/normalize';
import {
type CollectionCacheManager,
createCollectionCache,
} from '$shared/fetch/collectionCache';
import { createCollectionCache } from '$shared/fetch/collectionCache';
import type {
Readable,
Writable,

View File

@@ -0,0 +1,194 @@
/**
* Tests for buildQueryString utility
*/
import {
describe,
expect,
test,
} from 'vitest';
import { buildQueryString } from './buildQueryString';
describe('buildQueryString', () => {
describe('basic parameter building', () => {
test('should build query string with string parameter', () => {
const result = buildQueryString({ category: 'serif' });
expect(result).toBe('?category=serif');
});
test('should build query string with number parameter', () => {
const result = buildQueryString({ limit: 50 });
expect(result).toBe('?limit=50');
});
test('should build query string with boolean parameter', () => {
const result = buildQueryString({ active: true });
expect(result).toBe('?active=true');
});
test('should build query string with multiple parameters', () => {
const result = buildQueryString({
category: 'serif',
limit: 50,
page: 1,
});
expect(result).toBe('?category=serif&limit=50&page=1');
});
});
describe('array handling', () => {
test('should handle array of strings', () => {
const result = buildQueryString({
subsets: ['latin', 'latin-ext', 'cyrillic'],
});
expect(result).toBe('?subsets=latin&subsets=latin-ext&subsets=cyrillic');
});
test('should handle array of numbers', () => {
const result = buildQueryString({ ids: [1, 2, 3] });
expect(result).toBe('?ids=1&ids=2&ids=3');
});
test('should handle mixed arrays and primitives', () => {
const result = buildQueryString({
category: 'serif',
subsets: ['latin', 'latin-ext'],
limit: 50,
});
expect(result).toBe('?category=serif&subsets=latin&subsets=latin-ext&limit=50');
});
test('should filter out null/undefined values in arrays', () => {
const result = buildQueryString({
// @ts-expect-error - Testing runtime behavior with invalid types
ids: [1, null, 3, undefined],
});
expect(result).toBe('?ids=1&ids=3');
});
});
describe('optional values', () => {
test('should exclude undefined values', () => {
const result = buildQueryString({
category: 'serif',
search: undefined,
});
expect(result).toBe('?category=serif');
});
test('should exclude null values', () => {
const result = buildQueryString({
category: 'serif',
search: null,
});
expect(result).toBe('?category=serif');
});
test('should handle all undefined/null values', () => {
const result = buildQueryString({
category: undefined,
search: null,
});
expect(result).toBe('');
});
});
describe('URL encoding', () => {
test('should encode spaces', () => {
const result = buildQueryString({ search: 'hello world' });
expect(result).toBe('?search=hello+world');
});
test('should encode special characters', () => {
const result = buildQueryString({ query: 'a&b=c+d' });
expect(result).toBe('?query=a%26b%3Dc%2Bd');
});
test('should encode Unicode characters', () => {
const result = buildQueryString({ text: 'café' });
expect(result).toBe('?text=caf%C3%A9');
});
test('should encode reserved URL characters', () => {
const result = buildQueryString({ url: 'https://example.com' });
expect(result).toBe('?url=https%3A%2F%2Fexample.com');
});
});
describe('edge cases', () => {
test('should return empty string for empty object', () => {
const result = buildQueryString({});
expect(result).toBe('');
});
test('should return empty string when all values are excluded', () => {
const result = buildQueryString({
a: undefined,
b: null,
});
expect(result).toBe('');
});
test('should handle empty arrays', () => {
const result = buildQueryString({ tags: [] });
expect(result).toBe('');
});
test('should handle zero values', () => {
const result = buildQueryString({ page: 0, count: 0 });
expect(result).toBe('?page=0&count=0');
});
test('should handle false boolean', () => {
const result = buildQueryString({ active: false });
expect(result).toBe('?active=false');
});
test('should handle empty string', () => {
const result = buildQueryString({ search: '' });
expect(result).toBe('?search=');
});
});
describe('parameter order', () => {
test('should maintain parameter order from input object', () => {
const result = buildQueryString({
a: '1',
b: '2',
c: '3',
});
expect(result).toBe('?a=1&b=2&c=3');
});
});
describe('real-world examples', () => {
test('should handle Google Fonts API parameters', () => {
const result = buildQueryString({
category: 'sans-serif',
sort: 'popularity',
subset: 'latin',
});
expect(result).toBe('?category=sans-serif&sort=popularity&subset=latin');
});
test('should handle Fontshare API parameters', () => {
const result = buildQueryString({
categories: ['Sans', 'Serif'],
page: 1,
limit: 50,
search: 'satoshi',
});
expect(result).toBe('?categories=Sans&categories=Serif&page=1&limit=50&search=satoshi');
});
test('should handle pagination parameters', () => {
const result = buildQueryString({
page: 2,
per_page: 20,
sort: 'name',
order: 'desc',
});
expect(result).toBe('?page=2&per_page=20&sort=name&order=desc');
});
});
});

View File

@@ -0,0 +1,79 @@
/**
* Build query string from URL parameters
*
* Generic, type-safe function to build properly encoded query strings
* from URL parameters. Supports primitives, arrays, and optional values.
*
* @param params - Object containing query parameters
* @returns Encoded query string (empty string if no parameters)
*
* @example
* ```ts
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
* // Returns: "category=serif&subsets=latin&subsets=latin-ext"
*
* buildQueryString({ limit: 50, page: 1 })
* // Returns: "limit=50&page=1"
*
* buildQueryString({})
* // Returns: ""
*
* buildQueryString({ search: 'hello world', active: true })
* // Returns: "search=hello%20world&active=true"
* ```
*/
/**
* Query parameter value type
* Supports primitives, arrays, and excludes null/undefined
*/
export type QueryParamValue = string | number | boolean | string[] | number[];
/**
* Query parameters object
*/
export type QueryParams = Record<string, QueryParamValue | undefined | null>;
/**
* Build query string from URL parameters
*
* Handles:
* - Primitive values (string, number, boolean)
* - Arrays (multiple values with same key)
* - Optional values (excludes undefined/null)
* - Proper URL encoding
*
* Edge cases:
* - Empty object → empty string
* - No parameters → empty string
* - Nested objects → flattens to string representation
* - Special characters → proper encoding
*
* @param params - Object containing query parameters
* @returns Encoded query string (with "?" prefix if non-empty)
*/
export function buildQueryString(params: QueryParams): string {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
// Skip undefined/null values
if (value === undefined || value === null) {
continue;
}
// Handle arrays (multiple values with same key)
if (Array.isArray(value)) {
for (const item of value) {
if (item !== undefined && item !== null) {
searchParams.append(key, String(item));
}
}
} else {
// Handle primitives
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}

View File

@@ -0,0 +1,9 @@
/**
* Shared utility functions
*/
export { buildQueryString } from './buildQueryString';
export type {
QueryParams,
QueryParamValue,
} from './buildQueryString';