add: abstract search functionality to core + adjust meilisearch-plugin

add SearchService interface to medusa-interfaces
add DefaultSearchService skeleton implementation to core
add search-index.js loader to core for indexing db documents
add SearchSubscriber to core
add loadToSearchEngine method in ProductService
switch order of loaders in core to load subscriptions AFTER plugins
adjust service and loader for medusa-plugin-meilisearch
This commit is contained in:
zakariaelas
2021-09-15 22:42:17 +01:00
parent 425c8a5e5d
commit 0fbde7c51f
24 changed files with 405 additions and 187 deletions

View File

@@ -5,3 +5,4 @@ export { default as FulfillmentService } from "./fulfillment-service"
export { default as FileService } from "./file-service"
export { default as NotificationService } from "./notification-service"
export { default as OauthService } from "./oauth-service"
export { default as SearchService } from "./search-service"

View File

@@ -0,0 +1,93 @@
import { BaseService } from "medusa-interfaces"
/**
* The interface that all search services must implement.
* @interface
*/
class SearchService extends BaseService {
constructor() {
super()
}
/**
* Used to create an index
* @param indexName {string} - the index name.
* @param [options] {string} - the index name.
* @return {Promise<{object}>} - returns response from search engine provider
*/
createIndex(indexName, options) {
throw Error("createIndex must be overridden by a child class")
}
/**
* Used to get an index
* @param indexName {string} - the index name.
* @return {Promise<{object}>} - returns response from search engine provider
*/
getIndex(indexName) {
throw Error("getIndex must be overridden by a child class")
}
/**
* Used to index documents by the search engine provider.
* @param indexName {string} - the index name.
* @param documents {Array.<Object>} - documents array to be indexed
* @return {Promise<{object}>} - returns response from search engine provider
*/
addDocuments(indexName, documents) {
throw Error("addDocuments must be overridden by a child class")
}
/**
* Used to replace documents
* @param indexName {string} - the index name.
* @param documents {Object} - array of document objects that will replace existing documents
* @return {Promise<{object}>} - returns response from search engine provider
*/
replaceDocuments(indexName, documents) {
throw Error("updateDocument must be overridden by a child class")
}
/**
* Used to delete document
* @param indexName {string} - the index name.
* @param document_id {string} - the id of the document.
* @return {Promise<{object}>} - returns response from search engine provider
*/
deleteDocument(indexName, document_id) {
throw Error("deleteDocument must be overridden by a child class")
}
/**
* Used to delete all documents
* @param indexName {string} - the index name.
* @return {Promise<{object}>} - returns response from search engine provider
*/
deleteAllDocuments(indexName) {
throw Error("deleteAllDocuments must be overridden by a child class")
}
/**
* Used to search for a document in an index
* @param indexName {string} - the index name.
* @param query {string} - the search query.
* @param options {object} - any options passed to the request object other than the query and indexName
* e.g. pagination options, filtering options, etc.
* @return {Promise<{object}>} - returns response from search engine provider
*/
search(indexName, query, options) {
throw Error("search must be overridden by a child class")
}
/**
* Used to update the settings of an index
* @param indexName {string} - the index name.
* @param settings {object} - settings object
* @return {Promise<{object}>} - returns response from search engine provider
*/
updateSettings(indexName, settings) {
throw Error("updateSettings must be overridden by a child class")
}
}
export default SearchService

View File

@@ -24,7 +24,7 @@
"body-parser": "^1.19.0",
"lodash": "^4.17.21",
"medusa-core-utils": "^1.1.20",
"medusa-interfaces": "^1.1.21",
"medusa-interfaces": "^1.1.23",
"meilisearch": "^0.20.0"
},
"devDependencies": {

View File

@@ -1,10 +0,0 @@
import { Router } from "express"
import routes from "./routes/"
export default (container) => {
const app = Router()
routes(app)
return app
}

View File

@@ -1 +0,0 @@
export default (fn) => (...args) => fn(...args).catch(args[2])

View File

@@ -1,5 +0,0 @@
import { default as wrap } from "./await-middleware"
export default {
wrap,
}

View File

@@ -1,17 +0,0 @@
import { Router } from "express"
import bodyParser from "body-parser"
import middlewares from "../middlewares"
const route = Router()
export default (app) => {
app.use("/meilisearch", route)
route.post(
"/search",
bodyParser.json(),
middlewares.wrap(require("./meilisearch").default)
)
return app
}

View File

@@ -1,25 +1,14 @@
import {
defaultProductFields,
defaultProductRelations,
flattenSkus,
} from "../utils"
const INDEX_NS = "medusa-commerce"
export default async (container, options) => {
try {
const meilisearchService = container.resolve("meilisearchService")
const productService = container.resolve("productService")
const searchService = container.resolve("searchService")
const products = await productService.list(
{},
{
select: defaultProductFields,
relations: defaultProductRelations,
}
await Promise.all(
Object.entries(options.settings).map(([key, value]) =>
searchService.updateSettings(`${INDEX_NS}_${key}`, value)
)
)
const productsWithSkus = products.map((product) => flattenSkus(product))
await meilisearchService.updateSettings()
await meilisearchService.addDocuments(productsWithSkus)
} catch (err) {
console.log(err)
}

View File

@@ -1,36 +1,46 @@
import { BaseService } from "medusa-interfaces"
import { SearchService } from "medusa-interfaces"
import { MeiliSearch } from "meilisearch"
class MeilisearchService extends BaseService {
constructor({ eventBusService }, options) {
class MeiliSearchService extends SearchService {
constructor(container, options) {
super()
this.eventBus_ = eventBusService
this.options_ = options
this.client_ = new MeiliSearch(options.config).index("products")
this.client_ = new MeiliSearch(options.config)
}
deleteAllDocuments() {
return this.client_.deleteAllDocuments()
createIndex(indexName, options) {
return this.client_.createIndex(indexName, options)
}
addDocuments(documents) {
return this.client_.addDocuments(documents)
getIndex(indexName) {
return this.client_.index(indexName)
}
deleteDocument(document_id) {
return this.client_.deleteDocument(document_id)
addDocuments(indexName, documents) {
return this.client_.index(indexName).addDocuments(documents)
}
search(query, options) {
return this.client_.search(query, options)
replaceDocuments(indexName, documents) {
return this.client_.index(indexName).addDocuments(documents)
}
updateSettings() {
return this.client_.updateSettings(this.options_.settings)
deleteDocument(indexName, document_id) {
return this.client_.index(indexName).deleteDocument(document_id)
}
deleteAllDocuments(indexName) {
return this.client_.index(indexName).deleteAllDocuments()
}
search(indexName, query, options) {
return this.client_.index(indexName).search(query, options)
}
updateSettings(indexName, settings) {
return this.client_.index(indexName).updateSettings(settings)
}
}
export default MeilisearchService
export default MeiliSearchService

View File

@@ -1,69 +0,0 @@
import {
defaultProductRelations,
defaultProductFields,
flattenSkus,
} from "../utils"
class MeilisearchSubscriber {
constructor(
{ eventBusService, meilisearchService, productService },
options
) {
this.eventBus_ = eventBusService
this.meilisearchService_ = meilisearchService
this.productService_ = productService
this.eventBus_.subscribe("product.created", this.handleProductCreation)
this.eventBus_.subscribe("product.updated", this.handleProductUpdate)
this.eventBus_.subscribe("product.deleted", this.handleProductDeletion)
this.eventBus_.subscribe(
"product-variant.created",
this.handleProductVariantChange
)
this.eventBus_.subscribe(
"product-variant.updated",
this.handleProductVariantChange
)
this.eventBus_.subscribe(
"product-variant.deleted",
this.handleProductVariantChange
)
}
handleProductCreation = async (data) => {
const product = await this.retrieveProduct_(data.id)
await this.meilisearchService_.addDocuments([product])
}
retrieveProduct_ = async (product_id) => {
const product = await this.productService_.retrieve(product_id, {
relations: defaultProductRelations,
select: defaultProductFields,
})
const flattenedProduct = flattenSkus(product)
return flattenedProduct
}
handleProductUpdate = async (data) => {
const product = await this.retrieveProduct_(data.id)
await this.meilisearchService_.addDocuments([product])
}
handleProductDeletion = async (data) => {
await this.meilisearchService_.deleteDocument(data.id)
}
handleProductVariantChange = async (data) => {
const product = await this.retrieveProduct_(data.product_id)
await this.meilisearchService_.addDocuments([product])
}
}
export default MeilisearchSubscriber

View File

@@ -1,29 +0,0 @@
export const flattenSkus = (product) => {
const skus = product.variants.map((v) => v.sku).filter(Boolean)
product.sku = skus
return product
}
export const defaultProductFields = [
"id",
"title",
"subtitle",
"description",
"handle",
"is_giftcard",
"discountable",
"thumbnail",
"profile_id",
"collection_id",
"type_id",
"origin_country",
"created_at",
"updated_at",
]
export const defaultProductRelations = [
"variants",
"tags",
"type",
"collection",
]

View File

@@ -3777,20 +3777,20 @@ media-typer@0.3.0:
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
medusa-core-utils@^1.1.20:
version "1.1.20"
resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.20.tgz#676c0dc863a206b80cc53299a984c532d07df65f"
integrity sha512-gf+/L5eeqHea3xgjwD7YZEzfUGlxbjfvaeiiGWi3Wfu0dLa+G1B4S0TsX+upR+oVeWPmk66VMqWC80h3e4csqw==
medusa-core-utils@^1.1.22:
version "1.1.22"
resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-1.1.22.tgz#84ce0af0a7c672191d758ea462056e30a39d08b1"
integrity sha512-kMuRkWOuNG4Bw6epg/AYu95UJuE+rjHTeTWRLbEPrYGjWREV82tLWVDI21/QcccmaHmMU98Rkw2z9JwyFZIiyw==
dependencies:
joi "^17.3.0"
joi-objectid "^3.0.1"
medusa-interfaces@^1.1.21:
version "1.1.21"
resolved "https://registry.yarnpkg.com/medusa-interfaces/-/medusa-interfaces-1.1.21.tgz#ca86808e939b7ecc21a6d316008a4e41f163619f"
integrity sha512-mlHHoMIOFBc+Exs+uVIQsfeEP2C1Pi6IZHcpbm7O00tYBdQdqRjJre9+Z/I/Z37wt5IwA28/TIoVkYG71iQYxw==
medusa-interfaces@^1.1.23:
version "1.1.23"
resolved "https://registry.yarnpkg.com/medusa-interfaces/-/medusa-interfaces-1.1.23.tgz#b552a8c1d0eaddeff30472ab238652b9e1a56e73"
integrity sha512-dHCOnsyYQvjrtRd3p0ZqQZ4M/zmo4M/BAgVfRrYSyGrMdQ86TK9Z1DQDCHEzM1216AxEfXz2JYUD7ilTfG2iHQ==
dependencies:
medusa-core-utils "^1.1.20"
medusa-core-utils "^1.1.22"
meilisearch@^0.20.0:
version "0.20.0"

View File

@@ -15,6 +15,7 @@ import returnReasonRoutes from "./return-reasons"
import swapRoutes from "./swaps"
import variantRoutes from "./variants"
import giftCardRoutes from "./gift-cards"
import searchRoutes from "./search"
const route = Router()
@@ -43,6 +44,7 @@ export default (app, container, config) => {
returnRoutes(route)
giftCardRoutes(route)
returnReasonRoutes(route)
searchRoutes(route)
return app
}

View File

@@ -0,0 +1,12 @@
import { Router } from "express"
import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/search", route)
route.post("/", middlewares.wrap(require("./search").default))
return app
}

View File

@@ -1,9 +1,11 @@
import { Validator, MedusaError } from "medusa-core-utils"
import { INDEX_NS } from "../../../../utils/index-ns"
export default async (req, res) => {
const schema = Validator.object()
.keys({
q: Validator.string().required(),
indexName: Validator.string().required(),
})
.options({ allowUnknown: true })
@@ -13,11 +15,15 @@ export default async (req, res) => {
}
try {
const { q, ...options } = value
const { q, indexName, ...options } = value
const meiliSearchService = req.scope.resolve("meilisearchService")
const searchService = req.scope.resolve("searchService")
const results = await meiliSearchService.search(q, options)
const results = await searchService.search(
`${INDEX_NS}_${indexName}`,
q,
options
)
res.status(200).send(results)
} catch (error) {

View File

@@ -20,6 +20,9 @@ const silentResolution = (container, name, logger) => {
case "fulfillmentProviders":
identifier = "fulfillment"
break
case "searchService":
identifier = "search"
break
default:
identifier = name
}

View File

@@ -16,6 +16,7 @@ import passportLoader from "./passport"
import pluginsLoader, { registerPluginModels } from "./plugins"
import defaultsLoader from "./defaults"
import Logger from "./logger"
import searchIndexLoader from "./search-index"
export default async ({ directory: rootDirectory, expressApp }) => {
const { configModule } = getConfigFile(rootDirectory, `medusa-config`)
@@ -101,12 +102,6 @@ export default async ({ directory: rootDirectory, expressApp }) => {
const servAct = Logger.success(servicesActivity, "Services initialized") || {}
track("SERVICES_INIT_COMPLETED", { duration: servAct.duration })
const subActivity = Logger.activity("Initializing subscribers")
track("SUBSCRIBERS_INIT_STARTED")
subscribersLoader({ container, activityId: subActivity })
const subAct = Logger.success(subActivity, "Subscribers initialized") || {}
track("SUBSCRIBERS_INIT_COMPLETED", { duration: subAct.duration })
const expActivity = Logger.activity("Initializing express")
track("EXPRESS_INIT_STARTED")
await expressLoader({
@@ -138,6 +133,12 @@ export default async ({ directory: rootDirectory, expressApp }) => {
const pAct = Logger.success(pluginsActivity, "Plugins intialized") || {}
track("PLUGINS_INIT_COMPLETED", { duration: pAct.duration })
const subActivity = Logger.activity("Initializing subscribers")
track("SUBSCRIBERS_INIT_STARTED")
subscribersLoader({ container, activityId: subActivity })
const subAct = Logger.success(subActivity, "Subscribers initialized") || {}
track("SUBSCRIBERS_INIT_COMPLETED", { duration: subAct.duration })
const apiActivity = Logger.activity("Initializing API")
track("API_INIT_STARTED")
await apiLoader({
@@ -155,6 +156,12 @@ export default async ({ directory: rootDirectory, expressApp }) => {
const dAct = Logger.success(defaultsActivity, "Defaults initialized") || {}
track("DEFAULTS_INIT_COMPLETED", { duration: dAct.duration })
const searchActivity = Logger.activity("Initializing search engine indexing")
track("SEARCH_ENGINE_INDEXING_STARTED")
searchIndexLoader({ container, activityId: searchActivity })
const searchAct = Logger.success(searchActivity, "Indexing completed") || {}
track("SEARCH_ENGINE_INDEXING_COMPLETED", { duration: searchAct.duration })
return { container, dbConnection, app: expressApp }
}

View File

@@ -8,6 +8,7 @@ import {
NotificationService,
FileService,
OauthService,
SearchService,
} from "medusa-interfaces"
import { getConfigFile, createRequireFromPath } from "medusa-core-utils"
import _ from "lodash"
@@ -249,7 +250,7 @@ async function registerServices(pluginDetails, container) {
)
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
// resolution if we already know which fulfillment provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
@@ -263,7 +264,7 @@ async function registerServices(pluginDetails, container) {
)
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
// resolution if we already know which notification provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
@@ -272,13 +273,22 @@ async function registerServices(pluginDetails, container) {
})
} else if (loaded.prototype instanceof FileService) {
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
// resolution if we already know which file storage provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
),
[`fileService`]: aliasTo(name),
})
} else if (loaded.prototype instanceof SearchService) {
// Add the service directly to the container in order to make simple
// resolution if we already know which search provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
),
[`searchService`]: aliasTo(name),
})
} else {
container.register({
[name]: asFunction(

View File

@@ -0,0 +1,13 @@
export default async ({ container }) => {
const searchService = container.resolve("searchService")
const logger = container.resolve("logger")
if (searchService.isDefault) {
logger.warn(
"No search engine provider was found: make sure to include a search plugin to enable searching"
)
return
}
const productService = container.resolve("productService")
await productService.loadIntoSearchEngine()
}

View File

@@ -2,12 +2,15 @@ import _ from "lodash"
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { Brackets } from "typeorm"
import { flattenField } from "../utils/flatten-field"
import { INDEX_NS } from "../utils/index-ns"
/**
* Provides layer to manipulate products.
* @implements BaseService
*/
class ProductService extends BaseService {
static IndexName = `${INDEX_NS}_products`
static Events = {
UPDATED: "product.updated",
CREATED: "product.created",
@@ -25,6 +28,7 @@ class ProductService extends BaseService {
productTypeRepository,
productTagRepository,
imageRepository,
searchService,
}) {
super()
@@ -57,6 +61,9 @@ class ProductService extends BaseService {
/** @private @const {ImageRepository} */
this.imageRepository_ = imageRepository
/** @private @const {SearchService} */
this.searchService_ = searchService
}
withTransaction(transactionManager) {
@@ -741,6 +748,43 @@ class ProductService extends BaseService {
// const final = await this.runDecorators_(decorated)
return product
}
async loadIntoSearchEngine() {
if (this.searchService_.isDefault) return
const products = await this.list(
{},
{
select: [
"id",
"title",
"subtitle",
"description",
"handle",
"is_giftcard",
"discountable",
"thumbnail",
"profile_id",
"collection_id",
"type_id",
"origin_country",
"created_at",
"updated_at",
],
relations: ["variants", "tags", "type", "collection"],
}
)
const flattenSkus = product => {
product.sku = flattenField(product.variants, "sku").filter(Boolean)
return product
}
const productsWithSkus = products.map(product => flattenSkus(product))
await this.searchService_.addDocuments(
ProductService.IndexName,
productsWithSkus
)
}
}
export default ProductService

View File

@@ -0,0 +1,66 @@
import { SearchService } from "medusa-interfaces"
/**
* Default class that implements SearchService but provides stuv implementation for all methods
* @implements SearchService
*/
class DefaultSearchService extends SearchService {
constructor(container) {
super()
this.isDefault = true
this.logger_ = container.logger
}
createIndex(indexName, options) {
this.logger_.warn(
"This is an empty method: createIndex must be overridden by a child class"
)
}
getIndex(indexName) {
this.logger_.warn(
"This is an empty method: getIndex must be overridden by a child class"
)
}
addDocuments(indexName, documents) {
this.logger_.warn(
"This is an empty method: addDocuments must be overridden by a child class"
)
}
replaceDocuments(indexName, documents) {
this.logger_.warn(
"This is an empty method: replaceDocuments must be overridden by a child class"
)
}
deleteDocument(indexName, document_id) {
this.logger_.warn(
"This is an empty method: deleteDocument must be overridden by a child class"
)
}
deleteAllDocuments(indexName) {
this.logger_.warn(
"This is an empty method: deleteAllDocuments must be overridden by a child class"
)
}
search(indexName, query, options) {
this.logger_.warn(
"This is an empty method: search must be overridden a the child class"
)
return []
}
updateSettings(indexName, settings) {
this.logger_.warn(
"This is an empty method: updateSettings must be overridden by a child class"
)
}
}
export default DefaultSearchService

View File

@@ -0,0 +1,89 @@
import ProductService from "../services/product"
import ProductVariantService from "../services/product-variant"
import { flattenField } from "../utils/flatten-field"
class SearchSubscriber {
constructor({ eventBusService, searchService, productService }, options) {
this.eventBus_ = eventBusService
this.searchService_ = searchService
this.productService_ = productService
this.eventBus_.subscribe(
ProductService.Events.CREATED,
this.handleProductCreation
)
this.eventBus_.subscribe(
ProductService.Events.UPDATED,
this.handleProductUpdate
)
this.eventBus_.subscribe(
ProductService.Events.DELETED,
this.handleProductDeletion
)
this.eventBus_.subscribe(
ProductVariantService.Events.CREATED,
this.handleProductVariantChange
)
this.eventBus_.subscribe(
ProductVariantService.Events.UPDATED,
this.handleProductVariantChange
)
this.eventBus_.subscribe(
ProductVariantService.Events.DELETED,
this.handleProductVariantChange
)
}
handleProductCreation = async data => {
const product = await this.retrieveProduct_(data.id)
await this.searchService_.addDocuments(ProductService.IndexName, [product])
}
retrieveProduct_ = async product_id => {
const product = await this.productService_.retrieve(product_id, {
select: [
"id",
"title",
"subtitle",
"description",
"handle",
"is_giftcard",
"discountable",
"thumbnail",
"profile_id",
"collection_id",
"type_id",
"origin_country",
"created_at",
"updated_at",
],
relations: ["variants", "tags", "type", "collection"],
})
const skus = flattenField(product.variants, "sku").filter(Boolean)
product.sku = skus
return product
}
handleProductUpdate = async data => {
const product = await this.retrieveProduct_(data.id)
await this.searchService_.addDocuments(ProductService.IndexName, [product])
}
handleProductDeletion = async data => {
await this.searchService_.deleteDocument(ProductService.IndexName, data.id)
}
handleProductVariantChange = async data => {
const product = await this.retrieveProduct_(data.product_id)
await this.searchService_.addDocuments(ProductService.IndexName, [product])
}
}
export default SearchSubscriber

View File

@@ -0,0 +1,3 @@
export const flattenField = (list, field) => {
return list.map(el => el[field])
}

View File

@@ -0,0 +1 @@
export const INDEX_NS = `medusa-commerce`