/** * API Client for SchoolCompare FastAPI Backend * Handles all data fetching from the API with proper error handling and caching */ import type { SchoolsResponse, SchoolDetailsResponse, ComparisonResponse, RankingsResponse, FiltersResponse, MetricsResponse, DataInfoResponse, SchoolSearchParams, RankingsParams, APIError, } from './types'; // ============================================================================ // Configuration // ============================================================================ // Use FASTAPI_URL for server-side requests (internal Docker network) // Use NEXT_PUBLIC_API_URL for client-side requests (browser) const API_BASE_URL = typeof window === 'undefined' ? (process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || '/api') : (process.env.NEXT_PUBLIC_API_URL || '/api'); // Cache configuration for server-side fetching (Next.js revalidate) export const CACHE_DURATION = { FILTERS: 3600, // 1 hour METRICS: 3600, // 1 hour SCHOOLS_LIST: 60, // 1 minute SCHOOL_DETAILS: 300, // 5 minutes RANKINGS: 300, // 5 minutes COMPARISON: 60, // 1 minute DATA_INFO: 600, // 10 minutes }; // ============================================================================ // Error Handling // ============================================================================ export class APIFetchError extends Error { constructor( message: string, public status?: number, public detail?: string ) { super(message); this.name = 'APIFetchError'; } } async function handleResponse(response: Response): Promise { if (!response.ok) { let errorDetail = `HTTP ${response.status}: ${response.statusText}`; try { const errorData: APIError = await response.json(); errorDetail = errorData.detail || errorDetail; } catch { // If parsing JSON fails, use the default error } throw new APIFetchError( `API request failed: ${errorDetail}`, response.status, errorDetail ); } return response.json(); } // ============================================================================ // Helper Functions // ============================================================================ function buildQueryString(params: Record): string { const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { searchParams.append(key, String(value)); } }); return searchParams.toString(); } // ============================================================================ // School APIs // ============================================================================ /** * Fetch schools with optional search and filters * Supports both server-side (SSR) and client-side fetching */ export async function fetchSchools( params: SchoolSearchParams = {}, options: RequestInit = {} ): Promise { const queryString = buildQueryString(params); const url = `${API_BASE_URL}/schools${queryString ? `?${queryString}` : ''}`; const response = await fetch(url, { ...options, next: { revalidate: CACHE_DURATION.SCHOOLS_LIST, ...options.next, }, }); return handleResponse(response); } /** * Fetch detailed information for a specific school by URN */ export async function fetchSchoolDetails( urn: number, options: RequestInit = {} ): Promise { const url = `${API_BASE_URL}/schools/${urn}`; const response = await fetch(url, { ...options, next: { revalidate: CACHE_DURATION.SCHOOL_DETAILS, ...options.next, }, }); return handleResponse(response); } // ============================================================================ // Comparison APIs // ============================================================================ /** * Fetch comparison data for multiple schools * @param urns - Comma-separated URNs or array of URNs */ export async function fetchComparison( urns: string | number[], options: RequestInit = {} ): Promise { const urnsString = Array.isArray(urns) ? urns.join(',') : urns; const url = `${API_BASE_URL}/compare?urns=${urnsString}`; const response = await fetch(url, { ...options, next: { revalidate: CACHE_DURATION.COMPARISON, ...options.next, }, }); return handleResponse(response); } // ============================================================================ // Rankings APIs // ============================================================================ /** * Fetch school rankings by metric */ export async function fetchRankings( params: RankingsParams, options: RequestInit = {} ): Promise { const queryString = buildQueryString(params); const url = `${API_BASE_URL}/rankings?${queryString}`; const response = await fetch(url, { ...options, next: { revalidate: CACHE_DURATION.RANKINGS, ...options.next, }, }); return handleResponse(response); } // ============================================================================ // Filter & Metadata APIs // ============================================================================ /** * Fetch available filter options (local authorities, school types, years) */ export async function fetchFilters( options: RequestInit = {} ): Promise { const url = `${API_BASE_URL}/filters`; const response = await fetch(url, { ...options, next: { revalidate: CACHE_DURATION.FILTERS, ...options.next, }, }); return handleResponse(response); } /** * Fetch metric definitions (labels, descriptions, formats) */ export async function fetchMetrics( options: RequestInit = {} ): Promise { const url = `${API_BASE_URL}/metrics`; const response = await fetch(url, { ...options, next: { revalidate: CACHE_DURATION.METRICS, ...options.next, }, }); return handleResponse(response); } /** * Fetch database statistics and info */ export async function fetchDataInfo( options: RequestInit = {} ): Promise { const url = `${API_BASE_URL}/data-info`; const response = await fetch(url, { ...options, next: { revalidate: CACHE_DURATION.DATA_INFO, ...options.next, }, }); return handleResponse(response); } // ============================================================================ // Client-Side Fetcher (for SWR) // ============================================================================ /** * Generic fetcher function for use with SWR * @example * ```tsx * const { data, error } = useSWR('/api/schools', fetcher); * ``` */ export async function fetcher(url: string): Promise { const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url.startsWith('/') ? url.slice(4) : url}`; const response = await fetch(fullUrl); return handleResponse(response); } // ============================================================================ // Geocoding API // ============================================================================ /** * Geocode a UK postcode using postcodes.io */ export async function geocodePostcode(postcode: string): Promise<{ latitude: number; longitude: number; } | null> { try { const cleanPostcode = postcode.trim().toUpperCase(); const response = await fetch( `https://api.postcodes.io/postcodes/${encodeURIComponent(cleanPostcode)}` ); if (!response.ok) { return null; } const data = await response.json(); if (data.result) { return { latitude: data.result.latitude, longitude: data.result.longitude, }; } return null; } catch (error) { console.error('Geocoding error:', error); return null; } } // ============================================================================ // Utility Functions // ============================================================================ /** * Calculate distance between two coordinates using Haversine formula * @returns Distance in kilometers */ export function calculateDistance( lat1: number, lon1: number, lat2: number, lon2: number ): number { const R = 6371; // Earth's radius in kilometers const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } /** * Convert kilometers to miles */ export function kmToMiles(km: number): number { return km * 0.621371; }