← back to Wine Finder Next
lib/apiOptimization.ts
409 lines
// API Optimization Utilities
// Provides response compression, payload optimization, and batch operations
import { NextResponse } from 'next/server';
// ============= RESPONSE OPTIMIZATION =============
/**
* Optimized JSON response with field filtering and compression hints
*/
export function optimizedResponse<T>(
data: T,
options: {
fields?: string[]; // Only include specific fields
exclude?: string[]; // Exclude specific fields
compress?: boolean; // Add compression headers
cache?: { maxAge: number; swr?: number }; // Cache control
} = {}
): NextResponse {
let responseData = data;
// Field filtering to reduce payload size
if (options.fields && Array.isArray(data)) {
responseData = (data as any[]).map(item =>
filterFields(item, options.fields!)
) as T;
} else if (options.fields && typeof data === 'object') {
responseData = filterFields(data, options.fields) as T;
}
// Exclude sensitive/unnecessary fields
if (options.exclude) {
responseData = excludeFields(responseData, options.exclude);
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Cache control
if (options.cache) {
const { maxAge, swr } = options.cache;
if (swr) {
headers['Cache-Control'] = `public, max-age=${maxAge}, stale-while-revalidate=${swr}`;
} else {
headers['Cache-Control'] = `public, max-age=${maxAge}`;
}
}
// Compression hints
if (options.compress) {
headers['Content-Encoding'] = 'gzip';
}
return NextResponse.json(
{ success: true, data: responseData },
{ headers }
);
}
/**
* Filter object to only include specified fields
*/
function filterFields(obj: any, fields: string[]): any {
if (Array.isArray(obj)) {
return obj.map(item => filterFields(item, fields));
}
if (typeof obj !== 'object' || obj === null) {
return obj;
}
const filtered: any = {};
for (const field of fields) {
if (field.includes('.')) {
// Nested field support (e.g., 'user.name')
const [parent, ...rest] = field.split('.');
if (obj[parent]) {
filtered[parent] = filtered[parent] || {};
Object.assign(filtered[parent], filterFields(obj[parent], [rest.join('.')]));
}
} else if (obj[field] !== undefined) {
filtered[field] = obj[field];
}
}
return filtered;
}
/**
* Exclude specified fields from object
*/
function excludeFields<T>(data: T, exclude: string[]): T {
if (Array.isArray(data)) {
return data.map(item => excludeFields(item, exclude)) as T;
}
if (typeof data !== 'object' || data === null) {
return data;
}
const result = { ...data } as any;
for (const field of exclude) {
delete result[field];
}
return result;
}
// ============= PAGINATION HELPERS =============
export interface PaginationParams {
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
hasMore: boolean;
}
/**
* Paginate and sort an array of items
*/
export function paginate<T>(
items: T[],
params: PaginationParams = {}
): PaginatedResult<T> {
const {
page = 1,
pageSize = 20,
sortBy,
sortOrder = 'desc'
} = params;
let sorted = [...items];
// Sort if requested
if (sortBy) {
sorted.sort((a: any, b: any) => {
const aVal = a[sortBy];
const bVal = b[sortBy];
if (aVal === bVal) return 0;
const comparison = aVal > bVal ? 1 : -1;
return sortOrder === 'asc' ? comparison : -comparison;
});
}
const total = sorted.length;
const totalPages = Math.ceil(total / pageSize);
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedItems = sorted.slice(start, end);
return {
items: paginatedItems,
total,
page,
pageSize,
totalPages,
hasMore: page < totalPages
};
}
// ============= BATCH OPERATIONS =============
/**
* Process items in batches to avoid blocking
*/
export async function processBatch<T, R>(
items: T[],
processor: (item: T) => Promise<R>,
batchSize: number = 10
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
}
return results;
}
// ============= REQUEST VALIDATION =============
/**
* Extract and validate pagination parameters from request
*/
export function extractPaginationParams(searchParams: URLSearchParams): PaginationParams {
return {
page: Math.max(1, parseInt(searchParams.get('page') || '1', 10)),
pageSize: Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20', 10))),
sortBy: searchParams.get('sortBy') || undefined,
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || 'desc'
};
}
// ============= ERROR HANDLING =============
export class ApiError extends Error {
constructor(
message: string,
public statusCode: number = 500,
public code?: string
) {
super(message);
this.name = 'ApiError';
}
}
export function errorResponse(
error: unknown,
defaultMessage: string = 'An error occurred'
): NextResponse {
if (error instanceof ApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
code: error.code
},
{ status: error.statusCode }
);
}
if (error instanceof Error) {
console.error('API Error:', error);
return NextResponse.json(
{
success: false,
error: process.env.NODE_ENV === 'production' ? defaultMessage : error.message
},
{ status: 500 }
);
}
return NextResponse.json(
{
success: false,
error: defaultMessage
},
{ status: 500 }
);
}
// ============= PERFORMANCE MONITORING =============
interface TimingMetric {
name: string;
duration: number;
timestamp: number;
}
const timings: TimingMetric[] = [];
const MAX_TIMINGS = 1000;
export function trackTiming(name: string, duration: number): void {
timings.push({
name,
duration,
timestamp: Date.now()
});
// Keep only recent timings
if (timings.length > MAX_TIMINGS) {
timings.splice(0, timings.length - MAX_TIMINGS);
}
// Log slow operations
if (duration > 1000) {
console.warn(`Slow API operation: ${name} took ${duration}ms`);
}
}
export function getTimingStats(name?: string) {
const filtered = name ? timings.filter(t => t.name === name) : timings;
if (filtered.length === 0) {
return null;
}
const durations = filtered.map(t => t.duration);
const sum = durations.reduce((a, b) => a + b, 0);
return {
count: filtered.length,
avg: sum / filtered.length,
min: Math.min(...durations),
max: Math.max(...durations),
p95: percentile(durations, 0.95),
p99: percentile(durations, 0.99)
};
}
function percentile(arr: number[], p: number): number {
const sorted = [...arr].sort((a, b) => a - b);
const index = Math.ceil(sorted.length * p) - 1;
return sorted[index];
}
// ============= RESPONSE COMPRESSION =============
/**
* Check if request accepts gzip compression
*/
export function supportsGzip(acceptEncoding: string | null): boolean {
return acceptEncoding?.includes('gzip') ?? false;
}
/**
* Estimate response size in bytes
*/
export function estimateSize(data: any): number {
return new Blob([JSON.stringify(data)]).size;
}
// ============= ETAG SUPPORT =============
/**
* Generate ETag for response caching
*/
export function generateETag(data: any): string {
const str = JSON.stringify(data);
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return `"${Math.abs(hash).toString(36)}"`;
}
/**
* Check if ETag matches (for 304 Not Modified)
*/
export function checkETag(ifNoneMatch: string | null, etag: string): boolean {
return ifNoneMatch === etag;
}
// ============= QUERY OPTIMIZATION =============
/**
* Parse and optimize query parameters
*/
export function parseQueryFilters(searchParams: URLSearchParams): Record<string, any> {
const filters: Record<string, any> = {};
for (const [key, value] of searchParams.entries()) {
// Skip pagination params
if (['page', 'pageSize', 'sortBy', 'sortOrder'].includes(key)) {
continue;
}
// Parse boolean values
if (value === 'true') filters[key] = true;
else if (value === 'false') filters[key] = false;
// Parse numeric values
else if (!isNaN(Number(value))) filters[key] = Number(value);
// String values
else filters[key] = value;
}
return filters;
}
// ============= RATE LIMIT HELPERS =============
export function rateLimitHeaders(rateLimit: {
limit: number;
remaining: number;
reset: number;
}): Record<string, string> {
return {
'X-RateLimit-Limit': rateLimit.limit.toString(),
'X-RateLimit-Remaining': rateLimit.remaining.toString(),
'X-RateLimit-Reset': new Date(rateLimit.reset).toISOString()
};
}
// ============= CONDITIONAL REQUESTS =============
/**
* Handle conditional GET requests with If-Modified-Since
*/
export function checkIfModifiedSince(
ifModifiedSince: string | null,
lastModified: Date
): boolean {
if (!ifModifiedSince) return false;
const requestDate = new Date(ifModifiedSince);
return lastModified <= requestDate;
}
/**
* Create Last-Modified header value
*/
export function lastModifiedHeader(date: Date): string {
return date.toUTCString();
}