23 changes: 23 additions & 0 deletions packages/search/strapi-plugin-search/server/controllers/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict';

/**
* @param {{ strapi: Strapi }} strapi
* @returns {Object}
*/
function createConfigController({ strapi }) {
return {
async get(ctx) {
const { excludedFields = [], prefix: indexPrefix = '', contentTypes } = strapi.config.get('plugin.search');

ctx.send({
excludedFields,
indexPrefix,
contentTypes,
});
},
};
}

module.exports = {
createConfigController,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use strict';

const { createConfigController: config } = require('./config');
const { createSearchController: search } = require('./search');

module.exports = {
config,
search,
};
32 changes: 32 additions & 0 deletions packages/search/strapi-plugin-search/server/controllers/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict';

/**
* @param {{ strapi: Strapi }} strapi
* @returns {Object}
*/
function createSearchController({ strapi }) {
return {
async indexContentType(ctx) {
// validate that there is a param of contentType
if (!ctx.params.contentType) {
ctx.badRequest('contentType is required');
}

// validate that the contentType is a valid content type
const contentType = strapi.contentTypes[ctx.params.contentType];
if (!contentType) {
ctx.badRequest('contentType is not a valid content type');
}

// send to search service
const { successCount, totalCount } = await strapi.plugin('search').service('search').indexContentType({ contentType: ctx.params.contentType });

// send success response
ctx.send({ ok: true, successCount, totalCount });
},
};
}

module.exports = {
createSearchController,
};
14 changes: 14 additions & 0 deletions packages/search/strapi-plugin-search/server/routes/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use strict';

module.exports = [
{
method: 'GET',
path: '/config',
handler: 'config.get',
},
{
method: 'GET',
path: '/index/:contentType',
handler: 'search.indexContentType',
},
];
2 changes: 2 additions & 0 deletions packages/search/strapi-plugin-search/server/services/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

const lifecycle = require('./lifecycle');
const provider = require('./provider');
const search = require('./search');

module.exports = {
lifecycle,
provider,
search,
};
51 changes: 39 additions & 12 deletions packages/search/strapi-plugin-search/server/services/lifecycle.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const { omit, pick } = require('lodash/fp');
const { filter } = require('../utils/filters');

/**
* Gets lifecycle service
Expand All @@ -18,7 +19,7 @@ module.exports = () => ({
// Loop over configured contentTypes in ./config/plugins.js
contentTypes &&
contentTypes.forEach((contentType) => {
const { name, index, prefix: idPrefix = '', fields = [] } = contentType;
const { name, index, prefix: idPrefix = '', fields = [], filters } = contentType;

if (strapi.contentTypes[name]) {
const indexName = indexPrefix + (index ? index : name);
Expand All @@ -33,13 +34,23 @@ module.exports = () => ({

strapi.db.lifecycles.subscribe({
models: [name],

async afterCreate(event) {
provider.create({
indexName,
data: sanitize(event.result),
id: idPrefix + event.result.id,
});
const createEntry = () =>
provider.create({
indexName,
data: sanitize(event.result),
id: idPrefix + event.result.id,
});

if (!filters) {
return createEntry();
}

if (filter(event.result, filters)) {
createEntry();
} else {
strapi.log.info(`Skipping index creation for content type: ${name} (filter returned false)`);
}
},

// Todo: Fix `afterCreateMany` event result only has an count, it doesn't provide an array of result objects.
Expand All @@ -52,11 +63,27 @@ module.exports = () => ({
// },

async afterUpdate(event) {
provider.update({
indexName,
data: sanitize(event.result),
id: idPrefix + event.result.id,
});
const updateEntry = () =>
provider.update({
indexName,
data: sanitize(event.result),
id: idPrefix + event.result.id,
});

if (!filters) {
return updateEntry();
}

if (filter(event.result, filters)) {
updateEntry();
} else {
try {
provider.delete({ indexName, id: idPrefix + event.result.id });
strapi.log.info(`Deleting entry from index for content type: ${name} (filter returned false)`);
} catch (err) {
strapi.log.info(`Skipping index creation for content type: ${name} (filter returned false)`);
}
}
},

// Todo: Fix `afterUpdateMany` event result only has an count, it doesn't provide an array of result objects.
Expand Down
102 changes: 102 additions & 0 deletions packages/search/strapi-plugin-search/server/services/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use strict';

const { omit, pick } = require('lodash/fp');
const { filter } = require('../utils/filters');

function sanitizeEntry({ entry, fields, excludedFields }) {
if (fields.length > 0) {
return pick(fields, entry);
}

return omit(excludedFields, entry);
}

function getFirstLevelAttributeFromKey(key) {
return key.split('.')[0];
}

/**
* Gets search service
*
* @returns {object} Search service
*/
module.exports = () => ({
/**
* Index all entries for a given content type that aren't already indexed
*/
async indexContentType({ contentType }) {
const provider = strapi.plugin('search').provider;
const { excludedFields = [], prefix: indexPrefix = '', contentTypes } = strapi.config.get('plugin.search');

const contentTypeConfigs = contentTypes.reduce((acc, contentType) => {
acc[contentType.name] = contentType;
return acc;
}, {});

// get all entries for the content type
const entries = await strapi.entityService.findMany(contentType, {
fields: [
...(contentTypeConfigs[contentType].fields || []),
...(contentTypeConfigs[contentType].filters ? Object.keys(contentTypeConfigs[contentType].filters).map(getFirstLevelAttributeFromKey) : []),
],
});

// provider.create each entry
const results = await Promise.allSettled(
entries.map(async (entry) => {
if (contentTypeConfigs[contentType].filters && !filter(entry, contentTypeConfigs[contentType].filters)) {
throw new Error(`Skipping index creation for content type: ${contentType} (filter returned false)`);
}

// get the full entry
/**
* TODO: this doesn't populate everything that is populated afterCreate/afterUpdate
* lifecycle method so we need to figure out how to get the full entry here - check
* out the afterCreate/afterUpdate lifecycle methods in the core repo
* */
const fullEntry = await strapi.entityService.findOne(contentType, entry.id);

const sanitizedEntry = sanitizeEntry({
entry: fullEntry,
fields: contentTypeConfigs[contentType].fields || [],
excludedFields,
});

// get the index name
const indexName = indexPrefix + (contentTypeConfigs[contentType].index ? contentTypeConfigs[contentType].index : contentType);

// create the entry
/**
* TODO: to effectively return results here we need to convert this to an async
* function and get the result from algolia
*/
provider.create({
indexName,
data: sanitizedEntry,
id: contentTypeConfigs[contentType].prefix + entry.id,
});

return entry.id;
}),
);

let successCount = 0;

// log successful indexing
results.forEach((result) => {
if (result.status === 'fulfilled') {
strapi.log.info(`Indexed ${contentType} entry ${result.value}`);
successCount++;
}
if (result.status === 'rejected') {
strapi.log.error(`Failed to index ${contentType} entry ${result.reason}`);
}
});

// return summary
return {
successCount,
totalCount: entries.length,
};
},
});
54 changes: 54 additions & 0 deletions packages/search/strapi-plugin-search/server/utils/filters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

const _ = require('lodash');

/**
* Evaluates filter object logic against entry
*
* @param {object} entry
* @param {object} filters
* @returns {boolean}
*/
const filter = (entry, filters) => {
if (!filters) {
return true;
}

// iterate through each filter and compare it to one, stop processing if any fail
return Object.entries(filters).every(([path, expectedValue]) => {
strapi.log.info(`Search plugin: path: ${path}`);
try {
const entryValue = _.get(entry, path);
strapi.log.info(`Search plugin: entry value: ${entryValue}`);
strapi.log.info(`Search plugin: expected value: ${expectedValue}`);

if (entryValue === undefined) {
return true;
}

if (typeof expectedValue !== 'object') {
return false;
}

const operator = Object.keys(expectedValue)[0];

switch (operator) {
case '$eq':
strapi.log.info(`Search plugin: Comparing ${entryValue} $eq ${expectedValue[operator]}`);
return entryValue === expectedValue[operator];
case '$ne':
strapi.log.info(`Search plugin: Comparing ${entryValue} $ne ${expectedValue[operator]}`);
return entryValue !== expectedValue[operator];
default:
return false;
}
} catch (error) {
strapi.log.error(`Search plugin: Error running filter function: ${error.message}`);
return false;
}
});
};

module.exports = {
filter,
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const validateConfig = (config) => {
name: yup.string().required(),
index: yup.string(),
fields: yup.array().of(yup.string().required()),
filters: yup.object(),
}),
),
})
Expand Down
3 changes: 3 additions & 0 deletions packages/search/strapi-plugin-search/strapi-admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
'use strict';

module.exports = require('./admin/src').default;
4 changes: 4 additions & 0 deletions packages/search/strapi-plugin-search/strapi-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
const bootstrap = require('./server/bootstrap');
const config = require('./server/config');
const services = require('./server/services');
const controllers = require('./server/controllers');
const routes = require('./server/routes');

/**
* @returns {object} Plugin server object
Expand All @@ -11,4 +13,6 @@ module.exports = () => ({
bootstrap,
config,
services,
controllers,
routes,
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"url": "https://github.com/MattieBelt"
},
"engines": {
"node": ">=12.x.x <=16.x.x",
"node": ">=12.x.x <=18.x.x",
"npm": ">=6.0.0"
},
"license": "SEE LICENSE IN LICENSE"
Expand Down
17 changes: 14 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2274,6 +2274,17 @@
npmlog "^4.1.2"
write-file-atomic "^3.0.3"

"@mattie-bundle/strapi-plugin-search@file:example/.yalc/@mattie-bundle/strapi-plugin-search":
version "1.0.0-alpha.3"
dependencies:
"@strapi/utils" "^4.0.0"
lodash "^4.17.21"

"@mattie-bundle/strapi-provider-search-algolia@file:example/.yalc/@mattie-bundle/strapi-provider-search-algolia":
version "1.0.0-alpha.3"
dependencies:
algoliasearch "^4.9.1"

"@node-redis/bloom@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@node-redis/bloom/-/bloom-1.0.1.tgz#144474a0b7dc4a4b91badea2cfa9538ce0a1854e"
Expand Down Expand Up @@ -4702,9 +4713,9 @@ camelize@^1.0.0:
integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=

caniuse-lite@^1.0.30001286:
version "1.0.30001304"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001304.tgz#38af55ed3fc8220cb13e35e6e7309c8c65a05559"
integrity sha512-bdsfZd6K6ap87AGqSHJP/s1V+U6Z5lyrcbBu3ovbCCf8cSYpwTtGrCBObMpJqwxfTbLW6YTIdbb1jEeTelcpYQ==
version "1.0.30001452"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001452.tgz"
integrity sha512-Lkp0vFjMkBB3GTpLR8zk4NwW5EdRdnitwYJHDOOKIU85x4ckYCPQ+9WlVvSVClHxVReefkUMtWZH2l9KGlD51w==

caseless@~0.12.0:
version "0.12.0"
Expand Down