plugin.ts 8.1 KB

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