plugin.ts 8.6 KB

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