novel-reader-sdk
Version:
SDK for Novel Reader API
295 lines (249 loc) • 6.86 kB
text/typescript
/**
* SDK Utility Functions
*
* Helper functions for URL building, query parameters, and response handling
*/
import type { DatabaseId, ApiError } from './types';
import { createErrorFromApiResponse, NetworkError, TimeoutError } from './errors';
// ===== URL Building Utilities =====
/**
* Build URL with path parameters
*/
export function buildUrl(baseUrl: string, path: string, params?: Record<string, string>): string {
let url = `${baseUrl.replace(/\/$/, '')}${path}`;
if (params) {
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, encodeURIComponent(value));
}
}
return url;
}
/**
* Build query string from parameters
*/
export function buildQueryString(params?: Record<string, unknown>): string {
if (!params || Object.keys(params).length === 0) {
return '';
}
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
searchParams.append(key, value as string);
}
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
/**
* Combine URL with query parameters
*/
export function buildFullUrl(
baseUrl: string,
path: string,
params?: Record<string, string>,
query?: Record<string, unknown> | object
): string {
const url = buildUrl(baseUrl, path, params);
const queryString = buildQueryString(query as Record<string, unknown>);
return `${url}${queryString}`;
}
// ===== HTTP Request Utilities =====
/**
* Default fetch options for API requests
*/
export const defaultFetchOptions: RequestInit = {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
};
/**
* Enhanced fetch with timeout and error handling
*/
export async function fetchWithTimeout(
url: string,
options: RequestInit & { timeout?: number } = {}
): Promise<Response> {
const { timeout = 30000, ...fetchOptions } = options;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...defaultFetchOptions,
...fetchOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new TimeoutError(`Request timeout after ${timeout}ms`);
}
throw new NetworkError('Network request failed', error);
}
}
/**
* Parse JSON response with error handling
*/
export async function parseJsonResponse<T>(response: Response): Promise<T> {
const text = await response.text();
if (!text) {
throw new NetworkError('Empty response body');
}
try {
const data = JSON.parse(text) as T | ApiError;
// Check if response is an error
if (!response.ok) {
if (isApiError(data)) {
throw createErrorFromApiResponse(data);
}
throw new NetworkError(`HTTP ${response.status}: ${response.statusText}`);
}
return data as T;
} catch (error) {
if (error instanceof Error && error.name === 'SyntaxError') {
throw new NetworkError('Invalid JSON response', error);
}
throw error;
}
}
/**
* Type guard to check if response is an API error
*/
function isApiError(data: unknown): data is ApiError {
if (
typeof data === 'object' &&
data !== null &&
Object.prototype.hasOwnProperty.call(data, 'error')
) {
const error = (data as Record<string, unknown>).error;
if (
typeof error === 'object' &&
error !== null &&
Object.prototype.hasOwnProperty.call(error, 'code') &&
Object.prototype.hasOwnProperty.call(error, 'message')
) {
return true;
}
}
return false;
}
// ===== Validation Utilities =====
/**
* Validate database ID
*/
export function validateDatabaseId(id: string): asserts id is DatabaseId {
if (!id || typeof id !== 'string') {
throw new Error('Database ID must be a non-empty string');
}
}
/**
* Validate URL format
*/
export function validateUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Extract hostname from URL
*/
export function extractHostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
throw new Error(`Invalid URL: ${url}`);
}
}
// ===== Data Transformation Utilities =====
/**
* Clean and normalize string data
*/
export function normalizeString(str: string | null | undefined): string {
return (str ?? '').trim();
}
/**
* Convert array to comma-separated string
*/
export function arrayToString(arr: string[]): string {
return arr.filter(Boolean).join(', ');
}
/**
* Safe number parsing
*/
export function parseNumber(value: string | number | null | undefined): number | undefined {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsed = parseFloat(value);
return isNaN(parsed) ? undefined : parsed;
}
return undefined;
}
// ===== Retry Utilities =====
/**
* Retry configuration
*/
export interface RetryConfig {
maxAttempts: number;
baseDelay: number;
maxDelay: number;
backoffFactor: number;
}
/**
* Default retry configuration
*/
export const defaultRetryConfig: RetryConfig = {
maxAttempts: 3,
baseDelay: 1000,
maxDelay: 10000,
backoffFactor: 2,
};
/**
* Retry function with exponential backoff
*/
export async function withRetry<T>(
fn: () => Promise<T>,
config: Partial<RetryConfig> = {}
): Promise<T> {
const { maxAttempts, baseDelay, maxDelay, backoffFactor } = {
...defaultRetryConfig,
...config,
};
let lastError: Error = new Error('No attempts made');
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === maxAttempts) {
break;
}
const delay = Math.min(baseDelay * Math.pow(backoffFactor, attempt - 1), maxDelay);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
// ===== Debug Utilities =====
/**
* Log request details for debugging
*/
export function logRequest(method: string, url: string, data?: unknown): void {
if (process.env.NODE_ENV === 'development') {
console.log(`[ScrapingSDK] ${method} ${url}`, data ? { data } : '');
}
}
/**
* Log response details for debugging
*/
export function logResponse(url: string, status: number, data?: unknown): void {
if (process.env.NODE_ENV === 'development') {
console.log(`[ScrapingSDK] Response ${status} from ${url}`, data ? { data } : '');
}
}