diff --git a/src/controllers/adoptionController.js b/src/controllers/adoptionController.js deleted file mode 100644 index b06b5d9..0000000 --- a/src/controllers/adoptionController.js +++ /dev/null @@ -1,111 +0,0 @@ -import { firestoreOld } from '../utils/db.js'; -const firestore = firestoreOld; - -import { - getLatestDate, - generateQueryCacheKey, - getCachedQueryResult, - setCachedQueryResult -} from '../utils/controllerHelpers.js'; - -const TABLE = 'adoption'; - -/** - * List adoption data with filtering - */ -const listAdoptionData = async (req, res) => { - try { - const params = req.query; - - // Validate required parameters inline for speed - if (!params.geo || !params.rank || !params.technology) { - res.statusCode = 400; - res.end(JSON.stringify({ - success: false, - errors: [ - ...(!params.geo ? [{ geo: 'missing geo parameter' }] : []), - ...(!params.rank ? [{ rank: 'missing rank parameter' }] : []), - ...(!params.technology ? [{ technology: 'missing technology parameter' }] : []) - ] - })); - return; - } - - // Fast preprocessing - handle 'latest' date and technology array - const techArray = params.technology ? decodeURIComponent(params.technology).split(',') : []; - - let startDate = params.start; - if (startDate === 'latest') { - startDate = await getLatestDate(firestore, TABLE); - } - - // Create cache key for this specific query - const queryFilters = { - geo: params.geo, - rank: params.rank, - technology: techArray, - startDate: startDate, - endDate: params.end - }; - const cacheKey = generateQueryCacheKey(TABLE, queryFilters); - - // Check cache first - const cachedResult = getCachedQueryResult(cacheKey); - if (cachedResult) { - res.statusCode = 200; - res.end(JSON.stringify(cachedResult)); - return; - } - - // Build query - let query = firestore.collection(TABLE); - - // Apply required filters - query = query.where('geo', '==', params.geo); - query = query.where('rank', '==', params.rank); - - // Apply technology filter - if (techArray.length <= 30) { - // Use 'in' operator for batch processing (Firestore limit: 30 values https://cloud.google.com/firestore/docs/query-data/queries#limits_on_or_queries) - query = query.where('technology', 'in', techArray); - } else { - res.statusCode = 400; - res.end(JSON.stringify({ - success: false, - errors: [{ technology: 'Too many technologies specified. Maximum 30 allowed.' }] - })); - return; - } - - // Apply date filters - if (startDate) query = query.where('date', '>=', startDate); - if (params.end) query = query.where('date', '<=', params.end); - - // Apply field projection to exclude geo/rank - query = query.select('date', 'technology', 'adoption'); - - // Execute query - const snapshot = await query.get(); - const data = []; - snapshot.forEach(doc => { - data.push(doc.data()); - }); - - // Cache the result - setCachedQueryResult(cacheKey, data); - - // Direct response - res.statusCode = 200; - res.end(JSON.stringify(data)); - } catch (error) { - console.error('Error fetching adoption data:', error); - res.statusCode = 500; - res.end(JSON.stringify({ - errors: [{ error: 'Failed to fetch adoption data' }] - })); - } -}; - -export { - listAdoptionData -}; diff --git a/src/controllers/categoriesController.js b/src/controllers/categoriesController.js index 66170cb..6d46972 100644 --- a/src/controllers/categoriesController.js +++ b/src/controllers/categoriesController.js @@ -1,80 +1,52 @@ import { firestore } from '../utils/db.js'; -import { - applyArrayFilter, - selectFields, - generateQueryCacheKey, - getCachedQueryResult, - setCachedQueryResult -} from '../utils/controllerHelpers.js'; +import { executeQuery, validateArrayParameter } from '../utils/controllerHelpers.js'; /** - * List categories with optional filtering and field selection - Optimized version + * List categories with optional filtering and field selection */ const listCategories = async (req, res) => { - try { - const params = req.query; + const queryBuilder = async (params) => { const isOnlyNames = params.onlyname || typeof params.onlyname === 'string'; const hasCustomFields = params.fields && !isOnlyNames; - // Create cache key for this specific query - const queryFilters = { - category: params.category, - onlyname: isOnlyNames, - fields: params.fields - }; - const cacheKey = generateQueryCacheKey('categories', queryFilters); - - // Check cache first - const cachedResult = getCachedQueryResult(cacheKey); - if (cachedResult) { - res.statusCode = 200; - res.end(JSON.stringify(cachedResult)); - return; - } - let query = firestore.collection('categories').orderBy('category', 'asc'); - // Apply category filter using shared utility - query = applyArrayFilter(query, 'category', params.category); + // Apply category filter with validation + if (params.category) { + const categories = validateArrayParameter(params.category, 'category'); + if (categories.length > 0) { + query = query.where('category', 'in', categories); + } + } + // Apply field selection if (isOnlyNames) { - // Only select category field for names-only queries query = query.select('category'); } else if (hasCustomFields) { - // Select only requested fields const requestedFields = params.fields.split(',').map(f => f.trim()); query = query.select(...requestedFields); } - // Execute query - const snapshot = await query.get(); - const data = []; + return query; + }; - // Process results based on response type - snapshot.forEach(doc => { - const docData = doc.data(); + const dataProcessor = (data, params) => { + const isOnlyNames = params.onlyname || typeof params.onlyname === 'string'; - if (isOnlyNames) { - data.push(docData.category); - } else { - // Data already filtered by select(), just return it - data.push(docData); - } - }); + if (isOnlyNames) { + return data.map(item => item.category); + } + + return data; + }; - // Cache the result - setCachedQueryResult(cacheKey, data); + // Include onlyname and fields in cache key calculation + const customCacheKeyData = { + onlyname: req.query.onlyname || false, + fields: req.query.fields + }; - // Direct response - res.statusCode = 200; - res.end(JSON.stringify(data)); - } catch (error) { - console.error('Error fetching categories:', error); - res.statusCode = 500; - res.end(JSON.stringify({ - errors: [{ error: 'Failed to fetch categories' }] - })); - } + await executeQuery(req, res, 'categories', queryBuilder, dataProcessor, customCacheKeyData); }; export { listCategories }; diff --git a/src/controllers/cwvtechController.js b/src/controllers/cwvtechController.js deleted file mode 100644 index 35a51fc..0000000 --- a/src/controllers/cwvtechController.js +++ /dev/null @@ -1,112 +0,0 @@ -import { firestoreOld } from '../utils/db.js'; -const firestore = firestoreOld; - -import { - getLatestDate, - generateQueryCacheKey, - getCachedQueryResult, - setCachedQueryResult -} from '../utils/controllerHelpers.js'; - -const TABLE = 'core_web_vitals'; - -/** - * List Core Web Vitals data with filtering - */ -const listCWVTechData = async (req, res) => { - try { - const params = req.query; - - // Validate required parameters inline for speed - if (!params.geo || !params.rank || !params.technology) { - res.statusCode = 400; - res.end(JSON.stringify({ - success: false, - errors: [ - ...(!params.geo ? [{ geo: 'missing geo parameter' }] : []), - ...(!params.rank ? [{ rank: 'missing rank parameter' }] : []), - ...(!params.technology ? [{ technology: 'missing technology parameter' }] : []) - ] - })); - return; - } - - // Fast preprocessing - handle 'latest' date and technology array - const techArray = params.technology ? decodeURIComponent(params.technology).split(',') : []; - - let startDate = params.start; - if (startDate === 'latest') { - startDate = await getLatestDate(firestore, TABLE); - } - - // Create cache key for this specific query - const queryFilters = { - geo: params.geo, - rank: params.rank, - technology: techArray, - startDate: startDate, - endDate: params.end - }; - const cacheKey = generateQueryCacheKey(TABLE, queryFilters); - - // Check cache first - const cachedResult = getCachedQueryResult(cacheKey); - if (cachedResult) { - res.statusCode = 200; - res.end(JSON.stringify(cachedResult)); - return; - } - - // Build query - let query = firestore.collection(TABLE); - - // Apply required filters - query = query.where('geo', '==', params.geo); - query = query.where('rank', '==', params.rank); - - - // Apply technology filter - if (techArray.length <= 30) { - // Use 'in' operator for batch processing (Firestore limit: 30 values) - query = query.where('technology', 'in', techArray); - } else { - res.statusCode = 400; - res.end(JSON.stringify({ - success: false, - errors: [{ technology: 'Too many technologies specified. Maximum 30 allowed.' }] - })); - return; - } - - // Apply date filters - if (startDate) query = query.where('date', '>=', startDate); - if (params.end) query = query.where('date', '<=', params.end); - - // Apply field projection to exclude geo/rank - query = query.select('date', 'technology', 'vitals'); - - // Execute query - const snapshot = await query.get(); - const data = []; - snapshot.forEach(doc => { - data.push(doc.data()); - }); - - // Cache the result - setCachedQueryResult(cacheKey, data); - - // Direct response without wrapper functions - res.statusCode = 200; - res.end(JSON.stringify(data)); - } catch (error) { - console.error('Error fetching Core Web Vitals data:', error); - res.statusCode = 500; - res.end(JSON.stringify({ - errors: [{ error: 'Failed to fetch Core Web Vitals data' }] - })); - } -}; - -export { - listCWVTechData -}; diff --git a/src/controllers/geosController.js b/src/controllers/geosController.js index 31c8581..c670aea 100644 --- a/src/controllers/geosController.js +++ b/src/controllers/geosController.js @@ -1,38 +1,15 @@ import { firestore } from '../utils/db.js'; -import { handleControllerError, generateQueryCacheKey, getCachedQueryResult, setCachedQueryResult } from '../utils/controllerHelpers.js'; +import { executeQuery } from '../utils/controllerHelpers.js'; /** * List all geographic locations from database */ const listGeos = async (req, res) => { - try { - // Generate cache key for this query - const cacheKey = generateQueryCacheKey('geos', { orderBy: 'mobile_origins' }); + const queryBuilder = async () => { + return firestore.collection('geos').orderBy('mobile_origins', 'desc').select('geo'); + }; - // Check cache first - const cachedResult = getCachedQueryResult(cacheKey); - if (cachedResult) { - res.statusCode = 200; - res.end(JSON.stringify(cachedResult)); - return; - } - - const snapshot = await firestore.collection('geos').orderBy('mobile_origins', 'desc').select('geo').get(); - const data = []; - - // Extract only the 'geo' property from each document - snapshot.forEach(doc => { - data.push(doc.data()); - }); - - // Cache the result - setCachedQueryResult(cacheKey, data); - - res.statusCode = 200; - res.end(JSON.stringify(data)); - } catch (error) { - handleControllerError(res, error, 'fetching geographic locations'); - } + await executeQuery(req, res, 'geos', queryBuilder); }; export { diff --git a/src/controllers/lighthouseController.js b/src/controllers/lighthouseController.js deleted file mode 100644 index e7f0d49..0000000 --- a/src/controllers/lighthouseController.js +++ /dev/null @@ -1,111 +0,0 @@ -import { firestoreOld } from '../utils/db.js'; -const firestore = firestoreOld; - -import { - getLatestDate, - generateQueryCacheKey, - getCachedQueryResult, - setCachedQueryResult -} from '../utils/controllerHelpers.js'; - -const TABLE = 'lighthouse'; - -/** - * List Lighthouse data with filtering - */ -const listLighthouseData = async (req, res) => { - try { - const params = req.query; - - // Validate required parameters inline for speed - if (!params.geo || !params.rank || !params.technology) { - res.statusCode = 400; - res.end(JSON.stringify({ - success: false, - errors: [ - ...(!params.geo ? [{ geo: 'missing geo parameter' }] : []), - ...(!params.rank ? [{ rank: 'missing rank parameter' }] : []), - ...(!params.technology ? [{ technology: 'missing technology parameter' }] : []) - ] - })); - return; - } - - // Fast preprocessing - handle 'latest' date and technology array - const techArray = params.technology ? decodeURIComponent(params.technology).split(',') : []; - - let startDate = params.start; - if (startDate === 'latest') { - startDate = await getLatestDate(firestore, TABLE); - } - - // Create cache key for this specific query - const queryFilters = { - geo: params.geo, - rank: params.rank, - technology: techArray, - startDate: startDate, - endDate: params.end - }; - const cacheKey = generateQueryCacheKey(TABLE, queryFilters); - - // Check cache first - const cachedResult = getCachedQueryResult(cacheKey); - if (cachedResult) { - res.statusCode = 200; - res.end(JSON.stringify(cachedResult)); - return; - } - - // Build query - let query = firestore.collection(TABLE); - - // Apply required filters - query = query.where('geo', '==', params.geo); - query = query.where('rank', '==', params.rank); - - // Apply technology filter - if (techArray.length <= 30) { - // Use 'in' operator for batch processing (Firestore limit: 30 values) - query = query.where('technology', 'in', techArray); - } else { - res.statusCode = 400; - res.end(JSON.stringify({ - success: false, - errors: [{ technology: 'Too many technologies specified. Maximum 30 allowed.' }] - })); - return; - } - - // Apply date filters - if (startDate) query = query.where('date', '>=', startDate); - if (params.end) query = query.where('date', '<=', params.end); - - // Apply field projection to exclude geo/rank - query = query.select('date', 'technology', 'lighthouse'); - - // Execute query - const snapshot = await query.get(); - const data = []; - snapshot.forEach(doc => { - data.push(doc.data()); - }); - - // Cache the result - setCachedQueryResult(cacheKey, data); - - // Direct response without wrapper functions - res.statusCode = 200; - res.end(JSON.stringify(data)); - } catch (error) { - console.error('Error fetching Lighthouse data:', error); - res.statusCode = 500; - res.end(JSON.stringify({ - errors: [{ error: 'Failed to fetch Lighthouse data' }] - })); - } -}; - -export { - listLighthouseData -}; diff --git a/src/controllers/pageWeightController.js b/src/controllers/pageWeightController.js deleted file mode 100644 index 7fd6ff7..0000000 --- a/src/controllers/pageWeightController.js +++ /dev/null @@ -1,111 +0,0 @@ -import { firestoreOld } from '../utils/db.js'; -const firestore = firestoreOld; - -import { - getLatestDate, - generateQueryCacheKey, - getCachedQueryResult, - setCachedQueryResult -} from '../utils/controllerHelpers.js'; - -const TABLE = 'page_weight'; - -/** - * List Page Weight data with filtering - */ -const listPageWeightData = async (req, res) => { - try { - const params = req.query; - - // Validate required parameters inline for speed - if (!params.geo || !params.rank || !params.technology) { - res.statusCode = 400; - res.end(JSON.stringify({ - success: false, - errors: [ - ...(!params.geo ? [{ geo: 'missing geo parameter' }] : []), - ...(!params.rank ? [{ rank: 'missing rank parameter' }] : []), - ...(!params.technology ? [{ technology: 'missing technology parameter' }] : []) - ] - })); - return; - } - - // Fast preprocessing - handle 'latest' date and technology array - const techArray = params.technology ? decodeURIComponent(params.technology).split(',') : []; - - let startDate = params.start; - if (startDate === 'latest') { - startDate = await getLatestDate(firestore, TABLE); - } - - // Create cache key for this specific query - const queryFilters = { - geo: params.geo, - rank: params.rank, - technology: techArray, - startDate: startDate, - endDate: params.end - }; - const cacheKey = generateQueryCacheKey(TABLE, queryFilters); - - // Check cache first - const cachedResult = getCachedQueryResult(cacheKey); - if (cachedResult) { - res.statusCode = 200; - res.end(JSON.stringify(cachedResult)); - return; - } - - // Build query - let query = firestore.collection(TABLE); - - // Apply required filters - query = query.where('geo', '==', params.geo); - query = query.where('rank', '==', params.rank); - - // Apply technology filter - if (techArray.length <= 30) { - // Use 'in' operator for batch processing (Firestore limit: 30 values) - query = query.where('technology', 'in', techArray); - } else { - res.statusCode = 400; - res.end(JSON.stringify({ - success: false, - errors: [{ technology: 'Too many technologies specified. Maximum 30 allowed.' }] - })); - return; - } - - // Apply date filters - if (startDate) query = query.where('date', '>=', startDate); - if (params.end) query = query.where('date', '<=', params.end); - - // Apply field projection to exclude geo/rank and select only needed fields - query = query.select('date', 'technology', 'pageWeight'); - - // Execute query - const snapshot = await query.get(); - const data = []; - snapshot.forEach(doc => { - data.push(doc.data()); - }); - - // Cache the result - setCachedQueryResult(cacheKey, data); - - // Direct response without wrapper functions - res.statusCode = 200; - res.end(JSON.stringify(data)); - } catch (error) { - console.error('Error fetching Page Weight data:', error); - res.statusCode = 500; - res.end(JSON.stringify({ - errors: [{ error: 'Failed to fetch Page Weight data' }] - })); - } -}; - -export { - listPageWeightData -}; diff --git a/src/controllers/ranksController.js b/src/controllers/ranksController.js index 4fa7635..5fd4aae 100644 --- a/src/controllers/ranksController.js +++ b/src/controllers/ranksController.js @@ -1,37 +1,15 @@ import { firestore } from '../utils/db.js'; -import { handleControllerError, generateQueryCacheKey, getCachedQueryResult, setCachedQueryResult } from '../utils/controllerHelpers.js'; +import { executeQuery } from '../utils/controllerHelpers.js'; /** * List all rank options from database */ const listRanks = async (req, res) => { - try { - // Generate cache key for this query - const cacheKey = generateQueryCacheKey('ranks', { orderBy: 'mobile_origins' }); + const queryBuilder = async () => { + return firestore.collection('ranks').orderBy('mobile_origins', 'desc').select('rank'); + }; - // Check cache first - const cachedResult = getCachedQueryResult(cacheKey); - if (cachedResult) { - res.statusCode = 200; - res.end(JSON.stringify(cachedResult)); - return; - } - - const snapshot = await firestore.collection('ranks').orderBy('mobile_origins', 'desc').select('rank').get(); - const data = []; - - snapshot.forEach(doc => { - data.push(doc.data()); - }); - - // Cache the result - setCachedQueryResult(cacheKey, data); - - res.statusCode = 200; - res.end(JSON.stringify(data)); - } catch (error) { - handleControllerError(res, error, 'fetching ranks'); - } + await executeQuery(req, res, 'ranks', queryBuilder); }; export { diff --git a/src/controllers/reportController.js b/src/controllers/reportController.js new file mode 100644 index 0000000..fc42a58 --- /dev/null +++ b/src/controllers/reportController.js @@ -0,0 +1,139 @@ +import { firestoreOld } from '../utils/db.js'; +const firestore = firestoreOld; + +import { + REQUIRED_PARAMS, + validateRequiredParams, + sendValidationError, + getLatestDate, + generateQueryCacheKey, + getCachedQueryResult, + setCachedQueryResult, + handleControllerError, + validateArrayParameter +} from '../utils/controllerHelpers.js'; + +/** + * Configuration for different report types + */ +const REPORT_CONFIGS = { + adoption: { + table: 'adoption', + dataField: 'adoption' + }, + pageWeight: { + table: 'page_weight', + dataField: 'pageWeight' + }, + lighthouse: { + table: 'lighthouse', + dataField: 'lighthouse' + }, + cwv: { + table: 'core_web_vitals', + dataField: 'vitals' + } +}; + +/** + * Generic report data controller factory + * Creates controllers for adoption, pageWeight, lighthouse, and cwv data + */ +const createReportController = (reportType) => { + const config = REPORT_CONFIGS[reportType]; + if (!config) { + throw new Error(`Unknown report type: ${reportType}`); + } + + return async (req, res) => { + try { + const params = req.query; + + // Validate required parameters using shared utility + const errors = validateRequiredParams(params, [ + REQUIRED_PARAMS.GEO, + REQUIRED_PARAMS.RANK, + REQUIRED_PARAMS.TECHNOLOGY + ]); + + if (errors) { + sendValidationError(res, errors); + return; + } + + // Validate and process technology array + const techArray = validateArrayParameter(params.technology, 'technology'); + + // Handle 'latest' date substitution + let startDate = params.start; + if (startDate === 'latest') { + startDate = await getLatestDate(firestore, config.table); + } + + // Create cache key for this specific query + const queryFilters = { + geo: params.geo, + rank: params.rank, + technology: techArray, + startDate: startDate, + endDate: params.end + }; + const cacheKey = generateQueryCacheKey(config.table, queryFilters); + + // Check cache first + const cachedResult = getCachedQueryResult(cacheKey); + if (cachedResult) { + res.statusCode = 200; + res.end(JSON.stringify(cachedResult)); + return; + } + + // Build Firestore query + let query = firestore.collection(config.table); + + // Apply required filters + query = query.where('geo', '==', params.geo); + query = query.where('rank', '==', params.rank); + + // Apply technology filter with batch processing + query = query.where('technology', 'in', techArray); + + // Apply version filter with special handling for 'ALL' case + if (params.version && techArray.length === 1) { + //query = query.where('version', '==', params.version); // TODO: Uncomment when migrating to a new data schema + } else { + //query = query.where('version', '==', 'ALL'); + } + + // Apply date filters + if (startDate) query = query.where('date', '>=', startDate); + if (params.end) query = query.where('date', '<=', params.end); + + // Apply field projection to optimize query + query = query.select('date', 'technology', config.dataField); + + // Execute query + const snapshot = await query.get(); + const data = []; + snapshot.forEach(doc => { + data.push(doc.data()); + }); + + // Cache the result + setCachedQueryResult(cacheKey, data); + + // Send response + res.statusCode = 200; + res.end(JSON.stringify(data)); + + } catch (error) { + handleControllerError(res, error, `fetching ${reportType} data`); + } + }; +}; + +// Export individual controller functions +export const listAdoptionData = createReportController('adoption'); +export const listPageWeightData = createReportController('pageWeight'); +export const listLighthouseData = createReportController('lighthouse'); +export const listCWVTechData = createReportController('cwv'); diff --git a/src/controllers/technologiesController.js b/src/controllers/technologiesController.js index 0ed4a32..23b4029 100644 --- a/src/controllers/technologiesController.js +++ b/src/controllers/technologiesController.js @@ -1,85 +1,65 @@ import { firestore } from '../utils/db.js'; -import { - applyArrayFilter, - selectFields, - generateQueryCacheKey, - getCachedQueryResult, - setCachedQueryResult -} from '../utils/controllerHelpers.js'; +import { executeQuery, validateTechnologyArray, validateArrayParameter, FIRESTORE_IN_LIMIT } from '../utils/controllerHelpers.js'; /** * List technologies with optional filtering and field selection */ const listTechnologies = async (req, res) => { - try { - const params = req.query; + const queryBuilder = async (params) => { const isOnlyNames = params.onlyname || typeof params.onlyname === 'string'; const hasCustomFields = params.fields && !isOnlyNames; - // Create cache key for this specific query - const queryFilters = { - technology: params.technology, - category: params.category, - onlyname: isOnlyNames, - fields: params.fields - }; - const cacheKey = generateQueryCacheKey('technologies', queryFilters); + let query = firestore.collection('technologies').orderBy('technology', 'asc'); - // Check cache first - const cachedResult = getCachedQueryResult(cacheKey); - if (cachedResult) { - res.statusCode = 200; - res.end(JSON.stringify(cachedResult)); - return; + // Apply technology filter with validation + if (params.technology) { + const technologies = validateTechnologyArray(params.technology); + if (technologies === null) { + throw new Error(`Too many technologies specified. Maximum ${FIRESTORE_IN_LIMIT} allowed.`); + } + if (technologies.length > 0) { + query = query.where('technology', 'in', technologies); + } } - let query = firestore.collection('technologies').orderBy('technology', 'asc'); - - // Apply filters using shared utilities - query = applyArrayFilter(query, 'technology', params.technology); - query = applyArrayFilter(query, 'category_obj', params.category, 'array-contains-any'); + // Apply category filter with validation + if (params.category) { + const categories = validateArrayParameter(params.category, 'category'); + if (categories.length > 0) { + query = query.where('category_obj', 'array-contains-any', categories); + } + } + // Apply field selection if (isOnlyNames) { - // Only select technology field for names-only queries query = query.select('technology'); } else if (hasCustomFields) { - // Select only requested fields const requestedFields = params.fields.split(',').map(f => f.trim()); query = query.select(...requestedFields); } else { - // Select default presentation fields query = query.select('technology', 'category', 'description', 'icon', 'origins'); } - // Execute query - const snapshot = await query.get(); - let data = []; + return query; + }; - // Process results based on response type - snapshot.forEach(doc => { - const docData = doc.data(); + const dataProcessor = (data, params) => { + const isOnlyNames = params.onlyname || typeof params.onlyname === 'string'; - if (isOnlyNames) { - data.push(docData.technology); - } else { - // Data already filtered by select(), just return it - data.push(docData) - } - }); + if (isOnlyNames) { + return data.map(item => item.technology); + } + + return data; + }; - // Cache the result - setCachedQueryResult(cacheKey, data); + // Include onlyname and fields in cache key calculation + const customCacheKeyData = { + onlyname: req.query.onlyname || false, + fields: req.query.fields + }; - // Direct response - res.statusCode = 200; - res.end(JSON.stringify(data)); - } catch (error) { - console.error('Error fetching technologies:', error); - res.statusCode = 500; - res.end(JSON.stringify({ - errors: [{ error: 'Failed to fetch technologies' }] - })); - } + await executeQuery(req, res, 'technologies', queryBuilder, dataProcessor, customCacheKeyData); }; export { diff --git a/src/controllers/versionsController.js b/src/controllers/versionsController.js index 426366d..17cd8f7 100644 --- a/src/controllers/versionsController.js +++ b/src/controllers/versionsController.js @@ -1,40 +1,21 @@ import { firestore } from '../utils/db.js'; -import { convertToArray } from '../utils/helpers.js'; -import { handleControllerError, generateQueryCacheKey, getCachedQueryResult, setCachedQueryResult } from '../utils/controllerHelpers.js'; +import { executeQuery, validateTechnologyArray, FIRESTORE_IN_LIMIT } from '../utils/controllerHelpers.js'; /** * List versions with optional technology filtering */ const listVersions = async (req, res) => { - try { - const params = req.query; - - // Generate cache key for this query - const cacheKey = generateQueryCacheKey('versions', params); - - // Check cache first - const cachedResult = getCachedQueryResult(cacheKey); - if (cachedResult) { - res.statusCode = 200; - res.end(JSON.stringify(cachedResult)); - return; - } - + const queryBuilder = async (params) => { let query = firestore.collection('versions'); - // Apply technology filter + // Apply technology filter with validation if (params.technology) { - const technologies = convertToArray(params.technology); - if (technologies.length <= 30) { - // Use single query with 'in' operator for up to 30 technologies (Firestore limit) + const technologies = validateTechnologyArray(params.technology); + if (technologies === null) { + throw new Error(`Too many technologies specified. Maximum ${FIRESTORE_IN_LIMIT} allowed.`); + } + if (technologies.length > 0) { query = query.where('technology', 'in', technologies); - } else { - res.statusCode = 400; - res.end(JSON.stringify({ - success: false, - errors: [{ technology: 'Too many technologies specified. Maximum 30 allowed.' }] - })); - return; } } @@ -43,30 +24,16 @@ const listVersions = async (req, res) => { query = query.where('version', '==', params.version); } - // Only select requested fields if specified + // Apply field selection if (params.fields) { const requestedFields = params.fields.split(',').map(f => f.trim()); query = query.select(...requestedFields); } - // Execute single query - const snapshot = await query.get(); - const data = []; - - // Extract all version documents - snapshot.forEach(doc => { - data.push(doc.data()); - }); - - // Cache the result - setCachedQueryResult(cacheKey, data); + return query; + }; - // Send response - res.statusCode = 200; - res.end(JSON.stringify(data)); - } catch (error) { - handleControllerError(res, error, 'fetching versions'); - } + await executeQuery(req, res, 'versions', queryBuilder); }; export { diff --git a/src/index.js b/src/index.js index 897e782..46a01d4 100644 --- a/src/index.js +++ b/src/index.js @@ -27,16 +27,10 @@ const getController = async (name) => { controllers[name] = await import('./controllers/categoriesController.js'); break; case 'adoption': - controllers[name] = await import('./controllers/adoptionController.js'); - break; case 'cwvtech': - controllers[name] = await import('./controllers/cwvtechController.js'); - break; case 'lighthouse': - controllers[name] = await import('./controllers/lighthouseController.js'); - break; case 'pageWeight': - controllers[name] = await import('./controllers/pageWeightController.js'); + controllers[name] = await import('./controllers/reportController.js'); break; case 'ranks': controllers[name] = await import('./controllers/ranksController.js'); diff --git a/src/package-lock.json b/src/package-lock.json index d3e6653..d4e94bb 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -9,13 +9,12 @@ "version": "1.0.0", "dependencies": { "@google-cloud/firestore": "7.11.1", - "@google-cloud/functions-framework": "^4.0.0" + "@google-cloud/functions-framework": "4.0.0" }, "devDependencies": { "@jest/transform": "^30.0.0-beta.3", "jest": "29.7.0", - "nodemon": "3.0.1", - "supertest": "^7.1.1" + "supertest": "7.1.0" }, "engines": { "node": ">=22.0.0" @@ -2046,19 +2045,6 @@ "node": "*" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -2287,31 +2273,6 @@ "node": ">=10" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -3274,19 +3235,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -3587,13 +3535,6 @@ "node": ">=0.10.0" } }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -3686,19 +3627,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -3727,16 +3655,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3774,19 +3692,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5045,88 +4950,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nodemon": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz", - "integrity": "sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^3.2.7", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/nodemon/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/nodemon/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nodemon/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/nodemon/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -5511,13 +5334,6 @@ "node": ">= 0.10" } }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -5672,19 +5488,6 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6003,32 +5806,6 @@ "dev": true, "license": "ISC" }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -6232,9 +6009,9 @@ "license": "MIT" }, "node_modules/superagent": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.1.tgz", - "integrity": "sha512-O+PCv11lgTNJUzy49teNAWLjBZfc+A1enOwTpLlH6/rsvKcTwcdTT8m9azGkVqM7HBl5jpyZ7KTPhHweokBcdg==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", + "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", "dev": true, "license": "MIT", "dependencies": { @@ -6243,7 +6020,7 @@ "debug": "^4.3.4", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.0", - "formidable": "^3.5.4", + "formidable": "^3.5.1", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.11.0" @@ -6253,9 +6030,9 @@ } }, "node_modules/superagent/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "license": "MIT", "dependencies": { @@ -6307,14 +6084,14 @@ "license": "MIT" }, "node_modules/supertest": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.1.tgz", - "integrity": "sha512-aI59HBTlG9e2wTjxGJV+DygfNLgnWbGdZxiA/sgrnNNikIW8lbDvCtF6RnhZoJ82nU7qv7ZLjrvWqCEm52fAmw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.0.tgz", + "integrity": "sha512-5QeSO8hSrKghtcWEoPiO036fxH0Ii2wVQfFZSP0oqQhmjk8bOLhDFXr4JrvaFmPuEWUoq4znY3uSi8UzLKxGqw==", "dev": true, "license": "MIT", "dependencies": { "methods": "^1.1.2", - "superagent": "^10.2.1" + "superagent": "^9.0.1" }, "engines": { "node": ">=14.18.0" @@ -6454,16 +6231,6 @@ "node": ">=0.6" } }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -6506,13 +6273,6 @@ "node": ">= 0.6" } }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/src/package.json b/src/package.json index 56a95a4..b129f94 100644 --- a/src/package.json +++ b/src/package.json @@ -14,13 +14,12 @@ }, "dependencies": { "@google-cloud/firestore": "7.11.1", - "@google-cloud/functions-framework": "^4.0.0" + "@google-cloud/functions-framework": "4.0.0" }, "devDependencies": { "@jest/transform": "^30.0.0-beta.3", "jest": "29.7.0", - "nodemon": "3.0.1", - "supertest": "^7.1.1" + "supertest": "7.1.0" }, "jest": { "testEnvironment": "node", @@ -43,7 +42,9 @@ "__filename": false, "__dirname": false }, - "transformIgnorePatterns": ["node_modules/(?!(.*\\.mjs$))"], + "transformIgnorePatterns": [ + "node_modules/(?!(.*\\.mjs$))" + ], "transform": {} } } diff --git a/src/utils/controllerHelpers.js b/src/utils/controllerHelpers.js index 523c3c8..047f7bf 100644 --- a/src/utils/controllerHelpers.js +++ b/src/utils/controllerHelpers.js @@ -1,4 +1,4 @@ -import { convertToHashes, convertToArray } from './helpers.js'; +import { convertToArray } from './helpers.js'; /** * Common parameter validation patterns @@ -28,19 +28,6 @@ const validateRequiredParams = (params, required) => { return errors.length > 0 ? errors : null; }; - -/** - * Creates an error response object - * @param {Array>} errors - Array of [key, message] arrays - * @returns {Object} Error response object - */ -const createErrorResponse = (errors) => { - return { - success: false, - errors: convertToHashes(errors) - }; -}; - /** * Send error response for missing parameters * @param {Object} res - Response object @@ -48,7 +35,10 @@ const createErrorResponse = (errors) => { */ const sendValidationError = (res, errors) => { res.statusCode = 400; - res.end(JSON.stringify(createErrorResponse(errors))); + res.end(JSON.stringify({ + success: false, + errors: errors.map(([key, message]) => ({ [key]: message })) + })); }; // Cache for latest dates to avoid repeated queries @@ -166,112 +156,22 @@ const getLatestDate = async (firestore, collection) => { }; /** - * Apply date filters to a query - * @param {Object} query - Firestore query - * @param {Object} params - Request parameters - * @returns {Object} - Modified query - */ -const applyDateFilters = (query, params) => { - if (params.start) { - query = query.where('date', '>=', params.start); - } - if (params.end) { - query = query.where('date', '<=', params.end); - } - return query; -}; - -/** - * Apply standard filters (geo, rank, technology, version) to a query - * @param {Object} query - Firestore query - * @param {Object} params - Request parameters - * @param {string} technology - Technology name - * @param {Array} techArray - Array of technologies (used for version filtering) - * @returns {Object} - Modified query - */ -const applyStandardFilters = (query, params, technology, techArray = []) => { - if (params.geo) { - query = query.where('geo', '==', params.geo); - } - if (params.rank) { - query = query.where('rank', '==', params.rank); - } - if (technology) { - query = query.where('technology', '==', technology); - } - - // Apply version filter with special handling for 'ALL' case - if (params.version && techArray.length === 1) { - //query = query.where('version', '==', params.version); // TODO: Uncomment when migrating to a new data schema - } else { - //query = query.where('version', '==', 'ALL'); - } - - return query; -}; - -/** - * Process technology array and handle 'latest' date substitution - * @param {Object} firestore - Firestore instance - * @param {Object} params - Request parameters - * @param {string} collection - Collection name - * @returns {Object} - Processed parameters and tech array - */ -const preprocessParams = async (firestore, params, collection) => { - // Handle 'latest' special value for start parameter - if (params.start && params.start === 'latest') { - params.start = await getLatestDate(firestore, collection); - } - - // Handle version 'ALL' special case for multiple technologies - const techArray = convertToArray(params.technology); - if (!params.version || techArray.length > 1) { - params.version = 'ALL'; - } - - return { params, techArray }; -}; - -/** - * Apply array-based filters using 'in' or 'array-contains-any' operators - * @param {Object} query - Firestore query - * @param {string} field - Field name to filter on + * Validate array parameter against Firestore limit * @param {string} value - Comma-separated values or single value - * @param {string} operator - Firestore operator ('in' or 'array-contains-any') - * @returns {Object} - Modified query + * @param {string} fieldName - Field name for error messages (optional) + * @returns {Array} - Validated array + * @throws {Error} - If array exceeds Firestore limit */ -const applyArrayFilter = (query, field, value, operator = 'in') => { - if (!value) return query; +const validateArrayParameter = (value, fieldName = 'parameter') => { + if (!value) return []; + const valueArray = convertToArray(value); - if (valueArray.length > 0) { - query = query.where(field, operator, valueArray); + if (valueArray.length > FIRESTORE_IN_LIMIT) { + throw new Error(`Too many values specified for ${fieldName}. Maximum ${FIRESTORE_IN_LIMIT} allowed.`); } - return query; -}; - -/** - * Select specific fields from an object based on comma-separated field names - * @param {Object} data - Source data object - * @param {string} fieldsParam - Comma-separated field names (e.g., "technology,category") - * @returns {Object} - Object containing only requested fields - */ -const selectFields = (data, fieldsParam) => { - if (!fieldsParam) return data; - - const fields = convertToArray(fieldsParam); - - if (fields.length === 0) return data; - - const result = {}; - fields.forEach(field => { - if (data.hasOwnProperty(field)) { - result[field] = data[field]; - } - }); - - return result; + return valueArray; }; /** @@ -336,19 +236,95 @@ const handleControllerError = (res, error, operation) => { })); }; +/** + * Generic cache-enabled query executor + * Handles caching, query execution, and response for simple queries + * @param {Object} req - Request object + * @param {Object} res - Response object + * @param {string} collection - Firestore collection name + * @param {Function} queryBuilder - Function to build the query + * @param {Function} dataProcessor - Optional function to process results + */ +const executeQuery = async (req, res, collection, queryBuilder, dataProcessor = null, customCacheKeyData = null) => { + try { + const params = req.query; + + // Generate cache key with custom data if provided + const cacheKeyData = customCacheKeyData ? { ...params, ...customCacheKeyData } : params; + const cacheKey = generateQueryCacheKey(collection, cacheKeyData); + + // Check cache first + const cachedResult = getCachedQueryResult(cacheKey); + if (cachedResult) { + res.statusCode = 200; + res.end(JSON.stringify(cachedResult)); + return; + } + + // Build and execute query + const query = await queryBuilder(params); + const snapshot = await query.get(); + + let data = []; + snapshot.forEach(doc => { + data.push(doc.data()); + }); + + // Process data if processor provided + if (dataProcessor) { + data = dataProcessor(data, params); + } + + // Cache the result + setCachedQueryResult(cacheKey, data); + + // Send response + res.statusCode = 200; + res.end(JSON.stringify(data)); + + } catch (error) { + // Handle validation errors specifically + if (error.message.includes('Too many technologies')) { + res.statusCode = 400; + res.end(JSON.stringify({ + success: false, + errors: [{ technology: error.message }] + })); + return; + } + + handleControllerError(res, error, `querying ${collection}`); + } +}; + +// Firestore 'in' operator limit +const FIRESTORE_IN_LIMIT = 30; + +/** + * Technology array validation helper (backward compatibility wrapper) + * @param {string} technologyParam - Comma-separated technology string + * @returns {Array|null} - Array of technologies or null if too many + */ +const validateTechnologyArray = (technologyParam) => { + try { + return validateArrayParameter(technologyParam, 'technology'); + } catch (error) { + return null; // Maintain backward compatibility - return null on validation failure + } +}; + export { REQUIRED_PARAMS, + FIRESTORE_IN_LIMIT, validateRequiredParams, sendValidationError, getLatestDate, - applyDateFilters, - applyStandardFilters, - preprocessParams, - applyArrayFilter, - selectFields, + validateArrayParameter, handleControllerError, generateQueryCacheKey, getCachedQueryResult, setCachedQueryResult, - getCacheStats + getCacheStats, + executeQuery, + validateTechnologyArray }; diff --git a/src/utils/helpers.js b/src/utils/helpers.js index 9e7ed9c..d4ce424 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -1,7 +1,3 @@ -/** - * Utility functions for API requests and responses - */ - /** * Converts a comma-separated string to an array * @param {string} dataString - The string to convert @@ -11,18 +7,7 @@ const convertToArray = (dataString) => { if (!dataString) return []; // URL decode and split by comma - const decoded = decodeURIComponent(dataString); - return decoded.split(','); -}; - -/** - * Converts error arrays to hash format - * @param {Array>} arr - Array of [key, message] arrays - * @returns {Array} Array of {key: message} objects - */ -const convertToHashes = (arr) => { - return arr.map(([key, message]) => ({ [key]: message })); + return decodeURIComponent(dataString).split(','); }; - -export { convertToArray, convertToHashes }; +export { convertToArray };