PluginProbe
ElasticPress / 4.2.0
ElasticPress v4.2.0
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / Elasticsearch.php

Elasticsearch.php in ElasticPress 4.2.0, at includes/classes/Elasticsearch.php

1,562 lines 42.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ElasticPress-Elasticsearch API functions
4 *
5 * @since 3.0
6 * @package elasticpress
7 */
8
9 namespace ElasticPress;
10
11 use ElasticPress\Utils as Utils;
12 use \WP_Error as WP_Error;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit; // Exit if accessed directly.
16 }
17
18 /**
19 * Elasticsearch API class
20 */
21 class Elasticsearch {
22
23 /**
24 * Logged queries for debugging
25 *
26 * @since 1.8
27 * @var array
28 */
29 private $queries = [];
30
31 /**
32 * ES plugins
33 *
34 * @var array
35 * @since 2.2
36 */
37 public $elasticsearch_plugins = null;
38
39 /**
40 * ES version number
41 *
42 * @var string
43 * @since 2.2
44 */
45 public $elasticsearch_version = null;
46
47 /**
48 * Return singleton instance of class
49 *
50 * @return object
51 * @since 0.1.0
52 */
53 public static function factory() {
54 static $instance = false;
55
56 if ( ! $instance ) {
57 $instance = new self();
58 }
59
60 return $instance;
61 }
62
63 /**
64 * Index a document in Elasticsearch.
65 *
66 * We require $document to have ID set
67 *
68 * @param string $index Index name.
69 * @param string $type Index type. Previously this was used for index type. Now it's just passed to hooks for legacy reasons.
70 * @param array $document Formatted Elasticsearch document.
71 * @param boolean $blocking Blocking HTTP request or not.
72 * @since 3.0
73 * @return boolean|array
74 */
75 public function index_document( $index, $type, $document, $blocking = true ) {
76 /**
77 * Filter Elasticsearch index document request path
78 *
79 * @hook ep_index_{document_type}_request_path
80 * @param {string} $path Path to index document
81 * @param {int} $document_id Document ID
82 * @param {array} $document Document to index
83 * @param {string} $type Type of document
84 * @return {string} New path
85 * @since 3.0
86 */
87 if ( version_compare( $this->get_elasticsearch_version(), '7.0', '<' ) ) {
88 $path = apply_filters( 'ep_index_' . $type . '_request_path', $index . '/' . $type . '/' . $document['ID'], $document, $type );
89 } else {
90 $path = apply_filters( 'ep_index_' . $type . '_request_path', $index . '/_doc/' . $document['ID'], $document, $type );
91 }
92
93 $path = apply_filters( 'ep_index_request_path', $path, $document, $type );
94
95 if ( function_exists( 'wp_json_encode' ) ) {
96 $encoded_document = wp_json_encode( $document );
97 } else {
98 // phpcs:disable
99 $encoded_document = json_encode( $document );
100 // phpcs:enable
101 }
102
103 $request_args = array(
104 'body' => $encoded_document,
105 'method' => 'POST',
106 'timeout' => apply_filters( 'ep_index_document_timeout', 15 ),
107 'blocking' => $blocking,
108 );
109
110 $request = $this->remote_request( $path, $request_args, [], 'index' );
111
112 /**
113 * Backwards compat for pre-3.0
114 */
115
116 /**
117 * Fires after indexing document
118 *
119 * @hook ep_index_post_retrieve_raw_response
120 * @param {array} $request Remote request response
121 * @param {array} $document Current document
122 * @param {string} $path Elasticsearch request path
123 */
124 do_action( 'ep_index_post_retrieve_raw_response', $request, $document, $path );
125
126 /**
127 * Fires after indexing document
128 *
129 * @hook ep_index_retrieve_raw_response
130 * @param {array} $request Remote request response
131 * @param {array} $document Current document
132 * @param {string} $path Elasticsearch request path
133 */
134 do_action( 'ep_index_retrieve_raw_response', $request, $document, $path );
135
136 if ( ! is_wp_error( $request ) ) {
137 $response_body = wp_remote_retrieve_body( $request );
138
139 $return = json_decode( $response_body );
140 } else {
141 $return = false;
142 }
143
144 /**
145 * Backwards compat for pre-3.0
146 */
147
148 /**
149 * Fires after indexing document and body decoding
150 *
151 * @hook ep_index_index_post
152 * @param {array} $document Current document
153 * @param {array|boolean} $return Elasticsearch response. False on error.
154 */
155 do_action( 'ep_after_index_post', $document, $return );
156
157 /**
158 * Fires after indexing document and body decoding
159 *
160 * @hook ep_index_index
161 * @param {array} $document Current document
162 * @param {array|boolean} $return Elasticsearch response. False on error.
163 */
164 do_action( 'ep_after_index', $document, $return );
165
166 return $return;
167 }
168
169 /**
170 * Pull the site id from the index name
171 *
172 * @param string $index_name Index name.
173 * @since 0.9.0
174 * @return int
175 */
176 public function parse_site_id( $index_name ) {
177 return (int) preg_replace( '#^.*\-([0-9]+)$#', '$1', $index_name );
178 }
179
180 /**
181 * Refresh all index. Sometimes useful if you need changes to show up instantly.
182 *
183 * @since 3.0
184 * @return bool
185 */
186 public function refresh_indices() {
187
188 $request_args = array( 'method' => 'POST' );
189
190 $request = $this->remote_request( '_refresh', $request_args, [], 'refresh_indices' );
191
192 if ( ! is_wp_error( $request ) ) {
193 if ( isset( $request['response']['code'] ) && 200 === $request['response']['code'] ) {
194 return true;
195 }
196 }
197
198 return false;
199 }
200
201 /**
202 * Get Elasticsearch version. We cache this so we don't have to do it every time.
203 *
204 * @param bool $force Bust cache or not.
205 * @since 2.1.2
206 * @return string|bool
207 */
208 public function get_elasticsearch_version( $force = false ) {
209
210 $info = $this->get_elasticsearch_info( $force );
211
212 /**
213 * Filter Elasticsearch version
214 *
215 * @hook ep_elasticsearch_version
216 * @param {string} $version Version
217 * @return {string} New version
218 * @since 2.1.2
219 */
220 return apply_filters( 'ep_elasticsearch_version', $info['version'] );
221 }
222
223 /**
224 * Get Elasticsearch plugins. We cache this so we don't have to do it every time.
225 *
226 * @param bool $force Force cache refresh or not.
227 * @since 2.2
228 * @return string|bool
229 */
230 public function get_elasticsearch_plugins( $force = false ) {
231
232 $info = $this->get_elasticsearch_info( $force );
233
234 /**
235 * Filter Elasticsearch plugins
236 *
237 * @hook ep_elasticsearch_plugins
238 * @param {array} $plugins Elasticsearch plugins
239 * @return {array} New plugins
240 * @since 2.2
241 */
242 return apply_filters( 'ep_elasticsearch_plugins', $info['plugins'] );
243 }
244
245 /**
246 * Run a query on Elasticsearch
247 *
248 * @param string $index Index name.
249 * @param string $type Index type. Previously this was used for index type. Now it's just passed to hooks for legacy reasons.
250 * @param array $query Prepared ES query.
251 * @param array $query_args WP query args.
252 * @param mixed $query_object Could be WP_Query, WP_User_Query, etc.
253 * @since 3.0
254 * @return bool|array
255 */
256 public function query( $index, $type, $query, $query_args, $query_object = null ) {
257 if ( version_compare( $this->get_elasticsearch_version(), '7.0', '<' ) ) {
258 $path = $index . '/' . $type . '/_search';
259 } else {
260 $path = $index . '/_search';
261 }
262
263 // For backwards compat
264 /**
265 * Filter Elasticsearch query request path
266 *
267 * @hook ep_search_request_path
268 * @param {string} $path Request path
269 * @param {string} $index Index name
270 * @param {string} $type Index type
271 * @param {array} $query Prepared Elasticsearch query
272 * @param {array} $query_args Query arguments
273 * @param {mixed} $query_object Could be WP_Query, WP_User_Query, etc.
274 * @return {string} New path
275 */
276 $path = apply_filters( 'ep_search_request_path', $path, $index, $type, $query, $query_args, $query_object );
277
278 /**
279 * Filter Elasticsearch query request path
280 *
281 * @hook ep_query_request_path
282 * @param {string} $path Request path
283 * @param {string} $index Index name
284 * @param {string} $type Index type
285 * @param {array} $query Prepared Elasticsearch query
286 * @param {array} $query_args Query arguments
287 * @param {mixed} $query_object Could be WP_Query, WP_User_Query, etc.
288 * @return {string} New path
289 */
290 $path = apply_filters( 'ep_query_request_path', $path, $index, $type, $query, $query_args, $query_object );
291
292 $request_args = array(
293 'body' => wp_json_encode( $query ),
294 'method' => 'POST',
295 'headers' => array(
296 'Content-Type' => 'application/json',
297 ),
298 );
299
300 /**
301 * Filter whether to send the EP-Search-Term header or not.
302 *
303 * @todo Evaluate if we should remove tests for is_admin() and empty post types.
304 *
305 * @since 3.5.2
306 * @hook ep_query_send_ep_search_term_header
307 * @param {bool} $send_header True means send the EP-Search-Term header
308 * @param {array} $query_args WP query args
309 * @return {bool} New $send_header value
310 */
311 $send_ep_search_term_header = apply_filters(
312 'ep_query_send_ep_search_term_header',
313 (
314 Utils\is_epio() &&
315 ! empty( $query_args['s'] ) &&
316 Utils\is_integrated_request( 'search' ) &&
317 ! isset( $_GET['post_type'] ) // phpcs:ignore WordPress.Security.NonceVerification
318 ),
319 $query_args
320 );
321
322 // If needed, send the search term as a header to ES so the backend understands what a normal query looks like
323 if ( $send_ep_search_term_header ) {
324 $request_args['headers']['EP-Search-Term'] = rawurlencode( $query_args['s'] );
325 }
326
327 /**
328 * Filter Elasticsearch query request arguments
329 *
330 * @hook ep_query_request_args
331 * @since 3.6.4
332 * @param {array} $request_args Request arguments
333 * @param {string} $path Request path
334 * @param {string} $index Index name
335 * @param {string} $type Index type
336 * @param {array} $query Prepared Elasticsearch query
337 * @param {array} $query_args Query arguments
338 * @param {mixed} $query_object Could be WP_Query, WP_User_Query, etc.
339 * @return {array} New request arguments
340 */
341 $request_args = apply_filters( 'ep_query_request_args', $request_args, $path, $index, $type, $query, $query_args, $query_object );
342
343 $request = $this->remote_request( $path, $request_args, $query_args, 'query' );
344
345 $remote_req_res_code = absint( wp_remote_retrieve_response_code( $request ) );
346
347 $is_valid_res = ( $remote_req_res_code >= 200 && $remote_req_res_code <= 299 );
348
349 /**
350 * Filter whether Elasticsearch remote request response code is valid
351 *
352 * @hook ep_remote_request_is_valid_res
353 * @param {boolean} $is_valid_res Whether response code is valid or not
354 * @param {array} $request Remote request response
355 * @return {string} New value
356 */
357 if ( ! is_wp_error( $request ) && apply_filters( 'ep_remote_request_is_valid_res', $is_valid_res, $request ) ) {
358
359 $response_body = wp_remote_retrieve_body( $request );
360
361 $response = json_decode( $response_body, true );
362
363 $hits = $this->get_hits_from_query( $response );
364 $total_hits = $this->get_total_hits_from_query( $response );
365
366 if ( ! empty( $response['aggregations'] ) ) {
367 /**
368 * Deprecated way to retrieve aggregations.
369 *
370 * @hook ep_retrieve_aggregations
371 * @param {array} $aggregations Elasticsearch aggregations
372 * @param {array} $query Prepared Elasticsearch query
373 * @param {string} $scope Backwards compat for scope parameter.
374 * @param {array} $query_args Current WP Query arguments
375 */
376 do_action( 'ep_retrieve_aggregations', $response['aggregations'], $query, '', $query_args );
377 }
378
379 /**
380 * Fires after valid Elasticsearch query
381 *
382 * @hook ep_valid_response
383 * @param {array} $response Elasticsearch decoded response
384 * @param {array} $query Prepared Elasticsearch query
385 * @param {array} $query_args Current WP Query arguments
386 * @param {mixed} $query_object Could be WP_Query, WP_User_Query, etc.
387 */
388 do_action( 'ep_valid_response', $response, $query, $query_args, $query_object );
389
390 // Backwards compat
391 /**
392 * Fires after valid Elasticsearch query
393 *
394 * @hook ep_retrieve_raw_response
395 * @param {array} $response Elasticsearch request
396 * @param {array} $query Prepared Elasticsearch query
397 * @param {array} $query_args Current WP Query arguments
398 * @param {mixed} $query_object Could be WP_Query, WP_User_Query, etc.
399 */
400 do_action( 'ep_retrieve_raw_response', $request, $query, $query_args, $query_object );
401
402 $documents = [];
403
404 foreach ( $hits as $hit ) {
405 $document = $hit['_source'];
406 $document['site_id'] = $this->parse_site_id( $hit['_index'] );
407
408 if ( ! empty( $hit['highlight'] ) ) {
409 $document['highlight'] = $hit['highlight'];
410 }
411
412 /**
413 * Filter Elasticsearch retrieved document
414 *
415 * @hook ep_retrieve_the_{index_type}
416 * @param {array} $document Document retrieved from Elasticsearch
417 * @param {array} $hit Raw Elasticsearch hit
418 * @param {string} $index Index name
419 * @return {array} New document
420 */
421 $documents[] = apply_filters( 'ep_retrieve_the_' . $type, $document, $hit, $index );
422 }
423
424 /**
425 * Filter Elasticsearch query results
426 *
427 * @hook ep_es_query_results
428 * @param {array} $results Results from Elasticsearch
429 * @param {response} $response Raw response from Elasticsearch
430 * @param {array} $query Raw Elasticsearch query
431 * @param {array} $query_args Query arguments
432 * @param {mixed} $query_object Could be WP_Query, WP_User_Query, etc.
433 * @return {array} New results
434 */
435 return apply_filters(
436 'ep_es_query_results',
437 [
438 'found_documents' => $total_hits,
439 'documents' => $documents,
440 ],
441 $response,
442 $query,
443 $query_args,
444 $query_object
445 );
446 }
447
448 /**
449 * Fires after invalid Elasticsearch query
450 *
451 * @hook ep_invalid_response
452 * @param {array} $request Remote request response
453 * @param {array} $query Prepared Elasticsearch query
454 * @param {array} $query_args Current WP Query arguments
455 * @param {mixed} $query_object Could be WP_Query, WP_User_Query, etc.
456 */
457 do_action( 'ep_invalid_response', $request, $query, $query_args, $query_object );
458
459 return false;
460 }
461
462 /**
463 * Returns the number of total results that ElasticSearch found for the given query
464 *
465 * @param array $response Response to get total hits from.
466 * @since 2.5
467 * @return int
468 */
469 public function get_total_hits_from_query( $response ) {
470
471 if ( $this->is_empty_query( $response ) ) {
472 return 0;
473 }
474
475 return $response['hits']['total'];
476 }
477
478 /**
479 * Returns array containing hits returned from query, if such exist
480 *
481 * @param array $response Response to get hits from.
482 * @since 2.5
483 * @return array
484 */
485 public function get_hits_from_query( $response ) {
486
487 if ( $this->is_empty_query( $response ) ) {
488 return [];
489 }
490
491 /**
492 * Filter Elasticsearch allows to flatten hits, if searched hits are come within aggregations.
493 *
494 * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-top-hits-aggregation.html
495 *
496 * @hook ep_get_hits_from_query
497 * @param {array} $hits from Elasticsearch
498 * @param {response} $response Raw response from Elasticsearch
499 * @return {array} hits
500 */
501 return apply_filters( 'ep_get_hits_from_query', $response['hits']['hits'], $response );
502 }
503
504 /**
505 * Check if a response array contains results or not
506 *
507 * @param array $response Response to check.
508 * @since 0.1.2
509 * @return bool
510 */
511 public function is_empty_query( $response ) {
512
513 if ( ! is_array( $response ) ) {
514 return true;
515 }
516
517 if ( isset( $response['error'] ) ) {
518 return true;
519 }
520
521 if ( empty( $response['hits'] ) ) {
522 return true;
523 }
524
525 if ( isset( $response['hits']['total'] ) && 0 === (int) $response['hits']['total'] ) {
526 return true;
527 }
528
529 return false;
530 }
531
532 /**
533 * Delete an Elasticsearch document
534 *
535 * @param string $index Index name.
536 * @param string $type Index type. Previously this was used for index type. Now it's just passed to hooks for legacy reasons.
537 * @param int $document_id Document id to delete.
538 * @param boolean $blocking Blocking HTTP request or not.
539 * @since 3.0
540 * @return boolean
541 */
542 public function delete_document( $index, $type, $document_id, $blocking = true ) {
543 if ( version_compare( $this->get_elasticsearch_version(), '7.0', '<' ) ) {
544 $path = $index . '/' . $type . '/' . $document_id;
545 } else {
546 $path = $index . '/_doc/' . $document_id;
547 }
548
549 $request_args = [
550 'method' => 'DELETE',
551 'timeout' => 15,
552 'blocking' => $blocking,
553 ];
554
555 $request = $this->remote_request( $path, $request_args, [], 'delete' );
556
557 if ( ! is_wp_error( $request ) ) {
558 $response_body = wp_remote_retrieve_body( $request );
559
560 $response = json_decode( $response_body, true );
561
562 if ( ! empty( $response['found'] ) ) {
563 return true;
564 }
565 }
566
567 return false;
568 }
569
570 /**
571 * Add appropriate headers to request
572 *
573 * @since 1.4
574 * @return array
575 */
576 public function format_request_headers() {
577 $headers = array(
578 'Content-Type' => 'application/json',
579 );
580
581 // Check for ElasticPress API key and add to header if needed.
582 if ( defined( 'EP_API_KEY' ) && EP_API_KEY ) {
583 $headers['X-ElasticPress-API-Key'] = EP_API_KEY;
584 }
585
586 /**
587 * ES Shield info
588 *
589 * @since 1.9
590 */
591 $shield = Utils\get_shield_credentials();
592
593 if ( ! empty( $shield ) ) {
594 // phpcs:disable
595 $headers['Authorization'] = 'Basic ' . base64_encode( $shield );
596 // phpcs:enable
597 }
598
599 /**
600 * Filter Elasticsearch request headers
601 *
602 * @hook ep_format_request_headers
603 * @param {array} $headers Current headers
604 * @return {array} New headers
605 */
606 $headers = apply_filters( 'ep_format_request_headers', $headers );
607
608 return $headers;
609 }
610
611 /**
612 * Get a document from Elasticsearch given an id
613 *
614 * @param string $index Index name.
615 * @param string $type Index type. Previously this was used for index type. Now it's just passed to hooks for legacy reasons.
616 * @param int $document_id Document id to get.
617 * @since 3.0
618 * @return boolean|array
619 */
620 public function get_document( $index, $type, $document_id ) {
621 if ( version_compare( $this->get_elasticsearch_version(), '7.0', '<' ) ) {
622 $path = $index . '/' . $type . '/' . $document_id;
623 } else {
624 $path = $index . '/_doc/' . $document_id;
625 }
626
627 $request_args = [ 'method' => 'GET' ];
628
629 $request = $this->remote_request( $path, $request_args, [], 'get' );
630
631 if ( ! is_wp_error( $request ) ) {
632 $response_body = wp_remote_retrieve_body( $request );
633
634 $response = json_decode( $response_body, true );
635
636 if ( ! empty( $response['exists'] ) || ! empty( $response['found'] ) ) {
637 return $response['_source'];
638 }
639 }
640
641 return false;
642 }
643
644 /**
645 * Delete the network alias.
646 *
647 * Network aliases are used to query documents across blogs in a network.
648 *
649 * @param string $alias Alias to use.
650 * @since 3.0
651 * @return array|boolean
652 */
653 public function delete_network_alias( $alias ) {
654 $path = '*/_alias/' . $alias;
655
656 $request_args = [ 'method' => 'DELETE' ];
657
658 $request = $this->remote_request( $path, $request_args, [], 'delete_network_alias' );
659
660 if ( ! is_wp_error( $request ) && ( 200 >= wp_remote_retrieve_response_code( $request ) && 300 > wp_remote_retrieve_response_code( $request ) ) ) {
661 $response_body = wp_remote_retrieve_body( $request );
662
663 return json_decode( $response_body );
664 }
665
666 return false;
667 }
668
669 /**
670 * Get multiple documents from Elasticsearch given an array of ids
671 *
672 * @param string $index Index name.
673 * @param string $type Index type. Previously this was used for index type. Now it's just passed to hooks for legacy reasons.
674 * @param array $document_ids Array of document ids to get.
675 * @since 3.6.0
676 * @return boolean|array
677 */
678 public function get_documents( $index, $type, $document_ids ) {
679 if ( version_compare( $this->get_elasticsearch_version(), '7.0', '<' ) ) {
680 $path = apply_filters( 'ep_index_' . $type . '_request_path', $index . '/' . $type . '/_mget', $document_ids, $type );
681 } else {
682 $path = apply_filters( 'ep_index_' . $type . '_request_path', $index . '/_mget', $document_ids, $type );
683 }
684
685 $request_args = [
686 'method' => 'POST',
687 'body' => wp_json_encode(
688 array(
689 'ids' => $document_ids,
690 )
691 ),
692 ];
693
694 $request = $this->remote_request( $path, $request_args, [], 'post' );
695
696 if ( is_wp_error( $request ) ) {
697 return false;
698 }
699
700 $response_body = wp_remote_retrieve_body( $request );
701
702 $response = json_decode( $response_body, true );
703
704 $docs = [];
705
706 if ( isset( $response['docs'] ) && is_array( $response['docs'] ) ) {
707 foreach ( $response['docs'] as $doc ) {
708 if ( ! empty( $doc['exists'] ) || ! empty( $doc['found'] ) ) {
709 $docs[ $doc['_id'] ] = $doc['_source'];
710 }
711 }
712 }
713
714 /**
715 * Filter documents found by Elasticsearch through the /_mget endpoint.
716 *
717 * @hook ep_get_documents
718 * @since 3.6.0
719 * @param {array} $docs Documents found indexed by ID
720 * @param {string} $index Index name
721 * @param {string} $type Index type
722 * @param {array} $document_ids Array of document ids
723 * @return {array} Documents to be returned
724 */
725 $docs = apply_filters( 'ep_get_documents', $docs, $index, $type, $document_ids );
726
727 return $docs;
728 }
729
730 /**
731 * Create the network alias.
732 *
733 * Network aliases are used to query documents across blogs in a network.
734 *
735 * @param array $indexes Indexes to group under alias.
736 * @param string $network_alias Name of network alias.
737 * @since 3.0
738 * @return boolean
739 */
740 public function create_network_alias( $indexes, $network_alias ) {
741
742 $path = '_aliases';
743
744 $args = array(
745 'actions' => [],
746 );
747
748 foreach ( $indexes as $index ) {
749 if ( empty( $index ) ) {
750 continue;
751 }
752
753 $args['actions'][] = array(
754 'add' => array(
755 'index' => $index,
756 'alias' => $network_alias,
757 ),
758 );
759 }
760
761 $request_args = array(
762 'body' => wp_json_encode( $args ),
763 'method' => 'POST',
764 'timeout' => 25,
765 );
766
767 $request = $this->remote_request( $path, $request_args, [], 'create_network_alias' );
768
769 if ( ! is_wp_error( $request ) && ( 200 >= wp_remote_retrieve_response_code( $request ) && 300 > wp_remote_retrieve_response_code( $request ) ) ) {
770 return true;
771 }
772
773 return false;
774 }
775
776 /**
777 * Put a mapping into Elasticsearch
778 *
779 * @param string $index Index name.
780 * @param array $mapping Mapping array.
781 * @since 3.0
782 * @return boolean
783 */
784 public function put_mapping( $index, $mapping ) {
785 /**
786 * Filter Elasticsearch mapping before put mapping
787 *
788 * @hook ep_config_mapping
789 * @param {array} $mapping Elasticsearch mapping
790 * @param {string} $index Index name
791 * @return {array} New mapping
792 */
793 $mapping = apply_filters( 'ep_config_mapping', $mapping, $index );
794
795 $request_args = [
796 'body' => wp_json_encode( $mapping ),
797 'method' => 'PUT',
798 'timeout' => 30,
799 ];
800
801 $request = $this->remote_request( $index, $request_args, [], 'put_mapping' );
802
803 /**
804 * Filter Elasticsearch put mapping response
805 *
806 * @hook ep_config_mapping_request
807 * @param {array} $request Elasticsearch response
808 * @param {string} $index Elasticsearch index name
809 * @param {array} $mapping Mapping sent to Elasticsearch
810 * @return {array} New response
811 */
812 $request = apply_filters( 'ep_config_mapping_request', $request, $index, $mapping );
813
814 $response_body = wp_remote_retrieve_body( $request );
815
816 if ( ! is_wp_error( $request ) && 200 === wp_remote_retrieve_response_code( $request ) ) {
817 $response_body = wp_remote_retrieve_body( $request );
818
819 return true;
820 }
821
822 return false;
823 }
824
825 /**
826 * Get current index mapping from Elasticsearch.
827 *
828 * @param string $index The index name.
829 * @since 3.5
830 * @return array
831 */
832 public function get_mapping( $index ) {
833 $request_args = [
834 'method' => 'GET',
835 'timeout' => 30,
836 ];
837
838 $request = $this->remote_request( $index, $request_args, [], 'get_mapping' );
839
840 if ( is_wp_error( $request ) || 200 !== wp_remote_retrieve_response_code( $request ) ) {
841 return [];
842 }
843
844 $body = wp_remote_retrieve_body( $request );
845
846 if ( ! $body ) {
847 return [];
848 }
849
850 $mapping = json_decode( $body, true );
851
852 return is_array( $mapping ) ? $mapping : [];
853 }
854
855 /**
856 * Close an open index.
857 *
858 * @param string $index Index name.
859 * @since 3.5
860 * @return boolean
861 */
862 public function close_index( $index ) {
863 $request_args = [
864 'method' => 'POST',
865 'timeout' => 30,
866 ];
867
868 $close = trailingslashit( $index ) . '_close';
869 $request = $this->remote_request( $close, $request_args, [], 'close_index' );
870
871 return ( ! is_wp_error( $request ) && 200 === wp_remote_retrieve_response_code( $request ) );
872 }
873
874 /**
875 * Open a closed index.
876 *
877 * @param string $index Index name.
878 * @since 3.5
879 * @return boolean
880 */
881 public function open_index( $index ) {
882 $request_args = [
883 'method' => 'POST',
884 'timeout' => 30,
885 ];
886
887 $open = trailingslashit( $index ) . '_open';
888 $request = $this->remote_request( $open, $request_args, [], 'open_index' );
889
890 return ( ! is_wp_error( $request ) && 200 === wp_remote_retrieve_response_code( $request ) );
891 }
892
893 /**
894 * Update index settings.
895 *
896 * @param string $index Index name.
897 * @param array $settings Setting update array.
898 * @param boolean $close_first Optional. True if index must be closed prior to update.
899 * Dynamic settings can be updated on open indices. Static
900 * settings must be closed. Default false.
901 * @since 3.5
902 * @return boolean
903 */
904 public function update_index_settings( $index, $settings, $close_first = false ) {
905 $request_args = [
906 'body' => wp_json_encode( $settings ),
907 'method' => 'PUT',
908 'timeout' => 30,
909 ];
910
911 $closed = false;
912 if ( $close_first ) {
913 $closed = $this->close_index( $index );
914 }
915
916 if ( ! $close_first || $closed ) {
917 $settings = trailingslashit( $index ) . '_settings';
918 $request = $this->remote_request( $settings, $request_args, [], 'update_index_settings' );
919 } else {
920 return false;
921 }
922
923 $updated = ( ! is_wp_error( $request ) && 200 === wp_remote_retrieve_response_code( $request ) );
924
925 if ( $closed ) {
926 $opened = $this->open_index( $index );
927 return ( $updated && $opened );
928 }
929
930 return $updated;
931 }
932
933 /**
934 * Delete an Elasticsearch index
935 *
936 * @param string $index Index name.
937 * @since 3.0
938 * @return boolean
939 */
940 public function delete_index( $index ) {
941
942 $request_args = [
943 'method' => 'DELETE',
944 'timeout' => 30,
945 ];
946
947 $request = $this->remote_request( $index, $request_args, [], 'delete_index' );
948
949 // 200 means the delete was successful
950 // 404 means the index was non-existent, but we should still pass this through as we will occasionally want to delete an already deleted index
951 if ( ! is_wp_error( $request ) && ( 200 === wp_remote_retrieve_response_code( $request ) || 404 === wp_remote_retrieve_response_code( $request ) ) ) {
952 $response_body = wp_remote_retrieve_body( $request );
953
954 return json_decode( $response_body );
955 }
956
957 return false;
958 }
959
960 /**
961 * Delete all indices
962 *
963 * @since 3.0
964 * @return boolean
965 */
966 public function delete_all_indices() {
967 return $this->delete_index( '*' );
968 }
969
970 /**
971 * Check if an ES index exists
972 *
973 * @param string $index Index name.
974 * @since 3.0
975 * @return boolean
976 */
977 public function index_exists( $index ) {
978
979 $request_args = [
980 'method' => 'HEAD',
981 ];
982
983 $request = $this->remote_request( $index, $request_args, [], 'index_exists' );
984
985 // 200 means the index exists.
986 // 404 means the index was non-existent.
987 if ( ! is_wp_error( $request ) && ( 200 === wp_remote_retrieve_response_code( $request ) || 404 === wp_remote_retrieve_response_code( $request ) ) ) {
988
989 if ( 404 === wp_remote_retrieve_response_code( $request ) ) {
990 return false;
991 }
992
993 if ( 200 === wp_remote_retrieve_response_code( $request ) ) {
994 return true;
995 }
996 }
997
998 return false;
999 }
1000
1001 /**
1002 * Bulk index Elasticsearch documents
1003 *
1004 * @param string $index Index name.
1005 * @param string $type Index type. Previously this was used for index type. Now it's just passed to hooks for legacy reasons.
1006 * @param string $body Encoded JSON.
1007 * @since 3.0
1008 * @return WP_Error|array
1009 */
1010 public function bulk_index( $index, $type, $body ) {
1011 /**
1012 * Filter Elasticsearch bulk index request path
1013 *
1014 * @hook ep_bulk_index_request_path
1015 * @param {string} Request path
1016 * @param {string} $body Bulk index request body
1017 * @param {string} $type Index type
1018 * @return {string} New path
1019 */
1020 if ( version_compare( $this->get_elasticsearch_version(), '7.0', '<' ) ) {
1021 $path = apply_filters( 'ep_bulk_index_request_path', $index . '/' . $type . '/_bulk', $body, $type );
1022 } else {
1023 $path = apply_filters( 'ep_bulk_index_request_path', $index . '/_bulk', $body, $type );
1024 }
1025
1026 $request_args = array(
1027 'method' => 'POST',
1028 'body' => $body,
1029 'timeout' => apply_filters( 'ep_bulk_index_timeout', 30 ),
1030 );
1031
1032 $request = $this->remote_request( $path, $request_args, [], 'bulk_index' );
1033
1034 if ( is_wp_error( $request ) ) {
1035 return $request;
1036 }
1037
1038 $response = wp_remote_retrieve_response_code( $request );
1039
1040 if ( 200 !== $response ) {
1041 return new WP_Error( $response, wp_remote_retrieve_response_message( $request ), $request );
1042 }
1043
1044 return json_decode( wp_remote_retrieve_body( $request ), true );
1045 }
1046
1047 /**
1048 * Return queries for debugging
1049 *
1050 * @since 1.8
1051 * @return array
1052 */
1053 public function get_query_log() {
1054 return $this->queries;
1055 }
1056
1057 /**
1058 * Wrapper for wp_remote_request
1059 *
1060 * This is a wrapper function for wp_remote_request to account for request failures.
1061 *
1062 * @since 1.6
1063 *
1064 * @param string $path Site URL to retrieve.
1065 * @param array $args Optional. Request arguments. Default empty array.
1066 * @param array $query_args Optional. The query args originally passed to WP_Query.
1067 * @param string $type Type of request, used for debugging.
1068 *
1069 * @return WP_Error|array The response or WP_Error on failure.
1070 */
1071 public function remote_request( $path, $args = [], $query_args = [], $type = null ) {
1072
1073 if ( empty( $args['method'] ) ) {
1074 $args['method'] = 'GET';
1075 }
1076
1077 // Checks for any previously set headers
1078 $existing_headers = isset( $args['headers'] ) ? (array) $args['headers'] : [];
1079
1080 // Add the API Header.
1081 // Note that the "User Agent" header will be changed via WordPress's `http_headers_useragent` filter later.
1082 $new_headers = $this->format_request_headers();
1083
1084 $args['headers'] = array_merge( $existing_headers, $new_headers );
1085
1086 /**
1087 * Filter Elasticsearch args prior to remote request
1088 *
1089 * @hook ep_pre_request_args
1090 * @since 3.6.4
1091 * @param {array} $args Request args
1092 * @param {string} $path Site URL to retrieve
1093 * @param {array} $query_args The query args originally passed to WP_Query.
1094 * @param {string|null} $type Type of request, used for debugging.
1095 * @return {array} New request args
1096 */
1097 $args = apply_filters( 'ep_pre_request_args', $args, $path, $query_args, $type );
1098
1099 $query = array(
1100 'time_start' => microtime( true ),
1101 'time_finish' => false,
1102 'args' => $args,
1103 'blocking' => true,
1104 'failed_hosts' => [],
1105 'request' => false,
1106 'host' => Utils\get_host(),
1107 'query_args' => $query_args,
1108 );
1109
1110 $request = false;
1111 $failures = 0;
1112
1113 add_filter( 'http_headers_useragent', [ $this, 'add_elasticpress_version_to_user_agent' ] );
1114
1115 // Optionally let us try back up hosts and account for failures.
1116 while ( true ) {
1117 /**
1118 * Filter Elasticsearch host prior to remote request
1119 *
1120 * @hook ep_pre_request_host
1121 * @param {string} Request host
1122 * @param {int} $failures Number of current failures
1123 * @param {string} $path Request path
1124 * @param {array} $args Request arguments
1125 * @return {string} New host
1126 */
1127 $query['host'] = apply_filters( 'ep_pre_request_host', $query['host'], $failures, $path, $args );
1128
1129 /**
1130 * Filter Elasticsearch url prior to remote request
1131 *
1132 * @hook ep_pre_request_url
1133 * @param {string} Request url
1134 * @param {int} $failures Number of current failures
1135 * @param {string} $host Request host
1136 * @param {string} $path Request path
1137 * @param {array} $args Request arguments
1138 * @return {string} New url
1139 */
1140 $query['url'] = apply_filters( 'ep_pre_request_url', esc_url( trailingslashit( $query['host'] ) . $path ), $failures, $query['host'], $path, $args );
1141
1142 /**
1143 * Filter whether remote request should be intercepted
1144 *
1145 * @hook ep_intercept_remote_request
1146 * @param {boolean} $intercept True to intercept
1147 * @return {boolean} New value
1148 */
1149 if ( true === apply_filters( 'ep_intercept_remote_request', false ) ) {
1150 /**
1151 * Filter intercepted request
1152 *
1153 * @hook ep_do_intercept_request
1154 * @since 3.2.2
1155 * @since 3.6.5 added $type
1156 * @param {array} $request New remote request response
1157 * @param {array} $query Remote request arguments
1158 * @param {args} $args Request arguments
1159 * @param {int} $failures Number of failures
1160 * @param {string} $type Type of request
1161 * @return {array} New request
1162 */
1163 $request = apply_filters( 'ep_do_intercept_request', new WP_Error( 400, 'No Request defined' ), $query, $args, $failures, $type );
1164 } else {
1165 $request = wp_remote_request( $query['url'], $args ); // try the existing host to avoid unnecessary calls.
1166 }
1167
1168 $request_response_code = (int) wp_remote_retrieve_response_code( $request );
1169
1170 $is_valid_res = ( $request_response_code >= 200 && $request_response_code <= 299 );
1171 $is_non_blocking_request = ( 0 === $request_response_code );
1172
1173 if ( false === $request || is_wp_error( $request ) || ( ! $is_valid_res && ! $is_non_blocking_request ) ) {
1174 $failures++;
1175
1176 /**
1177 * Filter max number of times to attempt remote requests
1178 *
1179 * @hook ep_max_remote_request_tries
1180 * @param {int} $tries Number of times to try
1181 * @param {path} $path Request path
1182 * @param {args} $args Request arguments
1183 * @return {int} New number of tries
1184 */
1185 if ( $failures >= apply_filters( 'ep_max_remote_request_tries', 1, $path, $args ) ) {
1186 break;
1187 }
1188 } else {
1189 break;
1190 }
1191 }
1192
1193 remove_filter( 'http_headers_useragent', [ $this, 'add_elasticpress_version_to_user_agent' ] );
1194
1195 // Return now if we're not blocking, since we won't have a response yet.
1196 if ( isset( $args['blocking'] ) && false === $args['blocking'] ) {
1197 $query['blocking'] = true;
1198 $query['request'] = $request;
1199 $this->add_query_log( $query );
1200
1201 return $request;
1202 }
1203
1204 $query['time_finish'] = microtime( true );
1205 $query['request'] = $request;
1206 $this->add_query_log( $query );
1207
1208 /**
1209 * Fires after Elasticsearch remote request
1210 *
1211 * @hook ep_remote_request
1212 * @param {array} $query Remote request arguments
1213 * @param {string} $type Request type
1214 */
1215 do_action( 'ep_remote_request', $query, $type );
1216
1217 return $request;
1218
1219 }
1220
1221 /**
1222 * Parse response from Elasticsearch
1223 *
1224 * Determines if there is an issue or if the response is valid.
1225 *
1226 * @since 1.9
1227 * @param object $response JSON decoded response from Elasticsearch.
1228 * @return array Contains the status message or the returned statistics.
1229 */
1230 public function parse_api_response( $response ) {
1231
1232 if ( null === $response ) {
1233
1234 return array(
1235 'status' => false,
1236 'msg' => esc_html__( 'Invalid response from ElasticPress server. Please contact your administrator.' ),
1237 );
1238
1239 } elseif (
1240 isset( $response->error ) &&
1241 (
1242 ( is_string( $response->error ) && stristr( $response->error, 'IndexMissingException' ) ) ||
1243 ( isset( $response->error->reason ) && stristr( $response->error->reason, 'no such index' ) )
1244 )
1245 ) {
1246
1247 if ( is_multisite() ) {
1248
1249 $error = __( 'Site not indexed. <p>Please run: <code>wp elasticpress index --setup --network-wide</code> using WP-CLI. Or use the index button on the left of this screen.</p>', 'elasticpress' );
1250
1251 } else {
1252
1253 $error = __( 'Site not indexed. <p>Please run: <code>wp elasticpress index --setup</code> using WP-CLI. Or use the index button on the left of this screen.</p>', 'elasticpress' );
1254
1255 }
1256
1257 return array(
1258 'status' => false,
1259 'msg' => $error,
1260 );
1261
1262 }
1263
1264 return array(
1265 'status' => true,
1266 'data' => $response->_all->primaries->indexing,
1267 );
1268
1269 }
1270
1271 /**
1272 * Get ES plugins and version, cache everything
1273 *
1274 * @param bool $force Bust cache or not.
1275 * @since 2.2
1276 * @return array
1277 */
1278 public function get_elasticsearch_info( $force = false ) {
1279 if ( ! empty( Utils\get_host() ) && ( $force || null === $this->elasticsearch_version || null === $this->elasticsearch_plugins ) ) {
1280
1281 // Get ES info from cache if available. If we are forcing, then skip cache check.
1282 if ( $force ) {
1283 $es_info = false;
1284 } else {
1285 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
1286 $es_info = get_site_transient( 'ep_es_info' );
1287 } else {
1288 $es_info = get_transient( 'ep_es_info' );
1289 }
1290 }
1291
1292 if ( ! empty( $es_info ) ) {
1293 // Set ES info from cache.
1294 $this->elasticsearch_version = $es_info['version'];
1295 $this->elasticsearch_plugins = $es_info['plugins'];
1296 } else {
1297 $path = '_nodes/plugins';
1298
1299 $request = $this->remote_request( $path, array( 'method' => 'GET' ) );
1300
1301 if ( is_wp_error( $request ) || 200 !== wp_remote_retrieve_response_code( $request ) ) {
1302 $this->elasticsearch_version = false;
1303 $this->elasticsearch_plugins = false;
1304
1305 /**
1306 * Try a different endpoint in case the plugins url is restricted
1307 *
1308 * @since 2.2.1
1309 */
1310
1311 $request = $this->remote_request( '', array( 'method' => 'GET' ) );
1312
1313 if ( ! is_wp_error( $request ) && 200 === wp_remote_retrieve_response_code( $request ) ) {
1314 $response_body = wp_remote_retrieve_body( $request );
1315 $response = json_decode( $response_body, true );
1316
1317 try {
1318 $this->elasticsearch_version = $response['version']['number'];
1319 } catch ( Exception $e ) {
1320 // Do nothing.
1321 }
1322 }
1323 } else {
1324 $response = json_decode( wp_remote_retrieve_body( $request ), true );
1325
1326 $this->elasticsearch_plugins = [];
1327 $this->elasticsearch_version = false;
1328
1329 if ( isset( $response['nodes'] ) ) {
1330
1331 foreach ( $response['nodes'] as $node ) {
1332 // Save version of last node. We assume all nodes are same version.
1333 $this->elasticsearch_version = $node['version'];
1334
1335 if ( isset( $node['plugins'] ) && is_array( $node['plugins'] ) ) {
1336
1337 foreach ( $node['plugins'] as $plugin ) {
1338
1339 $this->elasticsearch_plugins[ $plugin['name'] ] = $plugin['version'];
1340 }
1341
1342 break;
1343 }
1344 }
1345 }
1346 }
1347
1348 /**
1349 * Cache ES info
1350 *
1351 * @since 2.3.1
1352 */
1353
1354 /**
1355 * Filter elasticsearch info cache expiration
1356 *
1357 * @hook ep_es_info_cache_expiration
1358 * @param {int} $time Cache time in seconds
1359 * @return {int} New cache time
1360 */
1361 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
1362 set_site_transient(
1363 'ep_es_info',
1364 array(
1365 'version' => $this->elasticsearch_version,
1366 'plugins' => $this->elasticsearch_plugins,
1367 ),
1368 apply_filters( 'ep_es_info_cache_expiration', ( 5 * MINUTE_IN_SECONDS ) )
1369 );
1370 } else {
1371 set_transient(
1372 'ep_es_info',
1373 array(
1374 'version' => $this->elasticsearch_version,
1375 'plugins' => $this->elasticsearch_plugins,
1376 ),
1377 apply_filters( 'ep_es_info_cache_expiration', ( 5 * MINUTE_IN_SECONDS ) )
1378 );
1379 }
1380 }
1381 }
1382
1383 return array(
1384 'plugins' => $this->elasticsearch_plugins,
1385 'version' => $this->elasticsearch_version,
1386 );
1387 }
1388
1389 /**
1390 * Get cluster status
1391 *
1392 * Retrieves cluster stats from Elasticsearch.
1393 *
1394 * @since 1.9
1395 * @return array Contains the status message or the returned statistics.
1396 */
1397 public function get_cluster_status() {
1398
1399 if ( is_wp_error( Utils\get_host() ) ) {
1400
1401 return array(
1402 'status' => false,
1403 'msg' => esc_html__( 'Elasticsearch Host is not available.', 'elasticpress' ),
1404 );
1405
1406 } else {
1407
1408 $request = $this->remote_request( '_cluster/stats', array( 'method' => 'GET' ) );
1409
1410 if ( ! is_wp_error( $request ) ) {
1411
1412 $response = json_decode( wp_remote_retrieve_body( $request ) );
1413
1414 return $response;
1415
1416 }
1417
1418 return array(
1419 'status' => false,
1420 'msg' => $request->get_error_message(),
1421 );
1422
1423 }
1424 }
1425
1426 /**
1427 * Get an Elasticsearch pipeline
1428 *
1429 * @param string $id Id of pipeline.
1430 * @since 2.3
1431 * @return WP_Error|bool|array
1432 */
1433 public function get_pipeline( $id ) {
1434 $path = '_ingest/pipeline/' . $id;
1435
1436 $request_args = array(
1437 'method' => 'GET',
1438 );
1439
1440 /**
1441 * Filter get pipeline request arguments
1442 *
1443 * @hook ep_get_pipeline_args
1444 * @param {array} $request_args Request arguments
1445 * @return {array} New arguments
1446 */
1447 $request = $this->remote_request( $path, apply_filters( 'ep_get_pipeline_args', $request_args ), [], 'get_pipeline' );
1448
1449 if ( is_wp_error( $request ) ) {
1450 return $request;
1451 }
1452
1453 $response = wp_remote_retrieve_response_code( $request );
1454
1455 if ( 200 !== $response ) {
1456 return new WP_Error( $response, wp_remote_retrieve_response_message( $request ), $request );
1457 }
1458
1459 $body = json_decode( wp_remote_retrieve_body( $request ), true );
1460
1461 if ( empty( $body ) ) {
1462 return false;
1463 }
1464
1465 return $body;
1466 }
1467
1468 /**
1469 * Put an Elasticsearch pipeline
1470 *
1471 * @param string $id Pipeline id.
1472 * @param array $args Args to send to ES.
1473 * @since 2.3
1474 * @return WP_Error|bool
1475 */
1476 public function create_pipeline( $id, $args ) {
1477 $path = '_ingest/pipeline/' . $id;
1478
1479 $request_args = array(
1480 'body' => wp_json_encode( $args ),
1481 'method' => 'PUT',
1482 );
1483
1484 /**
1485 * Filter create pipeline request arguments
1486 *
1487 * @hook ep_create_pipeline_args
1488 * @param {array} $request_args Request arguments
1489 * @return {array} New arguments
1490 */
1491 $request = $this->remote_request( $path, apply_filters( 'ep_create_pipeline_args', $request_args ), [], 'create_pipeline' );
1492
1493 if ( is_wp_error( $request ) ) {
1494 return $request;
1495 }
1496
1497 $response = wp_remote_retrieve_response_code( $request );
1498
1499 if ( 200 > $response || 300 <= $response ) {
1500 return new WP_Error( $response, wp_remote_retrieve_response_message( $request ), $request );
1501 }
1502
1503 $body = json_decode( wp_remote_retrieve_body( $request ), true );
1504
1505 if ( empty( $body ) ) {
1506 return false;
1507 }
1508
1509 return true;
1510 }
1511
1512 /**
1513 * Conditionally add the ElasticPress version to the User Agent string.
1514 *
1515 * @since 3.6.1
1516 * @param string $user_agent Original User Agent.
1517 * @return string
1518 */
1519 public function add_elasticpress_version_to_user_agent( $user_agent ) {
1520 /**
1521 * Filter the User Agent header when submitting requests to Elasticsearch.
1522 *
1523 * @hook ep_remote_request_add_ep_user_agent
1524 * @param {bool} $should_add_ep_verion Whether the ElasticPress version should be added to the User Agent string.
1525 * @return {bool} New value
1526 * @since 3.6.1
1527 */
1528 if ( apply_filters( 'ep_remote_request_add_ep_user_agent', Utils\is_epio() ) ) {
1529 $end_part = '; ' . get_bloginfo( 'url' );
1530 $user_agent = str_replace(
1531 $end_part,
1532 ' (ElasticPress/' . EP_VERSION . ')' . $end_part,
1533 $user_agent
1534 );
1535 }
1536 return $user_agent;
1537 }
1538
1539 /**
1540 * Query logging. Don't log anything to the queries property when
1541 * WP_DEBUG is not enabled. Calls action 'ep_add_query_log' if you
1542 * want to access the query outside of the ElasticPress plugin. This
1543 * runs regardless of debufg settings.
1544 *
1545 * @param array $query Query to log.
1546 */
1547 protected function add_query_log( $query ) {
1548 if ( ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || ( defined( 'WP_EP_DEBUG' ) && WP_EP_DEBUG ) ) {
1549 $this->queries[] = $query;
1550 }
1551
1552 /**
1553 * Fires after item is added to the query log
1554 *
1555 * @hook ep_add_query_log
1556 * @param {array} $query Query to log
1557 */
1558 do_action( 'ep_add_query_log', $query );
1559 }
1560
1561 }
1562