Files

375 lines
9.7 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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,
2026-03-28 22:36:00 +00:00
LAaveragesResponse,
} 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<T>(response: Response): Promise<T> {
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, any>): 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<SchoolsResponse> {
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<SchoolsResponse>(response);
}
/**
* Fetch detailed information for a specific school by URN
*/
export async function fetchSchoolDetails(
urn: number,
options: RequestInit = {}
): Promise<SchoolDetailsResponse> {
const url = `${API_BASE_URL}/schools/${urn}`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.SCHOOL_DETAILS,
...options.next,
},
});
return handleResponse<SchoolDetailsResponse>(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<ComparisonResponse> {
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<ComparisonResponse>(response);
}
// ============================================================================
// Rankings APIs
// ============================================================================
/**
* Fetch school rankings by metric
*/
export async function fetchRankings(
params: RankingsParams,
options: RequestInit = {}
): Promise<RankingsResponse> {
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<RankingsResponse>(response);
}
// ============================================================================
// Filter & Metadata APIs
// ============================================================================
/**
* Fetch available filter options (local authorities, school types, years)
*/
export async function fetchFilters(
options: RequestInit = {}
): Promise<FiltersResponse> {
const url = `${API_BASE_URL}/filters`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.FILTERS,
...options.next,
},
});
return handleResponse<FiltersResponse>(response);
}
/**
* Fetch metric definitions (labels, descriptions, formats)
*/
export async function fetchMetrics(
options: RequestInit = {}
): Promise<MetricsResponse> {
const url = `${API_BASE_URL}/metrics`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.METRICS,
...options.next,
},
});
const data = await handleResponse<any>(response);
// Transform backend response to match our TypeScript types
// Backend uses 'name' and 'type', we use 'label' and 'format'
return {
metrics: data.metrics.map((metric: any) => ({
key: metric.key,
label: metric.name, // Map 'name' to 'label'
description: metric.description,
category: metric.category,
format: metric.type, // Map 'type' to 'format'
hasNationalAverage: metric.hasNationalAverage,
})),
};
}
2026-03-28 22:36:00 +00:00
/**
* Fetch per-LA average Attainment 8 score for secondary schools
*/
export async function fetchLAaverages(
options: RequestInit = {}
): Promise<LAaveragesResponse> {
const url = `${API_BASE_URL}/la-averages`;
const response = await fetch(url, {
...options,
next: {
revalidate: 3600,
...options.next,
},
});
return handleResponse<LAaveragesResponse>(response);
}
/**
* Fetch database statistics and info
*/
export async function fetchDataInfo(
options: RequestInit = {}
): Promise<DataInfoResponse> {
const url = `${API_BASE_URL}/data-info`;
const response = await fetch(url, {
...options,
next: {
revalidate: CACHE_DURATION.DATA_INFO,
...options.next,
},
});
return handleResponse<DataInfoResponse>(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<T>(url: string): Promise<T> {
// If it's already a full URL, use it directly
// Otherwise, prepend the API_BASE_URL
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url.startsWith('/') ? url : `/${url}`}`;
const response = await fetch(fullUrl);
return handleResponse<T>(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;
}