7f20f36d0a
The proxy returned `{fonts: null, total: 0}` for empty results, which
fetchProxyFonts surfaced as a generic Error. fontCatalogStore wrapped it
as FontNetworkError, and TanStack retried 3× with exponential backoff —
pinning the loading skeleton for ~7s before settling on an empty list.
Schema mismatches are deterministic; retrying only delays surfacing the
contract violation.
- shared/api/queryClient: introduce NonRetryableError marker class.
The default retry handler short-circuits when it sees this so any
store using the shared client gets fail-fast behavior for free.
- entities/Font/lib/errors: FontResponseError extends NonRetryableError.
- entities/Font/api/proxy/proxyFonts: throw FontResponseError (was a
bare Error). Document that ProxyFontsResponse.fonts is always an array.
- entities/Font/.../fontCatalogStore.fetchPage: preserve a
FontResponseError raised lower in the stack instead of re-wrapping
it as FontNetworkError.
- features/FilterAndSortFonts/api/filters: throw NonRetryableError on
invalid filters payloads and document the array-never-null contract.
221 lines
5.8 KiB
TypeScript
221 lines
5.8 KiB
TypeScript
/**
|
|
* Proxy API client
|
|
*
|
|
* Handles API requests to GlyphDiff proxy API for fetching font metadata.
|
|
* Provides error handling, pagination support, and type-safe responses.
|
|
*
|
|
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
|
|
* unified format, eliminating the need for client-side normalization.
|
|
*
|
|
* @see https://api.glyphdiff.com/api/v1/fonts
|
|
*/
|
|
|
|
import { api } from '$shared/api/api';
|
|
import { queryClient } from '$shared/api/queryClient';
|
|
import { fontKeys } from '$shared/api/queryKeys';
|
|
import { buildQueryString } from '$shared/lib/utils';
|
|
import type { QueryParams } from '$shared/lib/utils';
|
|
import { FontResponseError } from '../../lib/errors/errors';
|
|
import type { UnifiedFont } from '../../model/types';
|
|
|
|
/**
|
|
* Normalizes cache by seeding individual font entries from collection responses.
|
|
* This ensures that a font fetched in a list or batch is available via its detail key.
|
|
*
|
|
* @param fonts - Array of fonts to cache
|
|
*/
|
|
export function seedFontCache(fonts: UnifiedFont[]): void {
|
|
fonts.forEach(font => {
|
|
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
|
});
|
|
}
|
|
|
|
import { API_ENDPOINTS } from '$shared/api/endpoints';
|
|
|
|
/**
|
|
* Proxy API endpoint for font resources.
|
|
*/
|
|
const PROXY_API_URL = API_ENDPOINTS.fonts;
|
|
|
|
/**
|
|
* Proxy API parameters
|
|
*
|
|
* Maps directly to the proxy API query parameters
|
|
*
|
|
* UPDATED: Now supports array values for filters
|
|
*/
|
|
export interface ProxyFontsParams extends QueryParams {
|
|
/**
|
|
* Font provider filter
|
|
*
|
|
* NEW: Supports array of providers (e.g., ["google", "fontshare"])
|
|
* Backward compatible: Single value still works
|
|
*/
|
|
providers?: string[] | string;
|
|
|
|
/**
|
|
* Font category filter
|
|
*
|
|
* NEW: Supports array of categories (e.g., ["serif", "sans-serif"])
|
|
* Backward compatible: Single value still works
|
|
*/
|
|
categories?: string[] | string;
|
|
|
|
/**
|
|
* Character subset filter
|
|
*
|
|
* NEW: Supports array of subsets (e.g., ["latin", "cyrillic"])
|
|
* Backward compatible: Single value still works
|
|
*/
|
|
subsets?: string[] | string;
|
|
|
|
/**
|
|
* Search query (e.g., "roboto", "satoshi")
|
|
*/
|
|
q?: string;
|
|
|
|
/**
|
|
* Sort order for results
|
|
* "name" - Alphabetical by font name
|
|
* "popularity" - Most popular first
|
|
* "lastModified" - Recently updated first
|
|
*/
|
|
sort?: 'name' | 'popularity' | 'lastModified';
|
|
|
|
/**
|
|
* Number of items to return (pagination)
|
|
*/
|
|
limit?: number;
|
|
|
|
/**
|
|
* Number of items to skip (pagination)
|
|
* Use for pagination: offset = (page - 1) * limit
|
|
*/
|
|
offset?: number;
|
|
}
|
|
|
|
/**
|
|
* Proxy API response
|
|
*
|
|
* Includes pagination metadata alongside font data.
|
|
*
|
|
* Contract: `fonts` is always an array — never `null` or omitted, even when
|
|
* `total === 0`. Returning `null` on the wire is a backend regression and
|
|
* surfaces as FontResponseError (non-retryable) on the client.
|
|
*/
|
|
export interface ProxyFontsResponse {
|
|
/**
|
|
* List of font objects returned by the proxy.
|
|
* Always an array; empty when no matches.
|
|
*/
|
|
fonts: UnifiedFont[];
|
|
|
|
/**
|
|
* Total number of matching fonts (ignoring limit/offset)
|
|
*/
|
|
total: number;
|
|
|
|
/**
|
|
* Page size used for the request
|
|
*/
|
|
limit: number;
|
|
|
|
/**
|
|
* Start index for the result set
|
|
*/
|
|
offset: number;
|
|
}
|
|
|
|
/**
|
|
* Fetch fonts from proxy API
|
|
*
|
|
* @param params - Query parameters for filtering and pagination
|
|
* @returns Promise resolving to proxy API response
|
|
* @throws ApiError when request fails
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Fetch all sans-serif fonts from Google
|
|
* const response = await fetchProxyFonts({
|
|
* provider: 'google',
|
|
* category: 'sans-serif',
|
|
* limit: 50,
|
|
* offset: 0
|
|
* });
|
|
*
|
|
* // Search fonts across all providers
|
|
* const searchResponse = await fetchProxyFonts({
|
|
* q: 'roboto',
|
|
* limit: 20
|
|
* });
|
|
*
|
|
* // Fetch fonts with pagination
|
|
* const page1 = await fetchProxyFonts({ limit: 50, offset: 0 });
|
|
* const page2 = await fetchProxyFonts({ limit: 50, offset: 50 });
|
|
* ```
|
|
*/
|
|
export async function fetchProxyFonts(
|
|
params: ProxyFontsParams = {},
|
|
): Promise<ProxyFontsResponse> {
|
|
const queryString = buildQueryString(params);
|
|
const url = `${PROXY_API_URL}${queryString}`;
|
|
|
|
const response = await api.get<ProxyFontsResponse>(url);
|
|
|
|
if (!response.data) {
|
|
throw new FontResponseError('response', response.data);
|
|
}
|
|
if (!Array.isArray(response.data.fonts)) {
|
|
throw new FontResponseError('response.fonts', response.data.fonts);
|
|
}
|
|
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* Fetch font by ID
|
|
*
|
|
* Convenience function for fetching a single font by ID
|
|
* Note: This fetches a page and filters client-side, which is not ideal
|
|
* For production, consider adding a dedicated endpoint to the proxy API
|
|
*
|
|
* @param id - Font ID (family name for Google, slug for Fontshare)
|
|
* @returns Promise resolving to font or undefined
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const roboto = await fetchProxyFontById('Roboto');
|
|
* const satoshi = await fetchProxyFontById('satoshi');
|
|
* ```
|
|
*/
|
|
export async function fetchProxyFontById(
|
|
id: string,
|
|
): Promise<UnifiedFont | undefined> {
|
|
const response = await fetchProxyFonts({ limit: 1000, q: id });
|
|
|
|
if (!response || !response.fonts) {
|
|
console.error('[fetchProxyFontById] No fonts in response', { response });
|
|
return undefined;
|
|
}
|
|
|
|
return response.fonts.find(font => font.id === id);
|
|
}
|
|
|
|
/**
|
|
* Fetch multiple fonts by their IDs
|
|
*
|
|
* @param ids - Array of font IDs to fetch
|
|
* @returns Promise resolving to an array of fonts
|
|
*/
|
|
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
|
if (ids.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const queryString = ids.join(',');
|
|
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
|
|
|
const response = await api.get<UnifiedFont[]>(url);
|
|
return response.data ?? [];
|
|
}
|