Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.33% covered (success)
95.33%
286 / 300
61.11% covered (warning)
61.11%
11 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
GeniePlus
95.33% covered (success)
95.33%
286 / 300
61.11% covered (warning)
61.11%
11 / 18
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateConfiguration
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 init
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
2.15
 renewAccessToken
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 callApiWithToken
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 getFieldFromApiRecord
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 extractDisplayValues
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 apiStatusRecordToArray
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
1 / 1
5
 getTemplateQueryPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 sanitizeQueryParam
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStatus
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getStatuses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHolding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPurchaseHistory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 patronLogin
97.83% covered (success)
97.83%
45 / 46
0.00% covered (danger)
0.00%
0 / 1
2
 getMyProfile
98.70% covered (success)
98.70%
76 / 77
0.00% covered (danger)
0.00%
0 / 1
10
 getMyTransactions
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
1.00
1<?php
2
3/**
4 * GeniePlus API driver
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2022.
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  ILS_Drivers
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
28 */
29
30namespace VuFind\ILS\Driver;
31
32use VuFind\Exception\ILS as ILSException;
33
34use function count;
35use function in_array;
36
37/**
38 * GeniePlus API driver
39 *
40 * @category VuFind
41 * @package  ILS_Drivers
42 * @author   Demian Katz <demian.katz@villanova.edu>
43 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
44 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
45 */
46class GeniePlus extends AbstractAPI
47{
48    /**
49     * Status messages indicating available items
50     *
51     * @var string[]
52     */
53    protected $availableStatuses;
54
55    /**
56     * Access token
57     *
58     * @var string
59     */
60    protected $token = null;
61
62    /**
63     * Factory function for constructing the SessionContainer.
64     *
65     * @var callable
66     */
67    protected $sessionFactory;
68
69    /**
70     * Session cache
71     *
72     * @var \Laminas\Session\Container
73     */
74    protected $sessionCache;
75
76    /**
77     * Constructor
78     *
79     * @param callable $sessionFactory Factory function returning SessionContainer
80     * object
81     */
82    public function __construct(callable $sessionFactory)
83    {
84        $this->sessionFactory = $sessionFactory;
85    }
86
87    /**
88     * Support method for init(): make sure we have a valid configuration.
89     *
90     * @return void
91     * @throws ILSException
92     */
93    protected function validateConfiguration(): void
94    {
95        $missingConfigs = [];
96        $requiredApiSettings = [
97            'base_url',
98            'catalog_template',
99            'database',
100            'loan_template',
101            'oauth_id',
102            'username',
103            'password',
104            'patron_template',
105        ];
106        foreach ($requiredApiSettings as $setting) {
107            if (!isset($this->config['API'][$setting])) {
108                $missingConfigs[] = "API/$setting";
109            }
110        }
111        if (!isset($this->config['Patron']['field']['cat_password'])) {
112            $missingConfigs[] = 'Patron/field/cat_password';
113        }
114        if (!empty($missingConfigs)) {
115            throw new ILSException(
116                'Missing required GeniePlus.ini configuration setting(s): '
117                . implode(', ', $missingConfigs)
118            );
119        }
120    }
121
122    /**
123     * Initialize the driver.
124     *
125     * Validate configuration and perform all resource-intensive tasks needed to
126     * make the driver active.
127     *
128     * @return void
129     */
130    public function init()
131    {
132        $this->validateConfiguration();
133        $this->availableStatuses
134            = (array)($this->config['Item']['available_statuses'] ?? []);
135        $cacheNamespace = md5(
136            $this->config['API']['database'] . '|' . $this->config['API']['base_url']
137        );
138        $this->sessionCache = ($this->sessionFactory)($cacheNamespace);
139        if ($this->sessionCache->genieplus_token ?? false) {
140            $this->token = $this->sessionCache->genieplus_token;
141            $this->debug(
142                'Token taken from cache: ' . substr($this->token, 0, 30) . '...'
143            );
144        }
145    }
146
147    /**
148     * Renew the OAuth access token needed by the API.
149     *
150     * @return void
151     * @throws ILSException
152     */
153    protected function renewAccessToken(): void
154    {
155        $params = [
156            'client_id' => $this->config['API']['oauth_id'],
157            'grant_type' => 'password',
158            'database' => $this->config['API']['database'],
159            'username' => $this->config['API']['username'],
160            'password' => $this->config['API']['password'],
161        ];
162        $headers = [
163            'Accept: application/json',
164        ];
165        $response = $this->makeRequest('POST', '/_oauth/token', $params, $headers);
166        $result = json_decode($response->getBody());
167        if (!isset($result->access_token)) {
168            throw new ILSException('No access token in API response.');
169        }
170        $this->token = $this->sessionCache->genieplus_token = $result->access_token;
171    }
172
173    /**
174     * Call the API, with an access token added to the headers; renew token as
175     * needed.
176     *
177     * @param string $method  GET/POST/PUT/DELETE/etc
178     * @param string $path    API path (with a leading /)
179     * @param array  $params  Parameters object to be sent as data
180     * @param array  $headers Additional headers
181     *
182     * @return \Laminas\Http\Response
183     */
184    protected function callApiWithToken(
185        $method = 'GET',
186        $path = '/',
187        $params = [],
188        $headers = []
189    ) {
190        $headers[] = 'Accept: application/json';
191        if (null === $this->token) {
192            $this->renewAccessToken();
193        }
194        $authHeader = "Authorization: Bearer {$this->token}";
195        $response = $this->makeRequest(
196            $method,
197            $path,
198            $params,
199            array_merge($headers, [$authHeader]),
200            [401, 403]
201        );
202        if ($response->getStatusCode() > 400) {
203            $this->renewAccessToken();
204            $authHeader = "Authorization: Bearer {$this->token}";
205            $response = $this->makeRequest(
206                $method,
207                $path,
208                $params,
209                array_merge($headers, [$authHeader])
210            );
211        }
212        return $response;
213    }
214
215    /**
216     * Extract a field from an API response.
217     *
218     * @param array  $record Record containing field
219     * @param string $field  Name of field to extract
220     * @param string $type   Type of field being looked up (e.g. Item, Patron)
221     *
222     * @return array
223     */
224    protected function getFieldFromApiRecord($record, $field, $type = 'Item')
225    {
226        $fieldName = $this->config[$type]['field'][$field] ?? '';
227        return $record[$fieldName] ?? [];
228    }
229
230    /**
231     * Extract display values from an API response field.
232     *
233     * @param array $field Array of values from API
234     *
235     * @return array
236     */
237    protected function extractDisplayValues($field): array
238    {
239        $callback = function ($value) {
240            return $value['display'];
241        };
242        return array_map($callback, $field);
243    }
244
245    /**
246     * Extract holdings data from an API response. Return an array of arrays
247     * representing 852 fields (indexed by subfield code).
248     *
249     * @param array $record Record from API response
250     *
251     * @return array
252     */
253    protected function apiStatusRecordToArray($record): array
254    {
255        $bibId = current(
256            $this->extractDisplayValues(
257                $this->getFieldFromApiRecord($record, 'id')
258            )
259        );
260        $barcodes = $this->extractDisplayValues(
261            $this->getFieldFromApiRecord($record, 'barcode')
262        );
263        $callNos = $this->extractDisplayValues(
264            $this->getFieldFromApiRecord($record, 'callnumber')
265        );
266        $dueDates = $this->extractDisplayValues(
267            $this->getFieldFromApiRecord($record, 'duedate')
268        );
269        $locations = $this->extractDisplayValues(
270            $this->getFieldFromApiRecord($record, 'location')
271        );
272        $statuses = $this->extractDisplayValues(
273            $this->getFieldFromApiRecord($record, 'status')
274        );
275        $volumes = $this->extractDisplayValues(
276            $this->getFieldFromApiRecord($record, 'volume')
277        );
278        $total = max(
279            [
280                count($barcodes),
281                count($callNos),
282                count($dueDates),
283                count($locations),
284                count($statuses),
285                count($volumes),
286            ]
287        );
288        $result = [];
289        for ($i = 0; $i < $total; $i++) {
290            $availability = in_array($statuses[$i] ?? '', $this->availableStatuses)
291                ? 1 : 0;
292            $result[] = [
293                'id' => $bibId,
294                'availability' => $availability,
295                'status' => $statuses[$i] ?? '',
296                'location' => $locations[$i] ?? '',
297                'reserve' => 'N', // not supported
298                'callnumber' => $callNos[$i] ?? '',
299                'duedate' => $dueDates[$i] ?? '',
300                'number' => $volumes[$i] ?? ($i + 1),
301                'barcode' => $barcodes[$i] ?? '',
302            ];
303        }
304        $sortParts = array_map(
305            'trim',
306            explode(
307                ' ',
308                strtolower($this->config['Item']['sort'] ?? 'none')
309            )
310        );
311        $sortField = $sortParts[0];
312        if ($sortField !== 'none') {
313            $sortDirection = ($sortParts[1] ?? 'asc') === 'asc' ? 1 : -1;
314            $callback = function ($a, $b) use ($sortField, $sortDirection) {
315                return strnatcmp($a[$sortField] ?? '', $b[$sortField] ?? '')
316                    * $sortDirection;
317            };
318            usort($result, $callback);
319        }
320        return $result;
321    }
322
323    /**
324     * Get the search path to query a template.
325     *
326     * @param string $template Name of template to query
327     *
328     * @return string
329     */
330    protected function getTemplateQueryPath(string $template): string
331    {
332        $database = $this->config['API']['database'];
333        return "/_rest/databases/$database/templates/$template/search-result";
334    }
335
336    /**
337     * Sanitize a value for inclusion as a single-quoted value in a query string.
338     *
339     * @param string $value Value to sanitize
340     *
341     * @return string       Sanitized value
342     */
343    protected function sanitizeQueryParam(string $value): string
344    {
345        // The query language used by GeniePlus doubles quotes to escape them.
346        return str_replace("'", "''", $value);
347    }
348
349    /**
350     * Get Status
351     *
352     * This is responsible for retrieving the status information of a certain
353     * record.
354     *
355     * @param string $id The record id to retrieve the holdings for
356     *
357     * @return mixed     On success, an associative array with the following keys:
358     * id, availability (boolean), status, location, reserve, callnumber.
359     */
360    public function getStatus($id)
361    {
362        $template = $this->config['API']['catalog_template'];
363        $path = $this->getTemplateQueryPath($template);
364        $idField = $this->config['Item']['field']['id'] ?? 'UniqRecNum';
365        $safeId = $this->sanitizeQueryParam($id);
366        $params = [
367            'page-size' => 100,
368            'page' => 0,
369            'fields' => implode(',', $this->config['Item']['field'] ?? []),
370            'command' => "$idField == '$safeId'",
371        ];
372        $json = $this->callApiWithToken('GET', $path, $params)->getBody();
373        $response = json_decode($json, true);
374        return $this->apiStatusRecordToArray($response['records'][0] ?? []);
375    }
376
377    /**
378     * Get Statuses
379     *
380     * This is responsible for retrieving the status information for a
381     * collection of records.
382     *
383     * @param array $ids The array of record ids to retrieve the status for
384     *
385     * @return mixed     An array of getStatus() return values on success.
386     */
387    public function getStatuses($ids)
388    {
389        return array_map([$this, 'getStatus'], $ids);
390    }
391
392    /**
393     * Get Holding
394     *
395     * This is responsible for retrieving the holding information of a certain
396     * record.
397     *
398     * @param string $id      The record id to retrieve the holdings for
399     * @param array  $patron  Patron data
400     * @param array  $options Extra options (not currently used)
401     *
402     * @return mixed     On success, an associative array with the following keys:
403     * id, availability (boolean), status, location, reserve, callnumber, duedate,
404     * number, barcode.
405     *
406     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
407     */
408    public function getHolding($id, array $patron = null, array $options = [])
409    {
410        return $this->getStatus($id);
411    }
412
413    /**
414     * Get Purchase History
415     *
416     * This is responsible for retrieving the acquisitions history data for the
417     * specific record (usually recently received issues of a serial).
418     *
419     * @param string $id The record id to retrieve the info for
420     *
421     * @return mixed     An array with the acquisitions data on success.
422     *
423     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
424     */
425    public function getPurchaseHistory($id)
426    {
427        // Not supported here:
428        return [];
429    }
430
431    /**
432     * Public Function which retrieves feature-specific settings from the
433     * driver ini file.
434     *
435     * @param string $function The name of the feature to be checked
436     * @param array  $params   Optional feature-specific parameters (array)
437     *
438     * @return array An array with key-value pairs.
439     *
440     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
441     */
442    public function getConfig($function, $params = [])
443    {
444        if ('getMyTransactions' === $function) {
445            return $this->config['Transactions'] ?? [
446                'max_results' => 100,
447            ];
448        }
449
450        return false;
451    }
452
453    /**
454     * Patron Login
455     *
456     * This is responsible for authenticating a patron against the catalog.
457     *
458     * @param string $username The patron username
459     * @param string $password The patron password
460     *
461     * @throws ILSException
462     * @return mixed           Associative array of patron info on successful login,
463     * null on unsuccessful login.
464     */
465    public function patronLogin($username, $password)
466    {
467        $template = $this->config['API']['patron_template'];
468        $path = $this->getTemplateQueryPath($template);
469        $userField = $this->config['Patron']['field']['cat_username'] ?? 'Email';
470        $passField = $this->config['Patron']['field']['cat_password'];
471        $safeUser = $this->sanitizeQueryParam($username);
472        $safePass = $this->sanitizeQueryParam($password);
473        $idField = $this->config['Patron']['field']['id'] ?? 'ID';
474        $nameField = $this->config['Patron']['field']['name'] ?? 'Name';
475        $emailField = $this->config['Patron']['field']['email'] ?? 'Email';
476        $params = [
477            'page-size' => 1,
478            'page' => 0,
479            'fields' => implode(',', [$idField, $nameField, $emailField]),
480            'command' => "$userField == '$safeUser' AND $passField == '$safePass'",
481        ];
482        $json = $this->callApiWithToken('GET', $path, $params)->getBody();
483        $response = json_decode($json, true);
484        $user = $response['records'][0] ?? [];
485        if (empty($user)) {
486            return null;
487        }
488        $id = current(
489            $this->extractDisplayValues(
490                $this->getFieldFromApiRecord($user, 'id', 'Patron')
491            )
492        );
493        $email = current(
494            $this->extractDisplayValues(
495                $this->getFieldFromApiRecord($user, 'email', 'Patron')
496            )
497        );
498        $name = current(
499            $this->extractDisplayValues(
500                $this->getFieldFromApiRecord($user, 'name', 'Patron')
501            )
502        );
503        [$last, $first] = explode(',', $name, 2);
504        return [
505            'id'           => $id,
506            'firstname'    => trim($first),
507            'lastname'     => trim($last),
508            'cat_username' => trim($username),
509            'cat_password' => trim($password),
510            'email'        => $email,
511            'major'        => null,
512            'college'      => null,
513        ];
514    }
515
516    /**
517     * Get Patron Profile
518     *
519     * This is responsible for retrieving the profile for a specific patron.
520     *
521     * @param array $patron The patron array
522     *
523     * @return array        Array of the patron's profile data on success.
524     */
525    public function getMyProfile($patron)
526    {
527        $template = $this->config['API']['patron_template'];
528        $path = $this->getTemplateQueryPath($template);
529        $idField = $this->config['Patron']['field']['id'] ?? 'ID';
530        $safeId = $this->sanitizeQueryParam($patron['id']);
531        $fields = [
532            $this->config['Patron']['field']['address1'] ?? 'Address1',
533            $this->config['Patron']['field']['address2'] ?? 'Address2',
534            $this->config['Patron']['field']['zip'] ?? 'ZipCode',
535            $this->config['Patron']['field']['city'] ?? 'City',
536            $this->config['Patron']['field']['state'] ?? 'StateProv.CodeDesc',
537            $this->config['Patron']['field']['country'] ?? 'Country.CodeDesc',
538            $this->config['Patron']['field']['phone'] ?? 'PhoneNumber',
539            $this->config['Patron']['field']['expiration_date'] ?? 'ExpiryDate',
540        ];
541        $params = [
542            'page-size' => 1,
543            'page' => 0,
544            'fields' => implode(',', $fields),
545            'command' => "$idField == '$safeId'",
546        ];
547        $json = $this->callApiWithToken('GET', $path, $params)->getBody();
548        $response = json_decode($json, true);
549        $user = $response['records'][0] ?? [];
550        if (empty($user)) {
551            throw new \Exception("Unable to fetch patron $safeId");
552        }
553        $addr1 = current(
554            $this->extractDisplayValues(
555                $this->getFieldFromApiRecord($user, 'address1', 'Patron')
556            )
557        );
558        $addr2 = current(
559            $this->extractDisplayValues(
560                $this->getFieldFromApiRecord($user, 'address2', 'Patron')
561            )
562        );
563        $zip = current(
564            $this->extractDisplayValues(
565                $this->getFieldFromApiRecord($user, 'zip', 'Patron')
566            )
567        );
568        $city = current(
569            $this->extractDisplayValues(
570                $this->getFieldFromApiRecord($user, 'city', 'Patron')
571            )
572        );
573        $state = current(
574            $this->extractDisplayValues(
575                $this->getFieldFromApiRecord($user, 'state', 'Patron')
576            )
577        );
578        $country = current(
579            $this->extractDisplayValues(
580                $this->getFieldFromApiRecord($user, 'country', 'Patron')
581            )
582        );
583        $phone = current(
584            $this->extractDisplayValues(
585                $this->getFieldFromApiRecord($user, 'phone', 'Patron')
586            )
587        );
588        $expirationDate = current(
589            $this->extractDisplayValues(
590                $this->getFieldFromApiRecord($user, 'expiration_date', 'Patron')
591            )
592        );
593        $cityAndState = trim($city . (!empty($city) ? ', ' : '') . $state);
594        return [
595            'firstname'       => $patron['firstname'],
596            'lastname'        => $patron['lastname'],
597            'address1'        => empty($addr1) ? null : $addr1,
598            'address2'        => empty($addr2) ? null : $addr2,
599            'zip'             => empty($zip) ? null : $zip,
600            'city'            => empty($city) ? null : $cityAndState,
601            'country'         => empty($country) ? null : $country,
602            'phone'           => empty($phone) ? null : $phone,
603            'expiration_date' => empty($expirationDate) ? null : $expirationDate,
604        ];
605    }
606
607    /**
608     * Get Patron Transactions
609     *
610     * This is responsible for retrieving all transactions (i.e. checked out items)
611     * by a specific patron.
612     *
613     * @param array $patron The patron array from patronLogin
614     * @param array $params Parameters
615     *
616     * @return mixed        Array of the patron's transactions on success.
617     */
618    public function getMyTransactions($patron, $params = [])
619    {
620        $patronTemplate = $this->config['API']['patron_template'];
621        $loanTemplate = $this->config['API']['loan_template'];
622        $path = $this->getTemplateQueryPath($loanTemplate);
623        $idField = $patronTemplate . '.'
624            . ($this->config['Patron']['field']['id'] ?? 'ID');
625        $safeId = $this->sanitizeQueryParam($patron['id']);
626        $barcodeField = $this->config['Item']['field']['barcode']
627            ?? 'Inventory.Barcode';
628        $bibIdField = $this->config['Loan']['field']['bib_id']
629            ?? 'Inventory.Inventory@Catalog.UniqRecNum';
630        $dueField = $this->config['Loan']['field']['duedate'] ?? 'ClaimDate';
631        $archiveField = $this->config['Loan']['field']['archive'] ?? 'Archive';
632        $fields = [$barcodeField, $bibIdField, $dueField];
633        $params = [
634            'page-size' => $params['limit'] ?? 100,
635            'page' => ($params['page'] ?? 1) - 1,
636            'fields' => implode(',', $fields),
637            'command' => "$idField == '$safeId' AND $archiveField == 'No'",
638        ];
639        $json = $this->callApiWithToken('GET', $path, $params)->getBody();
640        $response = json_decode($json, true);
641        $callback = function ($entry) use ($barcodeField, $bibIdField, $dueField) {
642            return [
643                'id' => $entry[$bibIdField][0]['display'] ?? null,
644                'item_id' => $entry[$barcodeField][0]['display'] ?? null,
645                'duedate' => $entry[$dueField][0]['display'] ?? null,
646            ];
647        };
648        return [
649            'count' => $response['total'] ?? 0,
650            'records' => array_map($callback, $response['records'] ?? []),
651        ];
652    }
653}