feat(weather): /api/weather Open-Meteo proxy with 15-min cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import { router as jobsRouter } from './routes/jobs.js';
|
|||||||
import { router as captureRouter } from './routes/capture.js';
|
import { router as captureRouter } from './routes/capture.js';
|
||||||
import { spacesScopedRouter as companionRouter } from './routes/companion.js';
|
import { spacesScopedRouter as companionRouter } from './routes/companion.js';
|
||||||
import { router as dashboardRouter } from './routes/dashboard.js';
|
import { router as dashboardRouter } from './routes/dashboard.js';
|
||||||
|
import { router as weatherRouter } from './routes/weather.js';
|
||||||
|
|
||||||
export function mountApi(app) {
|
export function mountApi(app) {
|
||||||
const api = Router();
|
const api = Router();
|
||||||
@@ -55,6 +56,7 @@ export function mountApi(app) {
|
|||||||
api.use('/jobs', jobsRouter);
|
api.use('/jobs', jobsRouter);
|
||||||
api.use('/capture', captureRouter);
|
api.use('/capture', captureRouter);
|
||||||
api.use('/dashboard', dashboardRouter);
|
api.use('/dashboard', dashboardRouter);
|
||||||
|
api.use('/weather', weatherRouter);
|
||||||
api.use('/:entity_type/:entity_id/tags', tagsByEntityRouter);
|
api.use('/:entity_type/:entity_id/tags', tagsByEntityRouter);
|
||||||
|
|
||||||
api.use((_req, _res, next) => next(new NotFoundError('route not found')));
|
api.use((_req, _res, next) => next(new NotFoundError('route not found')));
|
||||||
|
|||||||
5
lib/api/routes/weather.js
Normal file
5
lib/api/routes/weather.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { asyncWrap } from '../errors.js';
|
||||||
|
import * as weather from '../../weather.js';
|
||||||
|
export const router = Router();
|
||||||
|
router.get('/', asyncWrap(async (_req, res) => res.json(await weather.current())));
|
||||||
33
lib/weather.js
Normal file
33
lib/weather.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// lib/weather.js — Melbourne, Open-Meteo, no API key, 15-min cache.
|
||||||
|
const LAT = -37.81, LON = 144.96, TTL_MS = 15 * 60 * 1000;
|
||||||
|
const CODES = { 0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',
|
||||||
|
51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',
|
||||||
|
71:'Light snow',73:'Snow',75:'Heavy snow',80:'Showers',81:'Showers',82:'Violent showers',
|
||||||
|
95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm' };
|
||||||
|
|
||||||
|
let cache = null; // { at, data }
|
||||||
|
let fetcher = defaultFetcher;
|
||||||
|
|
||||||
|
async function defaultFetcher() {
|
||||||
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${LAT}&longitude=${LON}` +
|
||||||
|
`¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m`;
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
||||||
|
if (!res.ok) throw new Error(`open-meteo ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _setFetcher(fn) { fetcher = fn; }
|
||||||
|
export function _resetCache() { cache = null; fetcher = defaultFetcher; }
|
||||||
|
|
||||||
|
export async function current() {
|
||||||
|
if (cache && Date.now() - cache.at < TTL_MS) return cache.data;
|
||||||
|
const raw = await fetcher();
|
||||||
|
const c = raw.current || {};
|
||||||
|
const data = {
|
||||||
|
temp: c.temperature_2m, feels_like: c.apparent_temperature,
|
||||||
|
humidity: c.relative_humidity_2m, wind: c.wind_speed_10m,
|
||||||
|
code: c.weather_code, label: CODES[c.weather_code] || 'Unknown'
|
||||||
|
};
|
||||||
|
cache = { at: Date.now(), data };
|
||||||
|
return data;
|
||||||
|
}
|
||||||
27
tests/api/weather.test.js
Normal file
27
tests/api/weather.test.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { setup } from './helpers.js';
|
||||||
|
import * as weather from '../../lib/weather.js';
|
||||||
|
|
||||||
|
let app, ownerHeaders;
|
||||||
|
beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
|
||||||
|
beforeEach(() => weather._resetCache());
|
||||||
|
|
||||||
|
const SAMPLE = { current: { temperature_2m: 14.2, apparent_temperature: 12.1, relative_humidity_2m: 71, wind_speed_10m: 9, weather_code: 3 } };
|
||||||
|
|
||||||
|
describe('weather api', () => {
|
||||||
|
it('401 without auth', async () => {
|
||||||
|
expect((await request(app).get('/api/weather')).status).toBe(401);
|
||||||
|
});
|
||||||
|
it('returns mapped weather and caches the upstream call', async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(SAMPLE);
|
||||||
|
weather._setFetcher(fetcher);
|
||||||
|
const r1 = await request(app).get('/api/weather').set(ownerHeaders);
|
||||||
|
expect(r1.status).toBe(200);
|
||||||
|
expect(r1.body.temp).toBe(14.2);
|
||||||
|
expect(r1.body.humidity).toBe(71);
|
||||||
|
expect(typeof r1.body.label).toBe('string'); // weather_code → text
|
||||||
|
await request(app).get('/api/weather').set(ownerHeaders); // 2nd hit
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1); // cached
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user