Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.79% covered (warning)
88.79%
95 / 107
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Connector
88.79% covered (warning)
88.79%
95 / 107
72.22% covered (warning)
72.22%
13 / 18
41.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
 getUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUniqueKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetLastUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 retrieve
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 similar
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 search
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 terms
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 write
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 query
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
2.08
 callWithHttpOptions
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 isRethrowableSolrException
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 forceToBackendException
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 trySolrUrls
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
10
 getCore
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 send
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * SOLR connector.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Search
25 * @author   Andrew S. Nagy <vufind-tech@lists.sourceforge.net>
26 * @author   David Maus <maus@hab.de>
27 * @author   Demian Katz <demian.katz@villanova.edu>
28 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
29 * @link     https://vufind.org
30 */
31
32namespace VuFindSearch\Backend\Solr;
33
34use Laminas\Http\Client\Adapter\Exception\TimeoutException;
35use Laminas\Http\Client as HttpClient;
36use Laminas\Http\Request;
37use Laminas\Uri\Http;
38use VuFindSearch\Backend\Exception\BackendException;
39use VuFindSearch\Backend\Exception\HttpErrorException;
40use VuFindSearch\Backend\Exception\RemoteErrorException;
41use VuFindSearch\Backend\Exception\RequestErrorException;
42use VuFindSearch\Backend\Solr\Document\DocumentInterface;
43use VuFindSearch\Exception\InvalidArgumentException;
44use VuFindSearch\ParamBag;
45
46use function call_user_func_array;
47use function count;
48use function is_callable;
49use function strlen;
50
51/**
52 * SOLR connector.
53 *
54 * @category VuFind
55 * @package  Search
56 * @author   Andrew S. Nagy <vufind-tech@lists.sourceforge.net>
57 * @author   David Maus <maus@hab.de>
58 * @author   Demian Katz <demian.katz@villanova.edu>
59 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
60 * @link     https://vufind.org
61 */
62class Connector implements \Laminas\Log\LoggerAwareInterface
63{
64    use \VuFind\Log\LoggerAwareTrait;
65    use \VuFindSearch\Backend\Feature\ConnectorCacheTrait;
66
67    /**
68     * Maximum length of a GET url.
69     *
70     * Switches to POST if the SOLR target URL exceeds this length.
71     *
72     * @see \VuFindSearch\Backend\Solr\Connector::query()
73     *
74     * @var int
75     */
76    public const MAX_GET_URL_LENGTH = 2048;
77
78    /**
79     * HTTP client factory
80     *
81     * @var callable
82     */
83    protected $clientFactory;
84
85    /**
86     * URL or an array of alternative URLs of the SOLR core.
87     *
88     * @var string|array
89     */
90    protected $url;
91
92    /**
93     * Handler map.
94     *
95     * @var HandlerMap
96     */
97    protected $map;
98
99    /**
100     * Solr field used to store unique identifier
101     *
102     * @var string
103     */
104    protected $uniqueKey;
105
106    /**
107     * Url of the last request
108     *
109     * @var ?Http
110     */
111    protected $lastUrl = null;
112
113    /**
114     * Constructor
115     *
116     * @param string|array        $url       SOLR core URL or an array of alternative
117     * URLs
118     * @param HandlerMap          $map       Handler map
119     * @param callable|HttpClient $cf        HTTP client factory or a client to clone
120     * @param string              $uniqueKey Solr field used to store unique
121     * identifier
122     */
123    public function __construct(
124        $url,
125        HandlerMap $map,
126        $cf,
127        $uniqueKey = 'id'
128    ) {
129        $this->url = $url;
130        $this->map = $map;
131        $this->uniqueKey = $uniqueKey;
132        if ($cf instanceof HttpClient) {
133            $this->clientFactory = function () use ($cf) {
134                return clone $cf;
135            };
136        } else {
137            $this->clientFactory = $cf;
138        }
139    }
140
141    /// Public API
142
143    /**
144     * Get the Solr URL.
145     *
146     * @return string
147     */
148    public function getUrl()
149    {
150        return $this->url;
151    }
152
153    /**
154     * Return handler map.
155     *
156     * @return HandlerMap
157     */
158    public function getMap()
159    {
160        return $this->map;
161    }
162
163    /**
164     * Get unique key.
165     *
166     * @return string
167     */
168    public function getUniqueKey()
169    {
170        return $this->uniqueKey;
171    }
172
173    /**
174     * Get the last request url.
175     *
176     * @return ?Http
177     */
178    public function getLastUrl()
179    {
180        return $this->lastUrl;
181    }
182
183    /**
184     * Clears the last url
185     *
186     * @return void
187     */
188    public function resetLastUrl()
189    {
190        $this->lastUrl = null;
191    }
192
193    /**
194     * Return document specified by id.
195     *
196     * @param string   $id     The document to retrieve from Solr
197     * @param ParamBag $params Parameters
198     *
199     * @return string
200     */
201    public function retrieve($id, ParamBag $params = null)
202    {
203        $params = $params ?: new ParamBag();
204        $params
205            ->set('q', sprintf('%s:"%s"', $this->uniqueKey, addcslashes($id, '"')));
206
207        $handler = $this->map->getHandler(__FUNCTION__);
208        $this->map->prepare(__FUNCTION__, $params);
209
210        return $this->query($handler, $params, true);
211    }
212
213    /**
214     * Return records similar to a given record specified by id.
215     *
216     * Uses MoreLikeThis Request Component or MoreLikeThis Handler
217     *
218     * @param string   $id     ID of given record (not currently used, but
219     * retained for backward compatibility / extensibility).
220     * @param ParamBag $params Parameters
221     *
222     * @return string
223     *
224     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
225     */
226    public function similar($id, ParamBag $params)
227    {
228        $handler = $this->map->getHandler(__FUNCTION__);
229        $this->map->prepare(__FUNCTION__, $params);
230        return $this->query($handler, $params, true);
231    }
232
233    /**
234     * Execute a search.
235     *
236     * @param ParamBag $params Parameters
237     *
238     * @return string
239     */
240    public function search(ParamBag $params)
241    {
242        $handler = $this->map->getHandler(__FUNCTION__);
243        $this->map->prepare(__FUNCTION__, $params);
244        return $this->query($handler, $params, true);
245    }
246
247    /**
248     * Extract terms from a SOLR index.
249     *
250     * @param ParamBag $params Parameters
251     *
252     * @return string
253     */
254    public function terms(ParamBag $params)
255    {
256        $handler = $this->map->getHandler(__FUNCTION__);
257        $this->map->prepare(__FUNCTION__, $params);
258
259        return $this->query($handler, $params, true);
260    }
261
262    /**
263     * Write to the SOLR index.
264     *
265     * @param DocumentInterface $document Document to write
266     * @param string            $handler  Update handler
267     * @param ParamBag          $params   Update handler parameters
268     *
269     * @return string Response body
270     */
271    public function write(
272        DocumentInterface $document,
273        $handler = 'update',
274        ParamBag $params = null
275    ) {
276        $params = $params ?: new ParamBag();
277        $urlSuffix = "/{$handler}";
278        if (count($params) > 0) {
279            $urlSuffix .= '?' . implode('&', $params->request());
280        }
281        $callback = function ($client) use ($document) {
282            $client->setEncType($document->getContentType());
283            $body = $document->getContent();
284            $client->setRawBody($body);
285            $client->getRequest()->getHeaders()
286                ->addHeaderLine('Content-Length', strlen($body));
287        };
288        return $this->trySolrUrls('POST', $urlSuffix, $callback);
289    }
290
291    /// Internal API
292
293    /**
294     * Send query to SOLR and return response body.
295     *
296     * @param string   $handler   SOLR request handler to use
297     * @param ParamBag $params    Request parameters
298     * @param bool     $cacheable Whether the query is cacheable
299     *
300     * @return string Response body
301     */
302    public function query($handler, ParamBag $params, bool $cacheable = false)
303    {
304        $urlSuffix = '/' . $handler;
305        $paramString = implode('&', $params->request());
306        if (strlen($paramString) > self::MAX_GET_URL_LENGTH) {
307            $method = Request::METHOD_POST;
308            $callback = function ($client) use ($paramString) {
309                $client->setRawBody($paramString);
310                $client->setEncType(HttpClient::ENC_URLENCODED);
311                $client->setHeaders(['Content-Length' => strlen($paramString)]);
312            };
313        } else {
314            $method = Request::METHOD_GET;
315            $urlSuffix .= '?' . $paramString;
316            $callback = null;
317        }
318
319        $this->debug(sprintf('Query %s', $paramString));
320        return $this->trySolrUrls($method, $urlSuffix, $callback, $cacheable);
321    }
322
323    /**
324     * Call a method with provided options for the HTTP client
325     *
326     * @param array  $options HTTP client options
327     * @param string $method  Method to call
328     * @param array  ...$args Method parameters
329     *
330     * @return mixed
331     */
332    public function callWithHttpOptions(
333        array $options,
334        string $method,
335        ...$args
336    ) {
337        $reflectionMethod = new \ReflectionMethod($this, $method);
338        if (!$reflectionMethod->isPublic()) {
339            throw new InvalidArgumentException("Method '$method' is not public");
340        }
341        if (empty($options)) {
342            return call_user_func_array([$this, $method], $args);
343        }
344        $originalFactory = $this->clientFactory;
345        try {
346            $this->clientFactory = function (string $url) use (
347                $originalFactory,
348                $options
349            ) {
350                $client = $originalFactory($url);
351                $client->setOptions($options);
352                return $client;
353            };
354            return call_user_func_array([$this, $method], $args);
355        } finally {
356            $this->clientFactory = $originalFactory;
357        }
358    }
359
360    /**
361     * Check if an exception from a Solr request should be thrown rather than retried
362     *
363     * @param \Exception $ex Exception
364     *
365     * @return bool
366     */
367    protected function isRethrowableSolrException($ex)
368    {
369        return $ex instanceof TimeoutException
370            || $ex instanceof RequestErrorException;
371    }
372
373    /**
374     * If an unexpected exception type was received, wrap it in a generic
375     * BackendException to standardize upstream handling.
376     *
377     * @param \Exception $ex Exception
378     *
379     * @return \Exception
380     */
381    protected function forceToBackendException($ex)
382    {
383        // Don't wrap specific backend exceptions....
384        if (
385            $ex instanceof RemoteErrorException
386            || $ex instanceof RequestErrorException
387            || $ex instanceof HttpErrorException
388        ) {
389            return $ex;
390        }
391        return
392            new BackendException('Problem connecting to Solr.', $ex->getCode(), $ex);
393    }
394
395    /**
396     * Try all Solr URLs until we find one that works (or throw an exception).
397     *
398     * @param string   $method    HTTP method to use
399     * @param string   $urlSuffix Suffix to append to all URLs tried
400     * @param callable $callback  Callback to configure client (null for none)
401     * @param bool     $cacheable Whether the request is cacheable
402     *
403     * @return string Response body
404     *
405     * @throws RemoteErrorException  SOLR signaled a server error (HTTP 5xx)
406     * @throws RequestErrorException SOLR signaled a client error (HTTP 4xx)
407     */
408    protected function trySolrUrls(
409        $method,
410        $urlSuffix,
411        $callback = null,
412        bool $cacheable = false
413    ) {
414        // This exception should never get thrown; it's just a safety in case
415        // something unanticipated occurs.
416        $exception = new \Exception('Unexpected exception.');
417
418        // Loop through all base URLs and try them in turn until one works.
419        $cacheKey = null;
420        foreach ((array)$this->url as $base) {
421            $client = ($this->clientFactory)($base . $urlSuffix);
422            $client->setMethod($method);
423            if (is_callable($callback)) {
424                $callback($client);
425            }
426            // Always create the cache key from the first server, and only after any
427            // callback has been called above.
428            if ($cacheable && $this->cache && null === $cacheKey) {
429                $cacheKey = $this->getCacheKey($client);
430                if ($result = $this->getCachedData($cacheKey)) {
431                    return $result;
432                }
433            }
434            try {
435                $result = $this->send($client);
436                if ($cacheKey) {
437                    $this->putCachedData($cacheKey, $result);
438                }
439                return $result;
440            } catch (\Exception $ex) {
441                if ($this->isRethrowableSolrException($ex)) {
442                    throw $this->forceToBackendException($ex);
443                }
444                $exception = $ex;
445            }
446        }
447
448        // If we got this far, everything failed -- throw a BackendException with
449        // the most recent exception caught above set as the previous exception.
450        throw $this->forceToBackendException($exception);
451    }
452
453    /**
454     * Extract the Solr core from the connector's URL.
455     *
456     * @return string
457     */
458    public function getCore(): string
459    {
460        $url = rtrim($this->getUrl(), '/');
461        $parts = explode('/', $url);
462        return array_pop($parts);
463    }
464
465    /**
466     * Send request the SOLR and return the response.
467     *
468     * @param HttpClient $client Prepared HTTP client
469     *
470     * @return string Response body
471     *
472     * @throws RemoteErrorException  SOLR signaled a server error (HTTP 5xx)
473     * @throws RequestErrorException SOLR signaled a client error (HTTP 4xx)
474     */
475    protected function send(HttpClient $client)
476    {
477        $this->debug(
478            sprintf('=> %s %s', $client->getMethod(), $client->getUri())
479        );
480
481        $this->lastUrl = $client->getUri();
482
483        $time     = microtime(true);
484        $response = $client->send();
485        $time     = microtime(true) - $time;
486
487        $this->debug(
488            sprintf(
489                '<= %s %s',
490                $response->getStatusCode(),
491                $response->getReasonPhrase()
492            ),
493            ['time' => $time]
494        );
495
496        if (!$response->isSuccess()) {
497            throw HttpErrorException::createFromResponse($response);
498        }
499        return $response->getBody();
500    }
501}