plugin.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import {
  2. AssetEvent,
  3. CollectionModificationEvent,
  4. DeepRequired,
  5. EventBus,
  6. ID,
  7. idsAreEqual,
  8. Logger,
  9. OnVendureBootstrap,
  10. PluginCommonModule,
  11. ProductChannelEvent,
  12. ProductEvent,
  13. ProductVariantEvent,
  14. TaxRateModificationEvent,
  15. Type,
  16. VendurePlugin,
  17. } from '@vendure/core';
  18. import { buffer, debounceTime, filter, map } from 'rxjs/operators';
  19. import { ELASTIC_SEARCH_OPTIONS, loggerCtx } from './constants';
  20. import { CustomMappingsResolver } from './custom-mappings.resolver';
  21. import { ElasticsearchIndexService } from './elasticsearch-index.service';
  22. import { AdminElasticSearchResolver, ShopElasticSearchResolver } from './elasticsearch-resolver';
  23. import { ElasticsearchService } from './elasticsearch.service';
  24. import { generateSchemaExtensions } from './graphql-schema-extensions';
  25. import { ElasticsearchIndexerController } from './indexer.controller';
  26. import { ElasticsearchOptions, mergeWithDefaults } from './options';
  27. /**
  28. * @description
  29. * This plugin allows your product search to be powered by [Elasticsearch](https://github.com/elastic/elasticsearch) - a powerful Open Source search
  30. * engine. This is a drop-in replacement for the DefaultSearchPlugin.
  31. *
  32. * ## Installation
  33. *
  34. * **Requires Elasticsearch v7.0 or higher.**
  35. *
  36. * `yarn add \@vendure/elasticsearch-plugin`
  37. *
  38. * or
  39. *
  40. * `npm install \@vendure/elasticsearch-plugin`
  41. *
  42. * Make sure to remove the `DefaultSearchPlugin` if it is still in the VendureConfig plugins array.
  43. *
  44. * Then add the `ElasticsearchPlugin`, calling the `.init()` method with {@link ElasticsearchOptions}:
  45. *
  46. * @example
  47. * ```ts
  48. * import { ElasticsearchPlugin } from '\@vendure/elasticsearch-plugin';
  49. *
  50. * const config: VendureConfig = {
  51. * // Add an instance of the plugin to the plugins array
  52. * plugins: [
  53. * ElasticsearchPlugin.init({
  54. * host: 'http://localhost',
  55. * port: 9200,
  56. * }),
  57. * ],
  58. * };
  59. * ```
  60. *
  61. * ## Search API Extensions
  62. * This plugin extends the default search query of the Shop API, allowing richer querying of your product data.
  63. *
  64. * The [SearchResponse](/docs/graphql-api/admin/object-types/#searchresponse) type is extended with information
  65. * about price ranges in the result set:
  66. * ```SDL
  67. * extend type SearchResponse {
  68. * prices: SearchResponsePriceData!
  69. * }
  70. *
  71. * type SearchResponsePriceData {
  72. * range: PriceRange!
  73. * rangeWithTax: PriceRange!
  74. * buckets: [PriceRangeBucket!]!
  75. * bucketsWithTax: [PriceRangeBucket!]!
  76. * }
  77. *
  78. * type PriceRangeBucket {
  79. * to: Int!
  80. * count: Int!
  81. * }
  82. *
  83. * extend input SearchInput {
  84. * priceRange: PriceRangeInput
  85. * priceRangeWithTax: PriceRangeInput
  86. * }
  87. *
  88. * input PriceRangeInput {
  89. * min: Int!
  90. * max: Int!
  91. * }
  92. * ```
  93. *
  94. * This `SearchResponsePriceData` type allows you to query data about the range of prices in the result set.
  95. *
  96. * ## Example Request & Response
  97. *
  98. * ```SDL
  99. * {
  100. * search (input: {
  101. * term: "table easel"
  102. * groupByProduct: true
  103. * priceRange: {
  104. min: 500
  105. max: 7000
  106. }
  107. * }) {
  108. * totalItems
  109. * prices {
  110. * range {
  111. * min
  112. * max
  113. * }
  114. * buckets {
  115. * to
  116. * count
  117. * }
  118. * }
  119. * items {
  120. * productName
  121. * score
  122. * price {
  123. * ...on PriceRange {
  124. * min
  125. * max
  126. * }
  127. * }
  128. * }
  129. * }
  130. * }
  131. * ```
  132. *
  133. * ```JSON
  134. *{
  135. * "data": {
  136. * "search": {
  137. * "totalItems": 9,
  138. * "prices": {
  139. * "range": {
  140. * "min": 999,
  141. * "max": 6396,
  142. * },
  143. * "buckets": [
  144. * {
  145. * "to": 1000,
  146. * "count": 1
  147. * },
  148. * {
  149. * "to": 2000,
  150. * "count": 2
  151. * },
  152. * {
  153. * "to": 3000,
  154. * "count": 3
  155. * },
  156. * {
  157. * "to": 4000,
  158. * "count": 1
  159. * },
  160. * {
  161. * "to": 5000,
  162. * "count": 1
  163. * },
  164. * {
  165. * "to": 7000,
  166. * "count": 1
  167. * }
  168. * ]
  169. * },
  170. * "items": [
  171. * {
  172. * "productName": "Loxley Yorkshire Table Easel",
  173. * "score": 30.58831,
  174. * "price": {
  175. * "min": 4984,
  176. * "max": 4984
  177. * }
  178. * },
  179. * // ... truncated
  180. * ]
  181. * }
  182. * }
  183. *}
  184. * ```
  185. *
  186. * @docsCategory ElasticsearchPlugin
  187. */
  188. @VendurePlugin({
  189. imports: [PluginCommonModule],
  190. providers: [
  191. ElasticsearchIndexService,
  192. ElasticsearchService,
  193. { provide: ELASTIC_SEARCH_OPTIONS, useFactory: () => ElasticsearchPlugin.options },
  194. ],
  195. adminApiExtensions: { resolvers: [AdminElasticSearchResolver] },
  196. shopApiExtensions: {
  197. resolvers: () => {
  198. const { options } = ElasticsearchPlugin;
  199. const requiresUnionResolver =
  200. 0 < Object.keys(options.customProductMappings || {}).length &&
  201. 0 < Object.keys(options.customProductVariantMappings || {}).length;
  202. return requiresUnionResolver
  203. ? [ShopElasticSearchResolver, CustomMappingsResolver]
  204. : [ShopElasticSearchResolver];
  205. },
  206. schema: () => generateSchemaExtensions(ElasticsearchPlugin.options),
  207. },
  208. workers: [ElasticsearchIndexerController],
  209. })
  210. export class ElasticsearchPlugin implements OnVendureBootstrap {
  211. private static options: DeepRequired<ElasticsearchOptions>;
  212. /** @internal */
  213. constructor(
  214. private eventBus: EventBus,
  215. private elasticsearchService: ElasticsearchService,
  216. private elasticsearchIndexService: ElasticsearchIndexService,
  217. ) {}
  218. /**
  219. * Set the plugin options.
  220. */
  221. static init(options: ElasticsearchOptions): Type<ElasticsearchPlugin> {
  222. this.options = mergeWithDefaults(options);
  223. return ElasticsearchPlugin;
  224. }
  225. /** @internal */
  226. async onVendureBootstrap(): Promise<void> {
  227. const { host, port } = ElasticsearchPlugin.options;
  228. try {
  229. const pingResult = await this.elasticsearchService.checkConnection();
  230. } catch (e) {
  231. Logger.error(`Could not connect to Elasticsearch instance at "${host}:${port}"`, loggerCtx);
  232. Logger.error(JSON.stringify(e), loggerCtx);
  233. return;
  234. }
  235. Logger.info(`Sucessfully connected to Elasticsearch instance at "${host}:${port}"`, loggerCtx);
  236. await this.elasticsearchService.createIndicesIfNotExists();
  237. this.elasticsearchIndexService.initJobQueue();
  238. this.eventBus.ofType(ProductEvent).subscribe((event) => {
  239. if (event.type === 'deleted') {
  240. return this.elasticsearchIndexService.deleteProduct(event.ctx, event.product);
  241. } else {
  242. return this.elasticsearchIndexService.updateProduct(event.ctx, event.product);
  243. }
  244. });
  245. this.eventBus.ofType(ProductVariantEvent).subscribe((event) => {
  246. if (event.type === 'deleted') {
  247. return this.elasticsearchIndexService.deleteVariant(event.ctx, event.variants);
  248. } else {
  249. return this.elasticsearchIndexService.updateVariants(event.ctx, event.variants);
  250. }
  251. });
  252. this.eventBus.ofType(AssetEvent).subscribe((event) => {
  253. if (event.type === 'updated') {
  254. return this.elasticsearchIndexService.updateAsset(event.ctx, event.asset);
  255. }
  256. });
  257. this.eventBus.ofType(ProductChannelEvent).subscribe((event) => {
  258. if (event.type === 'assigned') {
  259. return this.elasticsearchIndexService.assignProductToChannel(
  260. event.ctx,
  261. event.product,
  262. event.channelId,
  263. );
  264. } else {
  265. return this.elasticsearchIndexService.removeProductFromChannel(
  266. event.ctx,
  267. event.product,
  268. event.channelId,
  269. );
  270. }
  271. });
  272. const collectionModification$ = this.eventBus.ofType(CollectionModificationEvent);
  273. const closingNotifier$ = collectionModification$.pipe(debounceTime(50));
  274. collectionModification$
  275. .pipe(
  276. buffer(closingNotifier$),
  277. filter((events) => 0 < events.length),
  278. map((events) => ({
  279. ctx: events[0].ctx,
  280. ids: events.reduce((ids, e) => [...ids, ...e.productVariantIds], [] as ID[]),
  281. })),
  282. filter((e) => 0 < e.ids.length),
  283. )
  284. .subscribe((events) => {
  285. return this.elasticsearchIndexService.updateVariantsById(events.ctx, events.ids);
  286. });
  287. this.eventBus.ofType(TaxRateModificationEvent).subscribe((event) => {
  288. const defaultTaxZone = event.ctx.channel.defaultTaxZone;
  289. if (defaultTaxZone && idsAreEqual(defaultTaxZone.id, event.taxRate.zone.id)) {
  290. return this.elasticsearchService.updateAll(event.ctx);
  291. }
  292. });
  293. }
  294. }