init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
93
lib/weather/http.ts
Normal file
93
lib/weather/http.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Small server-side helpers shared by every provider adapter.
|
||||
*/
|
||||
|
||||
/** An error that knows which HTTP status the API route should answer with. */
|
||||
export class ProviderError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number = 502,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads an API key from the environment, or fails with a clear message. */
|
||||
export function readKey(envKey: string): string {
|
||||
const key = process.env[envKey]?.trim();
|
||||
if (!key) {
|
||||
throw new ProviderError(`${envKey} is not configured`, 500);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
/** Seconds to cache the response. Weather barely changes in 10 minutes. */
|
||||
cacheSeconds?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches JSON with caching (default 10 minutes) to protect free-tier quotas.
|
||||
* The URL often contains an API key, so it is never logged or echoed back.
|
||||
*/
|
||||
export async function fetchJson<T>(url: string, providerLabel: string, options: FetchOptions = {}): Promise<T> {
|
||||
const { cacheSeconds = 600, headers } = options;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { headers, next: { revalidate: cacheSeconds } });
|
||||
} catch {
|
||||
// DNS failure, offline, firewall… the request never got an answer.
|
||||
throw new ProviderError(`Couldn't reach ${providerLabel}. Check your connection and try again.`, 503);
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new ProviderError(`${providerLabel} rejected the API key`, 401);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new ProviderError(`${providerLabel} rate limit reached. Try again shortly.`, 429);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ProviderError(`${providerLabel} responded with HTTP ${response.status}`, 502);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
// ---------- Number and time helpers ----------
|
||||
|
||||
/** Rounds to one decimal place so the JSON stays tidy. */
|
||||
export function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/** Same as round1, but passes null/undefined/NaN through as null. */
|
||||
export function maybe(value: number | null | undefined): number | null {
|
||||
return value === null || value === undefined || !Number.isFinite(value) ? null : round1(value);
|
||||
}
|
||||
|
||||
/** Metres per second → km/h. */
|
||||
export function msToKph(ms: number): number {
|
||||
return round1(ms * 3.6);
|
||||
}
|
||||
|
||||
export function maybeMsToKph(ms: number | null | undefined): number | null {
|
||||
return ms === null || ms === undefined ? null : msToKph(ms);
|
||||
}
|
||||
|
||||
/** Unix seconds → ISO instant. */
|
||||
export function fromUnix(seconds: number): string {
|
||||
return new Date(seconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
/** Keeps the first `hours` of an hourly list, starting from the current hour. */
|
||||
export function nextHours<T extends { time: string }>(items: T[], hours: number): T[] {
|
||||
const startOfThisHour = Date.now() - 60 * 60 * 1000;
|
||||
return items.filter((item) => Date.parse(item.time) >= startOfThisHour).slice(0, hours);
|
||||
}
|
||||
|
||||
/** The app shows at most 7 days of hourly data and 14 days of daily data. */
|
||||
export const MAX_HOURS = 168;
|
||||
export const MAX_DAYS = 14;
|
||||
Loading…
Add table
Add a link
Reference in a new issue