diff --git a/README.md b/README.md index 500b339..2724719 100644 --- a/README.md +++ b/README.md @@ -174,9 +174,9 @@ Lists available versions. #### Versions Parameters -- `version` (optional): Filter by version name(s) - comma-separated list - `technology` (optional): Filter by technology name(s) - comma-separated list - `category` (optional): Filter by category - comma-separated list +- `version` (optional): Filter by version name(s) - comma-separated list - `onlyname` (optional): If present, returns only version names - `fields` (optional): Comma-separated list of fields to include in the response (see [Field Selection API Documentation](#field-selection-api-documentation) for details) @@ -209,10 +209,10 @@ Provides technology adoption data. #### Adoption Parameters - `technology` (required): Filter by technology name(s) - comma-separated list +- `geo` (required): Filter by geographic location +- `rank` (required): Filter by rank - `start` (optional): Filter by date range start (YYYY-MM-DD or 'latest') - `end` (optional): Filter by date range end (YYYY-MM-DD) -- `geo` (optional): Filter by geographic location -- `rank` (optional): Filter by rank #### Adoption Response @@ -334,8 +334,8 @@ Provides Page Weight metrics for technologies. #### Page Weight Parameters - `technology` (required): Filter by technology name(s) - comma-separated list -- `geo` (optional): Filter by geographic location -- `rank` (optional): Filter by rank +- `geo` (required): Filter by geographic location +- `rank` (required): Filter by rank - `start` (optional): Filter by date range start (YYYY-MM-DD or 'latest') - `end` (optional): Filter by date range end (YYYY-MM-DD) @@ -387,6 +387,70 @@ Returns a JSON object with the following schema: ] ``` + +### `GET /audits` + +Provides Lighthouse audits for technologies. + +#### Audits Parameters + +- `technology` (required): Filter by technology name(s) - comma-separated list +- `geo` (required): Filter by geographic location +- `rank` (required): Filter by rank +- `start` (optional): Filter by date range start (YYYY-MM-DD or 'latest') +- `end` (optional): Filter by date range end (YYYY-MM-DD) + +#### Audits Response + +```bash +curl --request GET \ + --url 'https://{{HOST}}/v1/audits?start=latest&geo=ALL&technology=WordPress&rank=ALL' +``` + +Returns a JSON object with the following schema: + +```json +[ + { + "date": "2025-06-01", + "audits": [ + { + "desktop": { + "pass_origins": 2428028 + }, + "mobile": { + "pass_origins": 2430912 + }, + "id": "first-contentful-paint", + "category": "performance" + }, + { + "desktop": { + "pass_origins": 490451 + }, + "mobile": { + "pass_origins": 477218 + }, + "id": "largest-contentful-paint", + "category": "performance" + }, + { + "desktop": { + "pass_origins": 1221876 + }, + "mobile": { + "pass_origins": 1296673 + }, + "id": "cumulative-layout-shift", + "category": "performance" + } + ], + "technology": "WordPress" + }, + ... +] +``` + ### `GET /ranks` Lists all available ranks. @@ -456,6 +520,32 @@ Returns a JSON object with the following schema: } ``` +### `POST /v1/cache-reset` + +Resets all caches in the API. This endpoint requires a POST request. + +```bash +curl --request POST \ + --url 'https://{{HOST}}/v1/cache-reset' +``` + +Returns a JSON object with the following schema: + +```json +{ + "success": true, + "message": "All caches have been reset", + "before": { + "queryCache": 150, + "dateCache": 12 + }, + "after": { + "queryCache": 0, + "dateCache": 0 + } +} +``` + ## Testing ```bash diff --git a/src/__tests__/routes.test.js b/src/__tests__/routes.test.js index 90ac268..f84cd9b 100644 --- a/src/__tests__/routes.test.js +++ b/src/__tests__/routes.test.js @@ -337,6 +337,10 @@ describe('API Routes', () => { const res = await request(app).get('/v1/technologies?invalid=parameter'); expect(res.statusCode).toEqual(200); expect(Array.isArray(res.body)).toBe(true); + + //expect(res.body).toHaveProperty('errors'); + //expect(res.body.errors[0]).toHaveProperty('error'); + //expect(res.body.errors[0].error).toContain('Unsupported parameters: '); }); }); @@ -358,4 +362,35 @@ describe('API Routes', () => { expect(res.headers['timing-allow-origin']).toEqual('*'); }); }); + + describe('Cache Management', () => { + it('should provide cache stats', async () => { + const res = await request(app) + .get('/v1/cache-stats') + .expect(200); + + expect(res.body).toHaveProperty('queryCache'); + expect(res.body).toHaveProperty('dateCache'); + expect(res.body).toHaveProperty('config'); + }); + + it('should reset cache on POST request', async () => { + const res = await request(app) + .post('/v1/cache-reset') + .expect(200); + + expect(res.body).toHaveProperty('success', true); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('before'); + expect(res.body).toHaveProperty('after'); + }); + + it('should handle cache reset OPTIONS request', async () => { + const res = await request(app) + .options('/v1/cache-reset') + .expect(204); + + expect(res.headers['access-control-allow-methods']).toContain('POST'); + }); + }); }); diff --git a/src/controllers/categoriesController.js b/src/controllers/categoriesController.js index 6d46972..6a76918 100644 --- a/src/controllers/categoriesController.js +++ b/src/controllers/categoriesController.js @@ -6,6 +6,19 @@ import { executeQuery, validateArrayParameter } from '../utils/controllerHelpers */ const listCategories = async (req, res) => { const queryBuilder = async (params) => { + /* + // Validate parameters + const supportedParams = ['category', 'onlyname', 'fields']; + const providedParams = Object.keys(params); + const unsupportedParams = providedParams.filter(param => !supportedParams.includes(param)); + + if (unsupportedParams.length > 0) { + const error = new Error(`Unsupported parameters: ${unsupportedParams.join(', ')}.`); + error.statusCode = 400; + throw error; + } + */ + const isOnlyNames = params.onlyname || typeof params.onlyname === 'string'; const hasCustomFields = params.fields && !isOnlyNames; diff --git a/src/controllers/reportController.js b/src/controllers/reportController.js index fc42a58..9f24123 100644 --- a/src/controllers/reportController.js +++ b/src/controllers/reportController.js @@ -32,6 +32,10 @@ const REPORT_CONFIGS = { cwv: { table: 'core_web_vitals', dataField: 'vitals' + }, + audits: { + table: 'audits', + dataField: 'audits' } }; @@ -49,6 +53,19 @@ const createReportController = (reportType) => { try { const params = req.query; + /* + // Validate supported parameters + const supportedParams = ['technology', 'geo', 'rank', 'start', 'end']; + const providedParams = Object.keys(params); + const unsupportedParams = providedParams.filter(param => !supportedParams.includes(param)); + + if (unsupportedParams.length > 0) { + const error = new Error(`Unsupported parameters: ${unsupportedParams.join(', ')}.`); + error.statusCode = 400; + throw error; + } + */ + // Validate required parameters using shared utility const errors = validateRequiredParams(params, [ REQUIRED_PARAMS.GEO, @@ -133,7 +150,10 @@ const createReportController = (reportType) => { }; // Export individual controller functions +export const listAuditsData = createReportController('audits'); export const listAdoptionData = createReportController('adoption'); -export const listPageWeightData = createReportController('pageWeight'); -export const listLighthouseData = createReportController('lighthouse'); export const listCWVTechData = createReportController('cwv'); +export const listLighthouseData = createReportController('lighthouse'); +export const listPageWeightData = createReportController('pageWeight'); + + diff --git a/src/controllers/technologiesController.js b/src/controllers/technologiesController.js index 23b4029..5d75aba 100644 --- a/src/controllers/technologiesController.js +++ b/src/controllers/technologiesController.js @@ -6,6 +6,19 @@ import { executeQuery, validateTechnologyArray, validateArrayParameter, FIRESTOR */ const listTechnologies = async (req, res) => { const queryBuilder = async (params) => { + /* + // Validate parameters + const supportedParams = ['technology', 'category', 'onlyname', 'fields']; + const providedParams = Object.keys(params); + const unsupportedParams = providedParams.filter(param => !supportedParams.includes(param)); + + if (unsupportedParams.length > 0) { + const error = new Error(`Unsupported parameters: ${unsupportedParams.join(', ')}.`); + error.statusCode = 400; + throw error; + } + */ + const isOnlyNames = params.onlyname || typeof params.onlyname === 'string'; const hasCustomFields = params.fields && !isOnlyNames; diff --git a/src/controllers/versionsController.js b/src/controllers/versionsController.js index 17cd8f7..8af580a 100644 --- a/src/controllers/versionsController.js +++ b/src/controllers/versionsController.js @@ -6,6 +6,19 @@ import { executeQuery, validateTechnologyArray, FIRESTORE_IN_LIMIT } from '../ut */ const listVersions = async (req, res) => { const queryBuilder = async (params) => { + /* + // Validate parameters + const supportedParams = ['version', 'technology', 'category', 'onlyname', 'fields']; + const providedParams = Object.keys(params); + const unsupportedParams = providedParams.filter(param => !supportedParams.includes(param)); + + if (unsupportedParams.length > 0) { + const error = new Error(`Unsupported parameters: ${unsupportedParams.join(', ')}.`); + error.statusCode = 400; + throw error; + } + */ + let query = firestore.collection('versions'); // Apply technology filter with validation diff --git a/src/index.js b/src/index.js index 46a01d4..cf2baeb 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ const controllers = { cwvtech: null, lighthouse: null, pageWeight: null, + audits: null, ranks: null, geos: null, versions: null @@ -30,6 +31,7 @@ const getController = async (name) => { case 'cwvtech': case 'lighthouse': case 'pageWeight': + case 'audits': controllers[name] = await import('./controllers/reportController.js'); break; case 'ranks': @@ -49,7 +51,7 @@ const getController = async (name) => { // Helper function to set CORS headers const setCORSHeaders = (res) => { res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Timing-Allow-Origin'); res.setHeader('Access-Control-Max-Age', '86400'); }; @@ -105,6 +107,14 @@ const handleRequest = async (req, res) => { return; } + // Validate URL to skip XSS attacks + const unsafe = /onerror|onload|javascript:/i; + if (unsafe.test(req.url)) { + res.statusCode = 400 + res.end(JSON.stringify({ error: 'Invalid input' })); + return; + } + // Parse URL const parsedUrl = url.parse(req.url, true); const pathname = parsedUrl.pathname; @@ -136,6 +146,9 @@ const handleRequest = async (req, res) => { } else if (pathname === '/v1/page-weight' && req.method === 'GET') { const { listPageWeightData } = await getController('pageWeight'); await listPageWeightData(req, res); + } else if (pathname === '/v1/audits' && req.method === 'GET') { + const { listAuditsData } = await getController('audits'); + await listAuditsData(req, res); } else if (pathname === '/v1/ranks' && req.method === 'GET') { const { listRanks } = await getController('ranks'); await listRanks(req, res); @@ -150,6 +163,11 @@ const handleRequest = async (req, res) => { const { getCacheStats } = await import('./utils/controllerHelpers.js'); const stats = getCacheStats(); sendJSONResponse(res, stats); + } else if (pathname === '/v1/cache-reset' && req.method === 'POST') { + // Cache reset endpoint + const { resetCache } = await import('./utils/controllerHelpers.js'); + const result = resetCache(); + sendJSONResponse(res, result); } else { // 404 Not Found res.statusCode = 404; diff --git a/src/utils/controllerHelpers.js b/src/utils/controllerHelpers.js index 047f7bf..b22313f 100644 --- a/src/utils/controllerHelpers.js +++ b/src/utils/controllerHelpers.js @@ -230,9 +230,14 @@ const getCacheStats = () => { */ const handleControllerError = (res, error, operation) => { console.error(`Error ${operation}:`, error); - res.statusCode = 500; + const statusCode = error.statusCode || 500; + res.statusCode = statusCode; + + // Use custom error message for client errors (4xx), generic message for server errors (5xx) + const errorMessage = statusCode >= 400 && statusCode < 500 ? error.message : `Failed to ${operation}`; + res.end(JSON.stringify({ - errors: [{ error: `Failed to ${operation}` }] + errors: [{ error: errorMessage }] })); }; @@ -313,6 +318,31 @@ const validateTechnologyArray = (technologyParam) => { } }; +/** + * Reset all caches + * @returns {Object} Reset operation result + */ +const resetCache = () => { + const beforeStats = { + queryCache: queryResultCache.size, + dateCache: latestDateCache.size + }; + + // Clear both caches + queryResultCache.clear(); + latestDateCache.clear(); + + return { + success: true, + message: 'All caches have been reset', + before: beforeStats, + after: { + queryCache: queryResultCache.size, + dateCache: latestDateCache.size + } + }; +}; + export { REQUIRED_PARAMS, FIRESTORE_IN_LIMIT, @@ -326,5 +356,6 @@ export { setCachedQueryResult, getCacheStats, executeQuery, - validateTechnologyArray + validateTechnologyArray, + resetCache }; diff --git a/terraform/dev/main.tf b/terraform/dev/main.tf index dc9c003..db15672 100644 --- a/terraform/dev/main.tf +++ b/terraform/dev/main.tf @@ -52,14 +52,14 @@ paths: /v1/adoption: get: summary: adoption - operationId: getadoptionReports + operationId: getAdoptionReports responses: 200: description: String /v1/page-weight: get: summary: pageWeight - operationId: getpageWeight + operationId: getPageWeightReports responses: 200: description: String @@ -73,7 +73,7 @@ paths: /v1/cwv: get: summary: cwv - operationId: getCwv + operationId: getCWVReports responses: 200: description: String @@ -98,6 +98,13 @@ paths: responses: 200: description: String + /v1/audits: + get: + summary: audits + operationId: getAuditReports + responses: + 200: + description: String EOF ) } diff --git a/terraform/prod/main.tf b/terraform/prod/main.tf index 9439dac..721b575 100644 --- a/terraform/prod/main.tf +++ b/terraform/prod/main.tf @@ -52,14 +52,14 @@ paths: /v1/adoption: get: summary: adoption - operationId: getadoptionReports + operationId: getAdoptionReports responses: 200: description: String /v1/page-weight: get: summary: pageWeight - operationId: getpageWeight + operationId: getPageWeightReports responses: 200: description: String @@ -73,7 +73,7 @@ paths: /v1/cwv: get: summary: cwv - operationId: getCwv + operationId: getCWVReports responses: 200: description: String @@ -98,6 +98,13 @@ paths: responses: 200: description: String + /v1/audits: + get: + summary: audits + operationId: getAuditReports + responses: + 200: + description: String EOF ) } diff --git a/test-api.sh b/test-api.sh index ea98db3..e73e836 100755 --- a/test-api.sh +++ b/test-api.sh @@ -85,6 +85,7 @@ test_endpoint "/" "" # Test technologies endpoint test_cors_preflight "/v1/technologies" +test_endpoint "/v1/technologies" "?onlyname=true" test_endpoint "/v1/technologies" "?technology=WordPress&onlyname=true" test_endpoint "/v1/technologies" "?technology=WordPress&onlyname=true&fields=technology,icon" test_endpoint "/v1/technologies" "?technology=WordPress&category=CMS&fields=technology,icon" @@ -112,4 +113,30 @@ test_endpoint "/v1/lighthouse" "?technology=WordPress&geo=ALL&rank=ALL&start=lat # Test page-weight endpoint test_endpoint "/v1/page-weight" "?technology=WordPress&geo=ALL&rank=ALL&start=latest" +# Test audits endpoint +test_endpoint "/v1/audits" "?technology=WordPress&geo=ALL&rank=ALL&start=latest" + +# Test cache stats endpoint +echo "Testing cache stats endpoint..." +test_endpoint "/v1/cache-stats" "" + +# Test cache reset endpoint +echo "Testing cache reset endpoint..." +echo "Checking cache reset: http://localhost:3000/v1/cache-reset" +response=$(curl -s -w "\n%{http_code}" -X POST "http://localhost:3000/v1/cache-reset") +http_code=$(echo "$response" | tail -n1) +body=$(echo "$response" | sed '$d') + +echo "$body" | jq . +echo "Status code: $http_code" + +if [[ $http_code -ne 200 ]]; then + echo "Error: Cache reset endpoint returned non-200 status code" + exit 1 +fi + +echo "" +echo "----------------------" +echo "" + echo "API tests complete! All endpoints returned 200 status code and CORS is properly configured."