diff --git a/src/api/search/src/bin/validation.js b/src/api/search/src/bin/validation.js index 882fe2b270..f34cef732c 100644 --- a/src/api/search/src/bin/validation.js +++ b/src/api/search/src/bin/validation.js @@ -1,4 +1,4 @@ -const { check, validationResult } = require('express-validator'); +const { oneOf, check, validationResult } = require('express-validator'); const queryValidationRules = [ // text must be between 1 and 256 and not empty @@ -31,8 +31,59 @@ const queryValidationRules = [ .bail(), ]; -const validateQuery = (rules) => { +/** + * Advanced search is more flexible, only needs at least ONE field, but can run without any too. + * Date formats must be YYYY-MM-DD + */ +const advancedQueryValidationRules = [ + oneOf([ + check('post') + .exists({ checkFalsy: true }) + .withMessage('post should not be empty') + .bail() + .isLength({ max: 256, min: 1 }) + .withMessage('post should be between 1 to 256 characters') + .bail(), + check('author') + .exists({ checkFalsy: true }) + .withMessage('author should exist') + .bail() + .isLength({ max: 100, min: 2 }) + .withMessage('invalid author value') + .bail(), + check('title') + .exists({ checkFalsy: true }) + .withMessage('title should exist') + .bail() + .isLength({ max: 100, min: 2 }) + .withMessage('invalid title value') + .bail(), + ]), + check('to').optional().isISO8601().withMessage('invalid date format').bail(), + + check('from').optional().isISO8601().withMessage('invalid date format').bail(), + check('perPage') + .optional() + .isInt({ min: 1, max: 10 }) + .withMessage('perPage should be empty or a number between 1 to 10') + .bail(), + + check('page') + .optional() + .isInt({ min: 0, max: 999 }) + .withMessage('page should be empty or a number between 0 to 999') + .bail(), +]; + +/** + * Validates query by passing rules. The rules are different based on the pathname + * of the request. If the pathname is '/' it is the basic route. + * Otherwise, if '/advanced/' it is the advanced search + */ +const validateQuery = () => { return async (req, res, next) => { + const rules = req.baseUrl === '/' ? queryValidationRules : advancedQueryValidationRules; + await Promise.all(rules.map((rule) => rule.run(req))); const result = validationResult(req); @@ -45,4 +96,4 @@ const validateQuery = (rules) => { }; }; -module.exports.validateQuery = validateQuery(queryValidationRules); +module.exports.validateQuery = validateQuery(); diff --git a/src/api/search/src/routes/query.js b/src/api/search/src/routes/query.js index 85a6309cad..c713afaca8 100644 --- a/src/api/search/src/routes/query.js +++ b/src/api/search/src/routes/query.js @@ -1,6 +1,6 @@ const { Router, createError } = require('@senecacdot/satellite'); const { validateQuery } = require('../bin/validation'); -const search = require('../search'); +const { search, advancedSearch } = require('../search'); const router = Router(); @@ -13,4 +13,13 @@ router.get('/', validateQuery, async (req, res, next) => { } }); +// route for advanced +router.get('/advanced', validateQuery, async (req, res, next) => { + try { + res.send(await advancedSearch(req.query)); + } catch (error) { + next(createError(503, error)); + } +}); + module.exports = router; diff --git a/src/api/search/src/search.js b/src/api/search/src/search.js index b918f3a4e3..0ae964b503 100644 --- a/src/api/search/src/search.js +++ b/src/api/search/src/search.js @@ -76,4 +76,94 @@ const search = async ( }; }; -module.exports = search; +/** + * Advanced search allows you to look up multiple or single fields based on the input provided + * @param options.post - text to search in post field + * @param options.author - text to search in author field + * @param options.title - text to search in title field + * @param options.from - published after this date + * @param options.to - published before this date + * @return all the results matching the fields text + * Range queries: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#_ranges + * Match field queries: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-ppmatch-query.html#query-dsl-match-query-zero + */ +const advancedSearch = async (options) => { + const results = { + query: { + bool: { + must: [], + }, + }, + }; + + const { must } = results.query.bool; + + if (options.author) { + must.push({ + match: { + author: { + query: options.author, + zero_terms_query: 'all', + }, + }, + }); + } + + if (options.post) { + must.push({ + match: { + text: { + query: options.post, + zero_terms_query: 'all', + }, + }, + }); + } + + if (options.title) { + must.push({ + match: { + title: { + query: options.title, + zero_terms_query: 'all', + }, + }, + }); + } + + if (options.from || options.to) { + must.push({ + range: { + published: { + gte: options.from || '2000-01-01', + lte: options.to || Date.now, + }, + }, + }); + } + + if (!options.perPage) { + options.perPage = ELASTIC_MAX_RESULTS_PER_PAGE; + } + + if (!options.page) { + options.page = 0; + } + + const { + body: { hits }, + } = await client.search({ + from: calculateFrom(options.page, options.perPage), + size: options.perPage, + _source: ['id'], + index, + type, + body: results, + }); + + return { + results: hits.total.value, + values: hits.hits.map(({ _id }) => ({ id: _id, url: `${POSTS_URL}/${_id}` })), + }; +}; +module.exports = { search, advancedSearch };