Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 257
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
RestConnector
0.00% covered (danger)
0.00%
0 / 257
0.00% covered (danger)
0.00%
0 / 11
4556
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 query
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 getRecord
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getInstitutionCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 performSearch
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
462
 call
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
90
 processResponse
0.00% covered (danger)
0.00%
0 / 93
0.00% covered (danger)
0.00%
0 / 1
342
 processHighlighting
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
30
 processDescription
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getJWT
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 getUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * Primo Central connector (REST API).
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 * Copyright (C) The National Library of Finland 2023.
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License version 2,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 * GNU General Public License for more details.
19 *
20 * You should have received a copy of the GNU General Public License
21 * along with this program; if not, write to the Free Software
22 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
23 *
24 * @category VuFind
25 * @package  Search
26 * @author   Spencer Lamm <slamm1@swarthmore.edu>
27 * @author   Anna Headley <aheadle1@swarthmore.edu>
28 * @author   Chelsea Lobdell <clobdel1@swarthmore.edu>
29 * @author   Demian Katz <demian.katz@villanova.edu>
30 * @author   Ere Maijala <ere.maijala@helsinki.fi>
31 * @author   Oliver Goldschmidt <o.goldschmidt@tuhh.de>
32 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
33 * @link     https://vufind.org
34 */
35
36namespace VuFindSearch\Backend\Primo;
37
38use Laminas\Session\Container as SessionContainer;
39
40use function array_key_exists;
41use function in_array;
42use function is_array;
43use function strlen;
44
45/**
46 * Primo Central connector (REST API).
47 *
48 * @category VuFind
49 * @package  Search
50 * @author   Spencer Lamm <slamm1@swarthmore.edu>
51 * @author   Anna Headley <aheadle1@swarthmore.edu>
52 * @author   Chelsea Lobdell <clobdel1@swarthmore.edu>
53 * @author   Demian Katz <demian.katz@villanova.edu>
54 * @author   Ere Maijala <ere.maijala@helsinki.fi>
55 * @author   Oliver Goldschmidt <o.goldschmidt@tuhh.de>
56 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
57 * @link     https://vufind.org
58 */
59class RestConnector implements ConnectorInterface, \Laminas\Log\LoggerAwareInterface
60{
61    use \VuFind\Log\LoggerAwareTrait;
62    use \VuFindSearch\Backend\Feature\ConnectorCacheTrait;
63
64    /**
65     * HTTP client factory
66     *
67     * @var callable
68     */
69    protected $clientFactory;
70
71    /**
72     * Primo JWT API URL
73     *
74     * @var string
75     */
76    protected $jwtUrl;
77
78    /**
79     * Primo REST API search URL
80     *
81     * @var string
82     */
83    protected $searchUrl;
84
85    /**
86     * Institution code
87     *
88     * @var string
89     */
90    protected $inst;
91
92    /**
93     * Session container
94     *
95     * @var SessionContainer
96     */
97    protected $session;
98
99    /**
100     * Response for an empty search
101     *
102     * @var array
103     */
104    protected static $emptyQueryResponse = [
105        'recordCount' => 0,
106        'documents' => [],
107        'facets' => [],
108        'error' => 'empty_search_disallowed',
109    ];
110
111    /**
112     * Mappings from VuFind index names to Primo
113     *
114     * @var array
115     */
116    protected $indexMappings = [
117        'AllFields' => 'any',
118        'Title' => 'title',
119        'Author' => 'creator',
120        'Subject' => 'sub',
121        'Abstract' => 'desc',
122        'ISSN' => 'issn',
123    ];
124
125    /**
126     * Legacy sort mappings
127     *
128     * @var array
129     */
130    protected $sortMappings = [
131        'scdate' => 'date',
132        'screator' => 'author',
133        'stitle' => 'title',
134    ];
135
136    /**
137     * Constructor
138     *
139     * Sets up the Primo API Client
140     *
141     * @param string           $jwtUrl        Primo JWT API URL
142     * @param string           $searchUrl     Primo REST API search URL
143     * @param string           $instCode      Institution code (used as view ID, i.e. the
144     * vid parameter unless specified in the URL)
145     * @param callable         $clientFactory HTTP client factory
146     * @param SessionContainer $session       Session container
147     */
148    public function __construct(
149        string $jwtUrl,
150        string $searchUrl,
151        string $instCode,
152        callable $clientFactory,
153        SessionContainer $session
154    ) {
155        $this->jwtUrl = $jwtUrl;
156        $this->searchUrl = $searchUrl;
157        $this->inst = $instCode;
158
159        $this->clientFactory = $clientFactory;
160        $this->session = $session;
161    }
162
163    /**
164     * Execute a search. Adds all the querystring parameters into
165     * $this->client and returns the parsed response
166     *
167     * @param string $institution Institution
168     * @param array  $terms       Associative array:
169     *     index       string: primo index to search (default "any")
170     *     lookfor     string: actual search terms
171     * @param array  $params      Associative array of optional arguments:
172     *     phrase      bool:   true if it's a quoted phrase (default false)
173     *     onCampus    bool:   (default true)
174     *     didyoumean  bool:   (default false)
175     *     filterList  array:  (field, value) pairs to filter results (def null)
176     *     pageNumber  string: index of first record (default 1)
177     *     limit       string: number of records to return (default 20)
178     *     sort        string: value to be used by for sorting (default null)
179     *     highlight   bool:   whether to highlight search term matches in records
180     *     highlightStart string: Prefix for a highlighted term
181     *     highlightEnd   string: Suffix for a Highlighted term
182     *     Anything in $params not listed here will be ignored.
183     *
184     * Note: some input parameters accepted by Primo are not implemented here:
185     *  - dym (did you mean)
186     *  - more (get more)
187     *  - lang (specify input language so engine can do lang. recognition)
188     *  - displayField (has to do with highlighting somehow)
189     *
190     * @throws \Exception
191     * @return array             An array of query results
192     *
193     * @link http://www.exlibrisgroup.org/display/PrimoOI/Brief+Search
194     */
195    public function query($institution, $terms, $params = null)
196    {
197        // defaults for params
198        $args = [
199            'phrase' => false,
200            'onCampus' => true,
201            'didYouMean' => false,
202            'filterList' => null,
203            'pcAvailability' => false,
204            'pageNumber' => 1,
205            'limit' => 20,
206            'sort' => null,
207            'highlight' => false,
208            'highlightStart' => '',
209            'highlightEnd' => '',
210        ];
211        if (isset($params)) {
212            $args = array_merge($args, $params);
213        }
214        // Ensure limit is at least 1 since Primo seems to be flaky with 0:
215        $args['limit'] = max(1, $args['limit']);
216
217        return $this->performSearch($terms, $args);
218    }
219
220    /**
221     * Retrieves a document specified by the ID.
222     *
223     * @param string  $recordId  The document to retrieve from the Primo API
224     * @param ?string $inst_code Institution code (optional)
225     * @param bool    $onCampus  Whether the user is on campus
226     *
227     * @throws \Exception
228     * @return array             An array of query results
229     */
230    public function getRecord(string $recordId, $inst_code = null, $onCampus = false)
231    {
232        if ('' === $recordId) {
233            return self::$emptyQueryResponse;
234        }
235        // Query String Parameters
236        $qs = [];
237        // It would be tempting to use 'exact' matching here, but it does not work
238        // with all record IDs, so need to use 'contains'. Contrary to the old
239        // brief search API, quotes are necessary here for all IDs to work.
240        $qs['q'] = 'rid,contains,"' . str_replace(';', ' ', $recordId) . '"';
241        $qs['offset'] = '0';
242        $qs['limit'] = '1';
243        // pcAvailability=true is needed for records, which
244        // are NOT in the PrimoCentral Holdingsfile.
245        // It won't hurt to have this parameter always set to true.
246        // But it'd hurt to have it not set in case you want to get
247        // a record, which is not in the Holdingsfile.
248        $qs['pcAvailability'] = 'true';
249
250        return $this->processResponse($this->call(http_build_query($qs)));
251    }
252
253    /**
254     * Get the institution code based on user IP. If user is coming from
255     * off campus return
256     *
257     * @return string
258     */
259    public function getInstitutionCode()
260    {
261        return $this->inst;
262    }
263
264    /**
265     * Support method for query() -- perform inner search logic
266     *
267     * @param array $terms Associative array:
268     *     index       string: primo index to search (default "any")
269     *     lookfor     string: actual search terms
270     * @param array $args  Associative array of optional arguments (see query method for more information)
271     *
272     * @throws \Exception
273     * @return array       An array of query results
274     */
275    protected function performSearch($terms, $args)
276    {
277        // we have to build a querystring because I think adding them
278        //   incrementally is implemented as a dictionary, but we are allowed
279        //   multiple querystring parameters with the same key.
280        $qs = [];
281
282        // QUERYSTRING: query (search terms)
283        // re: phrase searches, turns out we can just pass whatever we got
284        //   to primo and they will interpret it correctly.
285        //   leaving this flag in b/c it's not hurting anything, but we
286        //   don't currently have a situation where we need to use "exact"
287        $precision = $args['phrase'] ? 'exact' : 'contains';
288
289        $primoQuery = [];
290        if (is_array($terms)) {
291            foreach ($terms as $thisTerm) {
292                $lookfor = str_replace(';', ' ', $thisTerm['lookfor']);
293                if (!$lookfor) {
294                    continue;
295                }
296                // Set the index to search
297                $index = $this->indexMappings[$thisTerm['index']] ?? 'any';
298
299                // Set precision
300                if (array_key_exists('op', $thisTerm) && !empty($thisTerm['op'])) {
301                    $precision = $thisTerm['op'];
302                }
303
304                $primoQuery[] = "$index,$precision,$lookfor";
305            }
306        }
307
308        // Return if we don't have any query terms:
309        if (!$primoQuery && empty($args['filterList'])) {
310            return self::$emptyQueryResponse;
311        }
312
313        if ($primoQuery) {
314            $qs['q'] = implode(';', $primoQuery);
315        }
316
317        // QUERYSTRING: query (filter list)
318        // Date-related TODO:
319        //   - provide additional support / processing for [x to y] limits?
320        if (!empty($args['filterList'])) {
321            $multiFacets = [];
322            $qInclude = [];
323            $qExclude = [];
324            foreach ($args['filterList'] as $current) {
325                $facet = $current['field'];
326                $facetOp = $current['facetOp'];
327                $values = $current['values'];
328
329                foreach ($values as $value) {
330                    if ('OR' === $facetOp) {
331                        $multiFacets[] = "facet_$facet,include,$value";
332                    } elseif ('NOT' === $facetOp) {
333                        $qExclude[] = "facet_$facet,exact,$value";
334                    } else {
335                        $qInclude[] = "facet_$facet,exact,$value";
336                    }
337                }
338            }
339            if ($multiFacets) {
340                $qs['multiFacets'] = implode('|,|', $multiFacets);
341            }
342            if ($qInclude) {
343                $qs['qInclude'] = implode('|,|', $qInclude);
344            }
345            if ($qExclude) {
346                $qs['qExclude'] =  implode('|,|', $qExclude);
347            }
348        }
349
350        // QUERYSTRING: pcAvailability
351        // by default, Primo Central only returns matches,
352        // which are available via Holdingsfile
353        // pcAvailability = false
354        // By setting this value to true, also matches, which
355        // are NOT available via Holdingsfile are returned
356        // (yes, right, set this to true - that's ExLibris Logic)
357        if ($args['pcAvailability']) {
358            $qs['pcAvailability'] = 'true';
359        }
360
361        // QUERYSTRING: offset and limit
362        $recordStart = ($args['pageNumber'] - 1) * $args['limit'];
363        $qs['offset'] = $recordStart;
364        $qs['limit'] = $args['limit'];
365
366        // QUERYSTRING: sort
367        // Possible values are rank (default), title, author or date.
368        $sort = $args['sort'] ?? null;
369        if ($sort && 'relevance' !== $sort) {
370            // Map legacy sort options:
371            $qs['sort'] = $this->sortMappings[$sort] ?? $sort;
372        }
373
374        return $this->processResponse($this->call(http_build_query($qs)), $args);
375    }
376
377    /**
378     * Small wrapper for sendRequest, process to simplify error handling.
379     *
380     * @param string $qs Query string
381     *
382     * @return string Result body
383     * @throws \Exception
384     */
385    protected function call(string $qs): string
386    {
387        $url = $this->getUrl($this->searchUrl);
388        $url .= (str_contains($url, '?') ? '&' : '?') . $qs;
389        $this->debug("GET: $url");
390        $client = ($this->clientFactory)($url);
391        $client->setMethod('GET');
392        // Check cache:
393        $resultBody = null;
394        $cacheKey = null;
395        if ($this->cache) {
396            $cacheKey = $this->getCacheKey($client);
397            $resultBody = $this->getCachedData($cacheKey);
398        }
399        if (null === $resultBody) {
400            if ($jwt = $this->getJWT()) {
401                $client->setHeaders(
402                    [
403                        'Authorization' => [
404                            "Bearer $jwt",
405                        ],
406                    ]
407                );
408            }
409            // Send request:
410            $result = $client->send();
411            if ($jwt && $result->getStatusCode() === 403) {
412                // Reset JWT and try again:
413                $jwt = $this->getJWT(true);
414                $client->setHeaders(
415                    [
416                        'Authorization' => [
417                            "Bearer $jwt",
418                        ],
419                    ]
420                );
421                $result = $client->send();
422            }
423            $resultBody = $result->getBody();
424            if (!$result->isSuccess()) {
425                $this->logError("Request $url failed with error code " . $result->getStatusCode() . "$resultBody");
426                throw new \Exception($resultBody);
427            }
428            if ($cacheKey) {
429                $this->putCachedData($cacheKey, $resultBody);
430            }
431        }
432        return $resultBody;
433    }
434
435    /**
436     * Translate Primo's JSON into array of arrays.
437     *
438     * @param string $data   The raw xml from Primo
439     * @param array  $params Request parameters
440     *
441     * @return array The processed response from Primo
442     */
443    protected function processResponse(string $data, array $params = []): array
444    {
445        // Make sure data exists
446        if ('' === $data) {
447            throw new \Exception('Primo did not return any data');
448        }
449
450        // Parse API response
451        $response = json_decode($data);
452
453        if (false === $response) {
454            throw new \Exception('Error while parsing Primo response');
455        }
456
457        $totalhits = (int)$response->info->total;
458        $items = [];
459        foreach ($response->docs as $doc) {
460            $item = [];
461            $pnx = $doc->pnx;
462            $addata = $pnx->addata;
463            $control = $pnx->control;
464            $display = $pnx->display;
465            $search = $pnx->search;
466            $item['recordid'] = substr($control->recordid[0], 3);
467            $item['title'] = $display->title[0] ?? '';
468            $item['format'] = $display->type ?? [];
469            // creators (use the search fields instead of display to get them as an array instead of a long string)
470            if ($search->creator ?? null) {
471                $item['creator'] = array_map('trim', $search->creator);
472            }
473            // subjects (use the search fields instead of display to get them as an array instead of a long string)
474            if ($search->subject ?? null) {
475                $item['subjects'] = $search->subject;
476            }
477            $item['ispartof'] = $display->ispartof[0] ?? '';
478            $item['description'] = $display->description[0]
479                ?? $search->description[0]
480                ?? '';
481            // and the rest!
482            $item['language'] = $display->language[0] ?? '';
483            $item['source'] = implode('; ', $display->source ?? []);
484            $item['identifier'] = $display->identifier[0] ?? '';
485            $item['fulltext'] = $pnx->delivery->fulltext[0] ?? '';
486            $item['issn'] = $search->issn ?? [];
487            $item['publisher'] = $display->publisher ?? [];
488            $item['peer_reviewed'] = ($display->lds50[0] ?? '') === 'peer_reviewed';
489            $openurl = $pnx->links->openurl[0] ?? '';
490            $item['url'] = $openurl && !str_starts_with($openurl, '$')
491                ? $openurl
492                : ($pnx->GetIt2->link ?? '');
493
494            $processCitations = function (array $data): array {
495                return array_map(
496                    function ($s) {
497                        return "cdi_$s";
498                    },
499                    $data
500                );
501            };
502
503            // These require the cdi_ prefix in search, so add it right away:
504            $item['cites'] = $processCitations($display->cites ?? []);
505            $item['cited_by'] = $processCitations($display->citedby ?? []);
506
507            // Container data
508            $item['container_title'] = $addata->jtitle[0] ?? '';
509            $item['container_volume'] = $addata->volume[0] ?? '';
510            $item['container_issue'] = $addata->issue[0] ?? '';
511            $item['container_start_page'] = $addata->spage[0] ?? '';
512            $item['container_end_page'] = $addata->epage[0] ?? '';
513            foreach ($addata->eissn ?? [] as $eissn) {
514                if (!in_array($eissn, $item['issn'])) {
515                    $item['issn'][] = $eissn;
516                }
517            }
518            foreach ($addata->issn ?? [] as $issn) {
519                if (!in_array($issn, $item['issn'])) {
520                    $item['issn'][] = $issn;
521                }
522            }
523            $item['doi_str_mv'] = $addata->doi ?? [];
524
525            // Remove dash-less ISSNs if there are corresponding dashed ones
526            // (We could convert dash-less ISSNs to dashed ones, but try to stay
527            // true to the metadata)
528            $callback = function ($issn) use ($item) {
529                return strlen($issn) != 8
530                    || !in_array(
531                        substr($issn, 0, 4) . '-' . substr($issn, 4),
532                        $item['issn']
533                    );
534            };
535            $item['issn'] = array_values(array_filter($item['issn'], $callback));
536
537            $this->processHighlighting($item, $params, $response->highlights);
538
539            // Fix description now that highlighting is done:
540            $item['description'] = $this->processDescription($item['description']);
541
542            $item['fullrecord'] = json_decode(json_encode($pnx), true);
543            $items[] = $item;
544        }
545
546        // Add active filters to the facet list (Primo doesn't return them):
547        $facets = [];
548        foreach ($params['filterList'] ?? [] as $current) {
549            if ('NOT' === $current['facetOp']) {
550                continue;
551            }
552            $field = $current['field'];
553            foreach ($current['values'] as $value) {
554                $facets[$field][$value] = null;
555            }
556        }
557
558        // Process received facets
559        foreach ($response->facets as $facet) {
560            // Handle facet values as strings to ensure that numeric values stay
561            // intact (no array_combine etc.):
562            foreach ($facet->values as $value) {
563                $facets[$facet->name][(string)$value->value] = $value->count;
564            }
565            uasort(
566                $facets[$facet->name],
567                function ($a, $b) {
568                    // Put the selected facets (with null as value) on the top:
569                    return ($b ?? PHP_INT_MAX) <=> ($a ?? PHP_INT_MAX);
570                }
571            );
572        }
573
574        // Apparently there's no "did you mean" data in the response..
575
576        return [
577            'recordCount' => $totalhits,
578            'documents' => $items,
579            'facets' => $facets,
580            'didYouMean' => [],
581            'error' => $response->info->errorDetails->errorMessages[0] ?? [],
582        ];
583    }
584
585    /**
586     * Process highlighting tags of the record fields
587     *
588     * @param array     $record    Record data
589     * @param array     $params    Request params
590     * @param \StdClass $highlight Highlighting data
591     *
592     * @return void
593     */
594    protected function processHighlighting(array &$record, array $params, \StdClass $highlight): void
595    {
596        if (empty($params['highlight'])) {
597            return;
598        }
599
600        $startTag = $params['highlightStart'] ?? '';
601        $endTag = $params['highlightEnd'] ?? '';
602
603        $highlightFields = [
604            'title' => 'title',
605            'creator' => 'author',
606            'description' => 'description',
607        ];
608
609        $hilightDetails = [];
610
611        foreach ($highlightFields as $primoField => $field) {
612            if (
613                ($highlightValues = $highlight->$primoField ?? null)
614                && !empty($record[$field])
615            ) {
616                $match = implode(
617                    '|',
618                    array_map(
619                        function ($s) {
620                            return preg_quote($s, '/');
621                        },
622                        $highlightValues
623                    )
624                );
625                $hilightDetails[$field] = array_map(
626                    function ($s) use ($match, $startTag, $endTag) {
627                        return preg_replace("/(\b|-|–)($match)(\b|-|–)/", "$1$startTag$2$endTag$3", $s);
628                    },
629                    (array)$record[$field]
630                );
631            }
632        }
633
634        $record['highlightDetails'] = $hilightDetails;
635    }
636
637    /**
638     * Fix the description field by removing tags etc.
639     *
640     * @param string $description Description
641     *
642     * @return string
643     */
644    protected function processDescription($description)
645    {
646        // Sometimes the entire article is in the description, so just take a chunk
647        // from the beginning.
648        $description = trim(mb_substr($description, 0, 2500, 'UTF-8'));
649        // These may contain all kinds of metadata, and just stripping
650        // tags mushes it all together confusingly.
651        $description = str_replace('<P>', '<p>', $description);
652        $paragraphs = explode('<p>', $description);
653        foreach ($paragraphs as &$value) {
654            // Strip tags, trim so array_filter can get rid of
655            // entries that would just have spaces
656            $value = trim(strip_tags($value));
657        }
658        $paragraphs = array_filter($paragraphs);
659        // Now join paragraphs using line breaks
660        return implode('<br>', $paragraphs);
661    }
662
663    /**
664     * Get a JWT token for the session
665     *
666     * @param bool $renew Whether to renew the token
667     *
668     * @return string
669     */
670    protected function getJWT(bool $renew = false): string
671    {
672        if (!$this->jwtUrl) {
673            return '';
674        }
675
676        if (!$renew && isset($this->session->jwt)) {
677            return $this->session->jwt;
678        }
679        $client = ($this->clientFactory)($this->getUrl($this->jwtUrl));
680        $result = $client->setMethod('GET')->send();
681        $resultBody = $result->getBody();
682        if (!$result->isSuccess()) {
683            $this->logError(
684                "Request {$this->jwtUrl} failed with error code " . $result->getStatusCode() . "$resultBody"
685            );
686            throw new \Exception($resultBody);
687        }
688        $this->session->jwt = trim($resultBody, '"');
689        return $this->session->jwt;
690    }
691
692    /**
693     * Build a URL from a configured one
694     *
695     * @param string $url URL
696     *
697     * @return string
698     */
699    protected function getUrl(string $url): string
700    {
701        return str_replace('{{INSTCODE}}', urlencode($this->inst), $url);
702    }
703}