From 9abec4210c1b7632b6ec026d792b0ee507e19035 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 15:00:31 +0300 Subject: [PATCH] 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 --- .../Font/api/{ => fontshare}/fontshare.ts | 34 +-- .../Font/api/{ => google}/googleFonts.ts | 38 +--- src/entities/Font/api/index.ts | 12 +- .../api/{ => normalize}/normalize.test.ts | 16 +- .../Font/api/{ => normalize}/normalize.ts | 0 .../Font/model/stores/fontCollectionStore.ts | 5 +- src/shared/utils/buildQueryString.test.ts | 194 ++++++++++++++++++ src/shared/utils/buildQueryString.ts | 79 +++++++ src/shared/utils/index.ts | 9 + 9 files changed, 303 insertions(+), 84 deletions(-) rename src/entities/Font/api/{ => fontshare}/fontshare.ts (82%) rename src/entities/Font/api/{ => google}/googleFonts.ts (78%) rename src/entities/Font/api/{ => normalize}/normalize.test.ts (99%) rename src/entities/Font/api/{ => normalize}/normalize.ts (100%) create mode 100644 src/shared/utils/buildQueryString.test.ts create mode 100644 src/shared/utils/buildQueryString.ts create mode 100644 src/shared/utils/index.ts diff --git a/src/entities/Font/api/fontshare.ts b/src/entities/Font/api/fontshare/fontshare.ts similarity index 82% rename from src/entities/Font/api/fontshare.ts rename to src/entities/Font/api/fontshare/fontshare.ts index 6e6df87..ae8420a 100644 --- a/src/entities/Font/api/fontshare.ts +++ b/src/entities/Font/api/fontshare/fontshare.ts @@ -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 * diff --git a/src/entities/Font/api/googleFonts.ts b/src/entities/Font/api/google/googleFonts.ts similarity index 78% rename from src/entities/Font/api/googleFonts.ts rename to src/entities/Font/api/google/googleFonts.ts index 289a2a7..de3295b 100644 --- a/src/entities/Font/api/googleFonts.ts +++ b/src/entities/Font/api/google/googleFonts.ts @@ -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 * diff --git a/src/entities/Font/api/index.ts b/src/entities/Font/api/index.ts index 25f7752..6656408 100644 --- a/src/entities/Font/api/index.ts +++ b/src/entities/Font/api/index.ts @@ -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'; diff --git a/src/entities/Font/api/normalize.test.ts b/src/entities/Font/api/normalize/normalize.test.ts similarity index 99% rename from src/entities/Font/api/normalize.test.ts rename to src/entities/Font/api/normalize/normalize.test.ts index 60eb1b1..f757d06 100644 --- a/src/entities/Font/api/normalize.test.ts +++ b/src/entities/Font/api/normalize/normalize.test.ts @@ -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', () => { diff --git a/src/entities/Font/api/normalize.ts b/src/entities/Font/api/normalize/normalize.ts similarity index 100% rename from src/entities/Font/api/normalize.ts rename to src/entities/Font/api/normalize/normalize.ts diff --git a/src/entities/Font/model/stores/fontCollectionStore.ts b/src/entities/Font/model/stores/fontCollectionStore.ts index 7edc0be..f9e1aa9 100644 --- a/src/entities/Font/model/stores/fontCollectionStore.ts +++ b/src/entities/Font/model/stores/fontCollectionStore.ts @@ -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, diff --git a/src/shared/utils/buildQueryString.test.ts b/src/shared/utils/buildQueryString.test.ts new file mode 100644 index 0000000..b7e1d7a --- /dev/null +++ b/src/shared/utils/buildQueryString.test.ts @@ -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'); + }); + }); +}); diff --git a/src/shared/utils/buildQueryString.ts b/src/shared/utils/buildQueryString.ts new file mode 100644 index 0000000..fc09249 --- /dev/null +++ b/src/shared/utils/buildQueryString.ts @@ -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; + +/** + * 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}` : ''; +} diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 0000000..32e423b --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1,9 @@ +/** + * Shared utility functions + */ + +export { buildQueryString } from './buildQueryString'; +export type { + QueryParams, + QueryParamValue, +} from './buildQueryString';