Use FASTAPI_URL for SSR (internal Docker network: http://backend:80/api) Use NEXT_PUBLIC_API_URL for browser requests (http://localhost:8000/api) Fixes ECONNREFUSED error during server-side rendering. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
340 lines
8.8 KiB
TypeScript
340 lines
8.8 KiB
TypeScript
/**
|
|
* 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<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,
|
|
},
|
|
});
|
|
|
|
return handleResponse<MetricsResponse>(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> {
|
|
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url.startsWith('/') ? url.slice(4) : 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;
|
|
}
|