← 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();
}