From 724a1264e22763c24c145d4dcd8805563ba0fa1b Mon Sep 17 00:00:00 2001 From: Alon Kochba Date: Mon, 23 Mar 2026 10:43:29 +0200 Subject: [PATCH 1/2] feat: add /v1/geo-breakdown endpoint for geographic CWV breakdown (#94) * feat: add /v1/geo-breakdown endpoint for geographic CWV breakdown Adds a new controller and route that returns core_web_vitals data for all geographies for a given technology. Unlike /cwv, this endpoint omits the geo filter so callers can build a geographic breakdown chart without issuing one request per country. * refactor: merge geo-breakdown into reportController factory Add crossGeo option to createReportController; delete standalone geoBreakdownController.js. Endpoint now returns a single-month snapshot (latest by default, or the month specified by the end param). --------- Co-authored-by: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com> --- src/controllers/reportController.js | 42 +++++++++++++++++------------ src/index.js | 7 +++++ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/controllers/reportController.js b/src/controllers/reportController.js index 3fd4b53..248cd44 100644 --- a/src/controllers/reportController.js +++ b/src/controllers/reportController.js @@ -38,9 +38,11 @@ const REPORT_CONFIGS = { /** * Generic report data controller factory - * Creates controllers for adoption, pageWeight, lighthouse, and cwv data + * Creates controllers for adoption, pageWeight, lighthouse, and cwv data. + * Pass { crossGeo: true } to get a cross-geography snapshot (omits geo filter, + * includes geo in projection, returns a single month of data). */ -const createReportController = (reportType) => { +const createReportController = (reportType, { crossGeo = false } = {}) => { const config = REPORT_CONFIGS[reportType]; if (!config) { throw new Error(`Unknown report type: ${reportType}`); @@ -79,20 +81,10 @@ const createReportController = (reportType) => { // Validate and process technology array const techArray = validateArrayParameter(technologyParam, 'technology'); - // Handle 'latest' date substitution - let startDate = params.start; - if (startDate === 'latest') { - startDate = await getLatestDate(firestore, config.table); - } - // Build Firestore query let query = firestore.collection(config.table); - // Apply required filters - query = query.where('geo', '==', geoParam); query = query.where('rank', '==', rankParam); - - // Apply technology filter with batch processing query = query.where('technology', 'in', techArray); // Apply version filter with special handling for 'ALL' case @@ -102,12 +94,27 @@ const createReportController = (reportType) => { //query = query.where('version', '==', 'ALL'); } - // Apply date filters - if (startDate) query = query.where('date', '>=', startDate); - if (params.end) query = query.where('date', '<=', params.end); + if (crossGeo) { + // Cross-geo: single-month snapshot, all geographies included. + // Use 'end' param if provided, otherwise default to latest available date. + const snapshotDate = params.end || await getLatestDate(firestore, config.table); + query = query.where('date', '==', snapshotDate); + query = query.select('date', 'technology', 'geo', config.dataField); + } else { + // Normal time-series: filter by geo, apply date range, no geo in projection. + query = query.where('geo', '==', geoParam); - // Apply field projection to optimize query - query = query.select('date', 'technology', config.dataField); + // Handle 'latest' date substitution + let startDate = params.start; + if (startDate === 'latest') { + startDate = await getLatestDate(firestore, config.table); + } + + if (startDate) query = query.where('date', '>=', startDate); + if (params.end) query = query.where('date', '<=', params.end); + + query = query.select('date', 'technology', config.dataField); + } // Execute query const snapshot = await query.get(); @@ -132,5 +139,6 @@ export const listAdoptionData = createReportController('adoption'); export const listCWVTechData = createReportController('cwv'); export const listLighthouseData = createReportController('lighthouse'); export const listPageWeightData = createReportController('pageWeight'); +export const listGeoBreakdownData = createReportController('cwv', { crossGeo: true }); diff --git a/src/index.js b/src/index.js index a5831b9..cfe2b32 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ const controllers = { ranks: null, geos: null, versions: null, + geoBreakdown: null, static: null }; @@ -42,6 +43,9 @@ const getController = async (name) => { case 'versions': controllers[name] = await import('./controllers/versionsController.js'); break; + case 'geoBreakdown': + controllers[name] = await import('./controllers/reportController.js'); + break; case 'static': controllers[name] = await import('./controllers/cdnController.js'); break; @@ -140,6 +144,9 @@ const handleRequest = async (req, res) => { } else if (pathname === '/v1/versions' && req.method === 'GET') { const { listVersions } = await getController('versions'); await listVersions(req, res); + } else if (pathname === '/v1/geo-breakdown' && req.method === 'GET') { + const { listGeoBreakdownData } = await getController('geoBreakdown'); + await listGeoBreakdownData(req, res); } else if (pathname.startsWith('/v1/static/') && req.method === 'GET') { // GCS proxy endpoint for reports files const filePath = decodeURIComponent(pathname.replace('/v1/static/', '')); From 6351949e74670945fbdda1dd6cfd8353044ed2a9 Mon Sep 17 00:00:00 2001 From: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:17:15 +0100 Subject: [PATCH 2/2] test: add tests for /v1/geo-breakdown --- src/tests/routes.test.js | 43 ++++++++++++++++++++++++++++++++++++++++ test-api.sh | 23 +++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/tests/routes.test.js b/src/tests/routes.test.js index 6a627f6..8a93593 100644 --- a/src/tests/routes.test.js +++ b/src/tests/routes.test.js @@ -381,6 +381,49 @@ describe('API Routes', () => { }); }); + describe('GET /v1/geo-breakdown', () => { + it('should return geo breakdown data with default parameters', async () => { + const res = await request(app).get('/v1/geo-breakdown'); + expect(res.statusCode).toEqual(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should return geo breakdown data for a specific technology', async () => { + const res = await request(app).get('/v1/geo-breakdown?technology=WordPress'); + expect(res.statusCode).toEqual(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should accept an end date parameter', async () => { + const res = await request(app).get('/v1/geo-breakdown?technology=WordPress&end=2024-01-01'); + expect(res.statusCode).toEqual(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should accept a rank parameter', async () => { + const res = await request(app).get('/v1/geo-breakdown?technology=WordPress&rank=Top%201M'); + expect(res.statusCode).toEqual(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should handle empty technology parameter (defaults to ALL)', async () => { + const res = await request(app).get('/v1/geo-breakdown?technology='); + expect(res.statusCode).toEqual(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should handle CORS preflight requests', async () => { + const res = await request(app) + .options('/v1/geo-breakdown') + .set('Origin', 'http://example.com') + .set('Access-Control-Request-Method', 'GET') + .set('Access-Control-Request-Headers', 'Content-Type'); + + expect(res.statusCode).toEqual(204); + expect(res.headers['access-control-allow-origin']).toEqual('*'); + }); + }); + describe('Error Handling', () => { it('should return 404 for unknown endpoints', async () => { const res = await request(app).get('/v1/unknown-endpoint'); diff --git a/test-api.sh b/test-api.sh index ead560f..7809094 100755 --- a/test-api.sh +++ b/test-api.sh @@ -34,7 +34,7 @@ test_filter() { echo "Testing filter: ${description}" echo "URL: ${url}" - + response=$(curl -s -w "\n%{http_code}" "${url}") http_code=$(echo "$response" | tail -n1) body=$(echo "$response" | sed '$d') @@ -48,7 +48,7 @@ test_filter() { # Run the verification check using jq # The check should return "true" if it passes check_result=$(echo "$body" | jq "${filter_check}") - + if [[ "$check_result" != "true" ]]; then echo "Error: Filter verification failed for ${description}" echo "Verification expression: ${filter_check}" @@ -176,4 +176,23 @@ test_filter "/v1/categories" "" \ "length > 0" \ "Categories list is not empty" +# Test geo-breakdown endpoint +test_cors_preflight "/v1/geo-breakdown" +test_endpoint "/v1/geo-breakdown" "" +test_endpoint "/v1/geo-breakdown" "?technology=WordPress" +test_endpoint "/v1/geo-breakdown" "?technology=WordPress&rank=Top%201M" + +# Test geo-breakdown filter correspondences +test_filter "/v1/geo-breakdown" "" \ + "all(.[]; .technology == \"ALL\") and length > 0" \ + "Geo breakdown defaults (technology=ALL)" + +test_filter "/v1/geo-breakdown" "?technology=WordPress" \ + "all(.[]; .technology == \"WordPress\") and length > 0" \ + "Geo breakdown specific technology (WordPress)" + +test_filter "/v1/geo-breakdown" "?technology=WordPress" \ + "all(.[]; has(\"geo\")) and length > 0" \ + "Geo breakdown response includes geo field" + echo "API tests complete! All endpoints returned 200 and data corresponds to filters."