refactor(Font): consolidate API layer and update type structure
This commit is contained in:
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Fontshare API client
|
||||
*
|
||||
* Handles API requests to Fontshare API for fetching font metadata.
|
||||
* Provides error handling, pagination support, and type-safe responses.
|
||||
*
|
||||
* Pagination: The Fontshare API DOES support pagination via `page` and `limit` parameters.
|
||||
* However, the current implementation uses `fetchAllFontshareFonts()` to fetch all fonts upfront.
|
||||
* For future optimization, consider implementing incremental pagination for large datasets.
|
||||
*
|
||||
* @see https://fontshare.com
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
import type {
|
||||
FontshareApiModel,
|
||||
FontshareFont,
|
||||
} from '../../model/types/fontshare';
|
||||
|
||||
/**
|
||||
* Fontshare API parameters
|
||||
*/
|
||||
export interface FontshareParams extends QueryParams {
|
||||
/**
|
||||
* Filter by categories (e.g., ["Sans", "Serif", "Display"])
|
||||
*/
|
||||
categories?: string[];
|
||||
/**
|
||||
* Filter by tags (e.g., ["Magazines", "Branding", "Logos"])
|
||||
*/
|
||||
tags?: string[];
|
||||
/**
|
||||
* Page number for pagination (1-indexed)
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Number of items per page
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Search query to filter fonts
|
||||
*/
|
||||
q?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fontshare API response wrapper
|
||||
* Re-exported from model/types/fontshare for backward compatibility
|
||||
*/
|
||||
export type FontshareResponse = FontshareApiModel;
|
||||
|
||||
/**
|
||||
* Fetch fonts from Fontshare API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Fontshare API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all Sans category fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* categories: ['Sans'],
|
||||
* limit: 50
|
||||
* });
|
||||
*
|
||||
* // Fetch fonts with specific tags
|
||||
* const response = await fetchFontshareFonts({
|
||||
* tags: ['Branding', 'Logos']
|
||||
* });
|
||||
*
|
||||
* // Search fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* search: 'Satoshi'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `https://api.fontshare.com/v2/fonts${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<FontshareResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by slug
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param slug - Font slug (e.g., "satoshi", "general-sans")
|
||||
* @returns Promise resolving to Fontshare font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const satoshi = await fetchFontshareFontBySlug('satoshi');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFontBySlug(
|
||||
slug: string,
|
||||
): Promise<FontshareFont | undefined> {
|
||||
const response = await fetchFontshareFonts();
|
||||
return response.fonts.find(font => font.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all fonts from Fontshare
|
||||
* Convenience function for fetching all available fonts
|
||||
* Uses pagination to get all items
|
||||
*
|
||||
* @returns Promise resolving to all Fontshare fonts
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const allFonts = await fetchAllFontshareFonts();
|
||||
* console.log(`Found ${allFonts.fonts.length} fonts`);
|
||||
* ```
|
||||
*/
|
||||
export async function fetchAllFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const allFonts: FontshareFont[] = [];
|
||||
let page = 1;
|
||||
const limit = 100; // Max items per page
|
||||
|
||||
while (true) {
|
||||
const response = await fetchFontshareFonts({
|
||||
...params,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
allFonts.push(...response.fonts);
|
||||
|
||||
// Check if we've fetched all items
|
||||
if (response.fonts.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
// Return first response with all items combined
|
||||
const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit });
|
||||
|
||||
return {
|
||||
...firstResponse,
|
||||
fonts: allFonts,
|
||||
};
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Google Fonts API client
|
||||
*
|
||||
* Handles API requests to Google Fonts API for fetching font metadata.
|
||||
* Provides error handling, retry logic, and type-safe responses.
|
||||
*
|
||||
* Pagination: The Google Fonts API does NOT support pagination parameters.
|
||||
* All fonts matching the query are returned in a single response.
|
||||
* Use category, subset, or sort filters to reduce the result set if needed.
|
||||
*
|
||||
* @see https://developers.google.com/fonts/docs/developer_api
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
import type {
|
||||
FontItem,
|
||||
GoogleFontsApiModel,
|
||||
} from '../../model/types/google';
|
||||
|
||||
/**
|
||||
* Google Fonts API parameters
|
||||
*/
|
||||
export interface GoogleFontsParams extends QueryParams {
|
||||
/**
|
||||
* Google Fonts API key (required for Google Fonts API v1)
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* Font family name (to fetch specific font)
|
||||
*/
|
||||
family?: string;
|
||||
/**
|
||||
* Font category filter (e.g., "sans-serif", "serif", "display")
|
||||
*/
|
||||
category?: string;
|
||||
/**
|
||||
* Character subset filter (e.g., "latin", "latin-ext", "cyrillic")
|
||||
*/
|
||||
subset?: string;
|
||||
/**
|
||||
* Sort order for results
|
||||
*/
|
||||
sort?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending';
|
||||
/**
|
||||
* Cap the number of fonts returned
|
||||
*/
|
||||
capability?: 'VF' | 'WOFF2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Fonts API response wrapper
|
||||
* Re-exported from model/types/google for backward compatibility
|
||||
*/
|
||||
export type GoogleFontsResponse = GoogleFontsApiModel;
|
||||
|
||||
/**
|
||||
* Simplified font item from Google Fonts API
|
||||
* Re-exported from model/types/google for backward compatibility
|
||||
*/
|
||||
export type GoogleFontItem = FontItem;
|
||||
|
||||
/**
|
||||
* Google Fonts API base URL
|
||||
* Note: Google Fonts API v1 requires an API key. For development/testing without a key,
|
||||
* fonts may not load properly.
|
||||
*/
|
||||
const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts' as const;
|
||||
|
||||
/**
|
||||
* Fetch fonts from Google Fonts API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Google Fonts API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all sans-serif fonts sorted by popularity
|
||||
* const response = await fetchGoogleFonts({
|
||||
* category: 'sans-serif',
|
||||
* sort: 'popularity'
|
||||
* });
|
||||
*
|
||||
* // Fetch specific font family
|
||||
* const robotoResponse = await fetchGoogleFonts({
|
||||
* family: 'Roboto'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFonts(
|
||||
params: GoogleFontsParams = {},
|
||||
): Promise<GoogleFontsResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${GOOGLE_FONTS_API_URL}${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<GoogleFontsResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Google Fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by family name
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param family - Font family name (e.g., "Roboto")
|
||||
* @returns Promise resolving to Google Font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = await fetchGoogleFontFamily('Roboto');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFontFamily(
|
||||
family: string,
|
||||
): Promise<GoogleFontItem | undefined> {
|
||||
const response = await fetchGoogleFonts({ family });
|
||||
return response.items.find(item => item.family === family);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Exports API clients and normalization utilities
|
||||
*/
|
||||
|
||||
// Proxy API (PRIMARY - NEW)
|
||||
// Proxy API (primary)
|
||||
export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
@@ -14,25 +14,3 @@ export type {
|
||||
ProxyFontsParams,
|
||||
ProxyFontsResponse,
|
||||
} from './proxy/proxyFonts';
|
||||
|
||||
// Google Fonts API (DEPRECATED - kept for backward compatibility)
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
} from './google/googleFonts';
|
||||
export type {
|
||||
GoogleFontItem,
|
||||
GoogleFontsParams,
|
||||
GoogleFontsResponse,
|
||||
} from './google/googleFonts';
|
||||
|
||||
// Fontshare API (DEPRECATED - kept for backward compatibility)
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
fetchFontshareFonts,
|
||||
} from './fontshare/fontshare';
|
||||
export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './fontshare/fontshare';
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Proxy API filters
|
||||
*
|
||||
* Fetches filter metadata from GlyphDiff proxy API.
|
||||
* Provides type-safe response handling.
|
||||
*
|
||||
* @see https://api.glyphdiff.com/api/v1/filters
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
|
||||
/**
|
||||
* Filter metadata type from backend
|
||||
*/
|
||||
export interface FilterMetadata {
|
||||
/** Filter ID (e.g., "providers", "categories", "subsets") */
|
||||
id: string;
|
||||
|
||||
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */
|
||||
name: string;
|
||||
|
||||
/** Filter description */
|
||||
description: string;
|
||||
|
||||
/** Filter type */
|
||||
type: 'enum' | 'string' | 'array';
|
||||
|
||||
/** Available filter options */
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter option type
|
||||
*/
|
||||
export interface FilterOption {
|
||||
/** Option ID (e.g., "google", "serif", "latin") */
|
||||
id: string;
|
||||
|
||||
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */
|
||||
name: string;
|
||||
|
||||
/** Option value (e.g., "google", "serif", "latin") */
|
||||
value: string;
|
||||
|
||||
/** Number of fonts with this value */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy filters API response
|
||||
*/
|
||||
export interface ProxyFiltersResponse {
|
||||
/** Array of filter metadata */
|
||||
filters: FilterMetadata[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch filters from proxy API
|
||||
*
|
||||
* @returns Promise resolving to array of filter metadata
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all filters
|
||||
* const filters = await fetchProxyFilters();
|
||||
*
|
||||
* console.log(filters); // [
|
||||
* // { id: "providers", name: "Font Providers", options: [...] },
|
||||
* // { id: "categories", name: "Categories", options: [...] },
|
||||
* // { id: "subsets", name: "Character Subsets", options: [...] }
|
||||
* // ]
|
||||
* ```
|
||||
*/
|
||||
export async function fetchProxyFilters(): Promise<FilterMetadata[]> {
|
||||
const response = await api.get<FilterMetadata[]>('/api/v1/filters');
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
throw new Error('Proxy API returned invalid response');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
171
src/entities/Font/api/proxy/proxyFonts.test.ts
Normal file
171
src/entities/Font/api/proxy/proxyFonts.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Tests for proxy API client
|
||||
*/
|
||||
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
import type { ProxyFontsResponse } from './proxyFonts';
|
||||
|
||||
vi.mock('$shared/api/api', () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
} from './proxyFonts';
|
||||
|
||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
|
||||
|
||||
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
|
||||
return {
|
||||
id: 'roboto',
|
||||
family: 'Roboto',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
variants: [],
|
||||
subsets: [],
|
||||
...overrides,
|
||||
} as UnifiedFont;
|
||||
}
|
||||
|
||||
function mockApiGet<T>(data: T) {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({ data, status: 200 });
|
||||
}
|
||||
|
||||
describe('proxyFonts', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.get).mockReset();
|
||||
});
|
||||
|
||||
describe('fetchProxyFonts', () => {
|
||||
test('should fetch fonts with no params', async () => {
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [createMockFont()],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
const result = await fetchProxyFonts();
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith(PROXY_API_URL);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test('should build URL with query params', async () => {
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [createMockFont()],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
await fetchProxyFonts({ provider: 'google', category: 'sans-serif', limit: 20, offset: 0 });
|
||||
|
||||
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
|
||||
expect(calledUrl).toContain('provider=google');
|
||||
expect(calledUrl).toContain('category=sans-serif');
|
||||
expect(calledUrl).toContain('limit=20');
|
||||
expect(calledUrl).toContain('offset=0');
|
||||
});
|
||||
|
||||
test('should throw on invalid response (missing fonts array)', async () => {
|
||||
mockApiGet({ total: 0 });
|
||||
|
||||
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
|
||||
});
|
||||
|
||||
test('should throw on null response data', async () => {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
|
||||
|
||||
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchProxyFontById', () => {
|
||||
test('should return font matching the ID', async () => {
|
||||
const targetFont = createMockFont({ id: 'satoshi', name: 'Satoshi' });
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [createMockFont(), targetFont],
|
||||
total: 2,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
const result = await fetchProxyFontById('satoshi');
|
||||
|
||||
expect(result).toEqual(targetFont);
|
||||
});
|
||||
|
||||
test('should return undefined when font not found', async () => {
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [createMockFont()],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
const result = await fetchProxyFontById('nonexistent');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should search with the ID as query param', async () => {
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [],
|
||||
total: 0,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
await fetchProxyFontById('Roboto');
|
||||
|
||||
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
|
||||
expect(calledUrl).toContain('limit=1000');
|
||||
expect(calledUrl).toContain('q=Roboto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFontsByIds', () => {
|
||||
test('should return empty array for empty input', async () => {
|
||||
const result = await fetchFontsByIds([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(api.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should call batch endpoint with comma-separated IDs', async () => {
|
||||
const fonts = [createMockFont({ id: 'roboto' }), createMockFont({ id: 'satoshi' })];
|
||||
mockApiGet(fonts);
|
||||
|
||||
const result = await fetchFontsByIds(['roboto', 'satoshi']);
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith(`${PROXY_API_URL}/batch?ids=roboto,satoshi`);
|
||||
expect(result).toEqual(fonts);
|
||||
});
|
||||
|
||||
test('should return empty array when response data is nullish', async () => {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
|
||||
|
||||
const result = await fetchFontsByIds(['roboto']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user