Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 886
0.00% covered (danger)
0.00%
0 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
OverdriveConnector
0.00% covered (danger)
0.00%
0 / 886
0.00% covered (danger)
0.00%
0 / 37
46010
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getSessionContainer
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getUser
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getAccess
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 getAvailability
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
90
 getAvailabilityBulk
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
272
 getCollectionToken
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 doOverdriveCheckout
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
56
 placeOverDriveHold
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 updateOverDriveHold
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 suspendHold
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 editSuspendedHold
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 deleteHoldSuspension
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 cancelHold
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 returnResource
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 getDownloadRedirect
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 getAuthHeader
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 getConfig
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
12
 getFormatNames
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 getPermanentLinks
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getMagazineIssues
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 getMetadata
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 getMetadataForTitles
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getCheckout
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getHold
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getCheckouts
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
110
 getHolds
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
132
 callUrl
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
132
 connectToAPI
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
90
 callPatronUrl
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
240
 connectToPatronAPI
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
132
 getHttpClient
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 setCacheStorage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCachedData
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 putCachedData
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 removeCachedData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getResultObject
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * PHP version 8
5 *
6 * Copyright (C) Villanova University 2018.
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License version 2,
10 * as published by the Free Software Foundation.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License
18 * along with this program; if not, write to the Free Software
19 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301
20 * USA
21 *
22 * @category VuFind
23 * @package  DigitalContent
24 * @author   Demian Katz <demian.katz@villanova.edu>
25 * @author   Brent Palmer <brent-palmer@icpl.org>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public
27 *           License
28 * @link     https://vufind.org/wiki/development Wiki
29 */
30
31namespace VuFind\DigitalContent;
32
33use Exception;
34use Laminas\Cache\Storage\StorageInterface;
35use Laminas\Config\Config;
36use Laminas\Http\Client;
37use Laminas\Log\LoggerAwareInterface;
38use Laminas\Session\Container;
39use LmcRbacMvc\Service\AuthorizationServiceAwareInterface;
40use LmcRbacMvc\Service\AuthorizationServiceAwareTrait;
41use VuFind\Auth\ILSAuthenticator;
42use VuFind\Cache\KeyGeneratorTrait;
43use VuFind\Exception\ILS as ILSException;
44
45use function count;
46
47/**
48 * OverdriveConnector
49 *
50 * Class responsible for connecting to the OverDrive API
51 *
52 * @category VuFind
53 * @package  DigitalContent
54 * @author   Demian Katz <demian.katz@villanova.edu>
55 * @author   Brent Palmer <brent-palmer@icpl.org>
56 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public
57 *           License
58 * @link     https://vufind.org/wiki/development Wiki
59 * @todo     provide option for autocheckout by default in config
60 *       allow override for cover display using other covers
61 *       provide option for not requiring email for holds
62 *       provide option for giving users option for every hold
63 *       provide option for asking about autocheckout for every hold
64 *       provide config options for how to handle patrons with no access to OD
65 */
66class OverdriveConnector implements
67    LoggerAwareInterface,
68    AuthorizationServiceAwareInterface,
69    \VuFindHttp\HttpServiceAwareInterface
70{
71    use \VuFind\Log\LoggerAwareTrait {
72        logError as error;
73    }
74    use AuthorizationServiceAwareTrait;
75    use \VuFindHttp\HttpServiceAwareTrait;
76    use KeyGeneratorTrait;
77
78    /**
79     * Session Container
80     *
81     * @var Container
82     */
83    protected $sessionContainer;
84
85    /**
86     * OverDrive-specific configuration
87     *
88     * @var Config
89     */
90    protected $recordConfig;
91
92    /**
93     * Main VuFind configuration
94     *
95     * @var Config
96     */
97    protected $mainConfig;
98
99    /**
100     * ILS Authorization
101     *
102     * @var ILSAuthenticator
103     */
104    protected $ilsAuth;
105
106    /**
107     * HTTP Client
108     *
109     * Client for making calls to the API
110     *
111     * @var Client
112     */
113    protected $client;
114
115    /**
116     * Cache for storing ILS data temporarily (e.g. patron blocks)
117     *
118     * @var StorageInterface
119     */
120    protected $cache = null;
121
122    /**
123     * Constructor
124     *
125     * @param Config           $mainConfig       VuFind main conf
126     * @param Config           $recordConfig     Record-specific conf file
127     * @param ILSAuthenticator $ilsAuth          ILS Authenticator
128     * @param Container        $sessionContainer container
129     */
130    public function __construct(
131        Config $mainConfig,
132        Config $recordConfig,
133        ILSAuthenticator $ilsAuth,
134        Container $sessionContainer = null
135    ) {
136        $this->mainConfig = $mainConfig;
137        $this->recordConfig = $recordConfig;
138        $this->ilsAuth = $ilsAuth;
139        $this->sessionContainer = $sessionContainer;
140    }
141
142    /**
143     * Loads the session container
144     *
145     * @return \Laminas\Session\Container
146     */
147    protected function getSessionContainer()
148    {
149        if (null === $this->sessionContainer || !$this->sessionContainer) {
150            error_log('NO SESSION CONTAINER');
151        }
152        return $this->sessionContainer;
153    }
154
155    /**
156     * Get (Logged-in) User
157     *
158     * Returns the currently logged in user or false if the user is not
159     *
160     * @return array|boolean  an array of user info from the ILSAuthenticator
161     *                        or false if user is not logged in.
162     */
163    public function getUser()
164    {
165        try {
166            $user = $this->ilsAuth->storedCatalogLogin();
167        } catch (ILSException $e) {
168            return false;
169        }
170        return $user;
171    }
172
173    /**
174     * Get OverDrive Access
175     *
176     * Whether the patron has access to overdrive actions (hold,
177     * checkout etc.).
178     * This is stored and retrieved from the session.
179     *
180     * @param bool $refresh Force a check instead of checking cache
181     *
182     * @return object
183     */
184    public function getAccess($refresh = false)
185    {
186        if (!$user = $this->getUser()) {
187            return $this->getResultObject(false, 'User not logged in.');
188        }
189
190        $odAccess = $this->getSessionContainer()->odAccess;
191        if ($refresh || empty($odAccess)) {
192            if (
193                $this->connectToPatronAPI(
194                    $user['cat_username'],
195                    $user['cat_password'],
196                    true
197                )
198            ) {
199                $result = $this->getSessionContainer()->odAccess
200                    = $this->getResultObject(true);
201            } else {
202                $result = $this->getResultObject();
203                // There is some problem with the account
204                $result->code = 'od_account_problem';
205                $conf = $this->getConfig();
206
207                if ($conf->noAccessString) {
208                    if (
209                        str_contains(
210                            $this->getSessionContainer()->odAccessMessage,
211                            $conf->noAccessString
212                        )
213                    ) {
214                        // This user should not have access to OD
215                        $result->code = 'od_account_noaccess';
216                    }
217                }
218                // odAccessMessage is set in the session by the API call above
219                // maybe it should be saved to a class property instead
220                $result->msg = $this->getSessionContainer()->odAccessMessage;
221                $this->getSessionContainer()->odAccess = $result;
222            }
223        } else {
224            $result = $this->getSessionContainer()->odAccess;
225        }
226
227        return $result;
228    }
229
230    /**
231     * Get Availability
232     *
233     * Retrieves the availability for a single resource from OverDrive API
234     * with information like copiesOwned, copiesAvailable, numberOfHolds et.
235     *
236     * @param string $overDriveId The OverDrive ID (reserve ID) of the eResource
237     *
238     * @return object  Standard object with availability info
239     *
240     * @link https://developer.overdrive.com/apis/library-availability-new
241     */
242    public function getAvailability($overDriveId)
243    {
244        $result = $this->getResultObject();
245        if (!$overDriveId) {
246            $this->logWarning('no overdrive content ID was passed in.');
247            return $result;
248        }
249
250        if ($conf = $this->getConfig()) {
251            $collectionToken = $this->getCollectionToken();
252            // Hmm, no token. If user is logged in let's check access
253            if (!$collectionToken && $this->getUser()) {
254                $accessResult = $this->getAccess();
255                if (!$accessResult->status) {
256                    return $accessResult;
257                }
258            }
259            $baseUrl = $conf->discURL;
260            $availabilityUrl
261                = "$baseUrl/v2/collections/$collectionToken/products/";
262            $availabilityUrl .= "$overDriveId/availability";
263            $res = $this->callUrl($availabilityUrl);
264
265            if (($res->errorCode ?? '') == 'NotFound') {
266                if ($conf->consortiumSupport && !$this->getUser()) {
267                    // Consortium support is turned on but user is not logged in;
268                    // if the title is not found it probably means that it's only
269                    // available to some users.
270                    $result->status = true;
271                    $result->code = 'od_code_login_for_avail';
272                } else {
273                    $result->status = false;
274                    $result->code = 'od_code_resource_not_found';
275                    $this->logWarning("resource not found: $overDriveId");
276                }
277            } else {
278                $result->status = true;
279                $res->copiesAvailable ??= 0;
280                $res->copiesOwned ??= 0;
281                $res->numberOfHolds ??= 0;
282                $res->code = 'od_none';
283                $result->data = $res;
284            }
285        }
286
287        return $result;
288    }
289
290    /**
291     * Get Availability (in) Bulk
292     *
293     * Gets availability for up to 25 titles at once. This is used by the
294     * the ajax availability system
295     *
296     * @param array $overDriveIds The OverDrive ID (reserve IDs) of the
297     *                            eResources
298     *
299     * @return object|bool see getAvailability
300     *
301     * @todo if more than 25 passed in, make multiple calls
302     */
303    public function getAvailabilityBulk($overDriveIds = [])
304    {
305        $result = $this->getResultObject();
306        $loginRequired = false;
307        if (count($overDriveIds) < 1) {
308            $this->logWarning('no overdrive content ID was passed in.');
309            return false;
310        }
311
312        if ($conf = $this->getConfig()) {
313            if ($conf->consortiumSupport && !$this->getUser()) {
314                $loginRequired = true;
315            }
316            $collectionToken = $this->getCollectionToken();
317            // Hmm, no token. If user is logged in let's check access
318            if (!$collectionToken && $this->getUser()) {
319                $accessResult = $this->getAccess();
320                if (!$accessResult->status) {
321                    return $accessResult;
322                }
323            }
324            $baseUrl = $conf->discURL;
325            $availabilityPath = '/v2/collections/';
326            $availabilityPath .= "$collectionToken/availability?products=";
327            $availabilityUrl = $baseUrl . $availabilityPath .
328                implode(',', $overDriveIds);
329            $res = $this->callUrl($availabilityUrl);
330            if (!$res) {
331                $result->code = 'od_code_connection_failed';
332            } else {
333                if (($res->errorCode ?? null) == 'NotFound' || $res->totalItems == 0) {
334                    if ($loginRequired) {
335                        // Consortium support is turned on but user is not logged in.
336                        // If the title is not found it could mean that it's only
337                        // available to some users.
338                        $result->status = true;
339                        $result->code = 'od_code_login_for_avail';
340                    } else {
341                        $result->status = false;
342                        $result->code = 'od_code_resource_not_found';
343                        $this->logWarning('resources not found');
344                    }
345                } else {
346                    $result->status = true;
347                    foreach ($res->availability as $item) {
348                        $item->copiesAvailable ??= 0;
349                        $item->copiesOwned ??= 0;
350                        $item->numberOfHolds ??= 0;
351                        $result->data[strtolower($item->reserveId)] = $item;
352                        $res->code = 'od_none';
353                    }
354                    // Now look for items not returned
355                    foreach ($overDriveIds as $id) {
356                        if (!isset($result->data[$id])) {
357                            if ($loginRequired) {
358                                $result->data[$id] = $this->getResultObject(
359                                    $status = false,
360                                    $msg = '',
361                                    $code = 'od_code_login_for_avail'
362                                );
363                            } else {
364                                $result->data[$id] = $this->getResultObject(
365                                    $status = false,
366                                    $msg = '',
367                                    $code = 'od_code_resource_not_found'
368                                );
369                            }
370                        }
371                    }
372                }
373            }
374        }
375        return $result;
376    }
377
378    /**
379     * Get Collection Token
380     *
381     * Gets the collection token for the OverDrive collection. The collection
382     * token doesn't change much but according to
383     * the OD API docs it could change and should be retrieved each session.
384     * Also, the collection token depends on the user if the user is in a
385     * consortium. If consortium support is turned on then the user collection
386     * token will override the library collection token.
387     * The token itself is returned but it's also saved in the session and
388     * automatically returned.
389     *
390     * @return object|bool A collection token for the library's collection.
391     */
392    public function getCollectionToken()
393    {
394        $collectionToken = $this->getCachedData('collectionToken');
395        $userCollectionToken = $this->getSessionContainer(
396        )->userCollectionToken;
397        $conf = $this->getConfig();
398        if ($conf->consortiumSupport && $conf->usePatronAPI && $user = $this->getUser()) {
399            if (empty($userCollectionToken)) {
400                $baseUrl = $conf->circURL;
401                $patronURL = "$baseUrl/v1/patrons/me";
402                $res = $this->callPatronUrl(
403                    $user['cat_username'],
404                    $user['cat_password'],
405                    $patronURL
406                );
407                if ($res) {
408                    $userCollectionToken = $res->collectionToken;
409                    $this->getSessionContainer()->userCollectionToken
410                        = $userCollectionToken;
411                } else {
412                    return false;
413                }
414            }
415            return $userCollectionToken;
416        }
417        if (empty($collectionToken)) {
418            $this->debug('getting new collectionToken');
419            $baseUrl = $conf->discURL;
420            $libraryID = $conf->libraryID;
421            $libraryURL = "$baseUrl/v1/libraries/$libraryID";
422            $res = $this->callUrl($libraryURL);
423            if ($res) {
424                $collectionToken = $res->collectionToken;
425                $this->debug("OD collectionToken: $collectionToken");
426                $this->putCachedData('collectionToken', $collectionToken);
427            } else {
428                return false;
429            }
430        }
431        return $collectionToken;
432    }
433
434    /**
435     * OverDrive Checkout
436     * Processes a request to checkout a title from OverDrive
437     *
438     * @param string $overDriveId The overdrive id for the title
439     *
440     * @return object $result Results of the call.
441     */
442    public function doOverdriveCheckout($overDriveId)
443    {
444        $result = $this->getResultObject();
445
446        $this->debug('OverDriveConnector: doOverdriveCheckout|overdriveID: ' . $overDriveId);
447        if (!$user = $this->getUser()) {
448            $this->error('Checkout - user is not logged in', [], true);
449            return $result;
450        }
451        if ($config = $this->getConfig()) {
452            if (!$config->usePatronAPI) {
453                $this->error('Checkout - OverDrive patron APIs are disabled.');
454                return $result;
455            }
456            $url = $config->circURL . '/v1/patrons/me/checkouts';
457            $params = [
458                'reserveId' => $overDriveId,
459            ];
460
461            $response = $this->callPatronUrl(
462                $user['cat_username'],
463                $user['cat_password'],
464                $url,
465                $params,
466                'POST'
467            );
468
469            if (!empty($response)) {
470                if (isset($response->reserveId)) {
471                    $expires = '';
472                    if ($dt = new \DateTime($response->expires)) {
473                        $expires = $dt->format(
474                            (string)$config->displayDateFormat
475                        );
476                    }
477                    $result->status = true;
478                    $result->data = (object)[];
479                    $result->data->expires = $expires;
480                    $result->data->formats = $response->formats;
481                    // Add the checkout to the session cache
482                    $this->getSessionContainer()->checkouts[] = $response;
483                } else {
484                    $result->msg = $response->message;
485                }
486            } else {
487                $result->code = 'od_code_connection_failed';
488            }
489        }
490        return $result;
491    }
492
493    /**
494     * Places a hold on an item within OverDrive
495     *
496     * @param string $overDriveId The overdrive id for the title
497     * @param string $email       The email overdrive should use for notification
498     *
499     * @return \stdClass Object with result
500     */
501    public function placeOverDriveHold($overDriveId, $email)
502    {
503        $overDriveId = strtoupper($overDriveId);
504        $this->debug('OverDriveConnector: placeOverDriveHold: OverDriveID: $overDriveId, Email: $email');
505        $holdResult = $this->getResultObject();
506
507        if (!$user = $this->getUser()) {
508            $this->error('user is not logged in (hold)', [], true);
509            return $holdResult;
510        }
511
512        if ($config = $this->getConfig()) {
513            if (!$config->usePatronAPI) {
514                $this->error('Place hold - OverDrive patron APIs are disabled.');
515                return $holdResult;
516            }
517            $ignoreHoldEmail = false;
518            $url = $config->circURL . '/v1/patrons/me/holds';
519            $action = 'POST';
520            $params = [
521                'reserveId' => $overDriveId,
522                'emailAddress' => $email,
523                'ignoreHoldEmail' => $ignoreHoldEmail,
524            ];
525
526            $response = $this->callPatronUrl(
527                $user['cat_username'],
528                $user['cat_password'],
529                $url,
530                $params,
531                $action
532            );
533
534            if (!empty($response)) {
535                if (isset($response->holdListPosition)) {
536                    $holdResult->status = true;
537                    $holdResult->data = (object)[];
538                    $holdResult->data->holdListPosition
539                        = $response->holdListPosition;
540                } else {
541                    $holdResult->msg = $response->message;
542                }
543            } else {
544                $holdResult->code = 'od_code_connection_failed';
545            }
546        }
547        return $holdResult;
548    }
549
550    /**
551     * Updates the email address for a hold on an item within OverDrive
552     *
553     * @param string $overDriveId The overdrive id for the title
554     * @param string $email       The email overdrive should use for notif
555     *
556     * @return \stdClass Object with result
557     */
558    public function updateOverDriveHold($overDriveId, $email)
559    {
560        $overDriveId = strtoupper($overDriveId);
561        $this->debug('OverDriveConnector: updateOverDriveHold: OverDriveID: $overDriveId, Email: $email');
562        $holdResult = $this->getResultObject();
563
564        if (!$user = $this->getUser()) {
565            $this->error('user is not logged in (upd hold)', [], true);
566            return $holdResult;
567        }
568
569        if ($config = $this->getConfig()) {
570            if (!$config->usePatronAPI) {
571                $this->error('Update hold - OverDrive patron APIs are disabled.');
572                return $holdResult;
573            }
574            $autoCheckout = true;
575            $ignoreHoldEmail = false;
576            $url = $config->circURL . '/v1/patrons/me/holds/' . $overDriveId;
577            $action = 'PUT';
578            $params = [
579                'reserveId' => $overDriveId,
580                'emailAddress' => $email,
581            ];
582
583            $response = $this->callPatronUrl(
584                $user['cat_username'],
585                $user['cat_password'],
586                $url,
587                $params,
588                $action
589            );
590
591            // because this is a PUT Call, we are just looking for a boolean
592            if ($response) {
593                $holdResult->status = true;
594            } else {
595                $holdResult->msg = $response->message;
596            }
597        }
598        return $holdResult;
599    }
600
601    /**
602     * Suspend Hold
603     * Suspend an existing OverDrive Hold
604     *
605     * @param string $overDriveId    The overdrive id for the title
606     * @param string $email          The email overdrive should use for notif
607     * @param string $suspensionType indefinite or limited
608     * @param int    $numberOfDays   number of days to suspend the hold
609     *
610     * @return \stdClass Object with result
611     */
612    public function suspendHold($overDriveId, $email, $suspensionType = 'indefinite', $numberOfDays = 7)
613    {
614        $holdResult = $this->getResultObject();
615        $this->debug("suspendHold: OverDriveID:$overDriveId|Email:$email|suspensionType:$suspensionType");
616
617        if (!$user = $this->getUser()) {
618            $this->error('user is not logged in (susp hold)', [], true);
619            return $holdResult;
620        }
621        if ($config = $this->getConfig()) {
622            if (!$config->usePatronAPI) {
623                $this->error('Suspend hold - OverDrive patron APIs are disabled.');
624                return $holdResult;
625            }
626            $url = $config->circURL . "/v1/patrons/me/holds/$overDriveId/suspension";
627            $action = 'POST';
628            $params = [
629                'emailAddress' => $email,
630                'suspensionType' => $suspensionType,
631            ];
632            if ($suspensionType == 'limited') {
633                $params['numberOfDays'] = $numberOfDays;
634            }
635
636            $response = $this->callPatronUrl(
637                $user['cat_username'],
638                $user['cat_password'],
639                $url,
640                $params,
641                $action
642            );
643
644            if (!empty($response)) {
645                if (isset($response->holdListPosition)) {
646                    $holdResult->status = true;
647                    $holdResult->data = $response;
648                } else {
649                    $holdResult->msg = $response->message;
650                }
651            } else {
652                $holdResult->code = 'od_code_connection_failed';
653            }
654        }
655        return $holdResult;
656    }
657
658    /**
659     * Edit Suspended Hold
660     * Change the redelivery date on an already suspended hold
661     *
662     * @param string $overDriveId    The overdrive id for the title
663     * @param string $email          The email overdrive should use for notif
664     * @param string $suspensionType indefinite or limited
665     * @param int    $numberOfDays   number of days to suspend the hold
666     *
667     * @return \stdClass Object with result
668     */
669    public function editSuspendedHold($overDriveId, $email, $suspensionType = 'indefinite', $numberOfDays = 7)
670    {
671        $holdResult = $this->getResultObject();
672
673        if (!$user = $this->getUser()) {
674            $this->error('user is not logged in (edit susp hold)', [], true);
675            return $holdResult;
676        }
677        if ($config = $this->getConfig()) {
678            if (!$config->usePatronAPI) {
679                $this->error('Edit suspended hold - OverDrive patron APIs are disabled.');
680                return $holdResult;
681            }
682            $url = $config->circURL . "/v1/patrons/me/holds/$overDriveId/suspension";
683            $action = 'PUT';
684            $params = [
685                'emailAddress' => $email,
686                'suspensionType' => $suspensionType,
687            ];
688            if ($suspensionType == 'limited') {
689                $params['numberOfDays'] = $numberOfDays;
690            }
691
692            $response = $this->callPatronUrl(
693                $user['cat_username'],
694                $user['cat_password'],
695                $url,
696                $params,
697                $action
698            );
699
700            // because this is a PUT Call, we are just looking for a boolean
701            if ($response) {
702                $holdResult->status = true;
703            } else {
704                $holdResult->msg = $response->message;
705            }
706        }
707        return $holdResult;
708    }
709
710    /**
711     * Delete Suspended Hold
712     * Removes the suspension from a hold
713     *
714     * @param string $overDriveId The overdrive id for the title
715     *
716     * @return \stdClass Object with result
717     */
718    public function deleteHoldSuspension($overDriveId)
719    {
720        $holdResult = $this->getResultObject();
721
722        if (!$user = $this->getUser()) {
723            $this->error('user is not logged in (del hold susp)', [], true);
724            return $holdResult;
725        }
726        if ($config = $this->getConfig()) {
727            if (!$config->usePatronAPI) {
728                $this->error('Delete hold suspension - OverDrive patron APIs are disabled.');
729                return $holdResult;
730            }
731            $url = $config->circURL . "/v1/patrons/me/holds/$overDriveId/suspension";
732            $action = 'DELETE';
733            $response = $this->callPatronUrl(
734                $user['cat_username'],
735                $user['cat_password'],
736                $url,
737                null,
738                $action
739            );
740
741            // because this is a DELETE Call, we are just looking for a boolean
742            if ($response) {
743                $holdResult->status = true;
744            } else {
745                $holdResult->msg = $response->message;
746            }
747        }
748        return $holdResult;
749    }
750
751    /**
752     * Cancel Hold
753     * Cancel and existing OverDrive Hold
754     *
755     * @param string $overDriveId The overdrive id for the title
756     *
757     * @return \stdClass Object with result
758     */
759    public function cancelHold($overDriveId)
760    {
761        $holdResult = $this->getResultObject();
762        $this->debug('OverDriveConnector: cancelHold | OverDriveID: $overDriveID');
763        if (!$user = $this->getUser()) {
764            $this->error('user is not logged in (cancel hold)', [], true);
765            return $holdResult;
766        }
767        if ($config = $this->getConfig()) {
768            if (!$config->usePatronAPI) {
769                $this->error('Cancel hold - OverDrive patron APIs are disabled.');
770                return $holdResult;
771            }
772            $url = $config->circURL . "/v1/patrons/me/holds/$overDriveId";
773            $action = 'DELETE';
774            $response = $this->callPatronUrl(
775                $user['cat_username'],
776                $user['cat_password'],
777                $url,
778                null,
779                $action
780            );
781
782            // Because this is a DELETE Call, we are just looking for a boolean
783            if ($response) {
784                $holdResult->status = true;
785            } else {
786                $holdResult->msg = $response->message;
787            }
788        }
789        return $holdResult;
790    }
791
792    /**
793     * Return Resource
794     * Return a title early.
795     *
796     * @param string $resourceID OverDrive ID of the resource
797     *
798     * @return object|bool Object with result
799     */
800    public function returnResource($resourceID)
801    {
802        $result = $this->getResultObject();
803        if (!$user = $this->getUser()) {
804            $this->error('user is not logged in (return)', [], true);
805            return $result;
806        }
807        if ($config = $this->getConfig()) {
808            if (!$config->usePatronAPI) {
809                $this->error('Return resource - OverDrive patron APIs are disabled.');
810                return $result;
811            }
812            $url = $config->circURL . "/v1/patrons/me/checkouts/$resourceID";
813            $action = 'DELETE';
814            $response = $this->callPatronUrl(
815                $user['cat_username'],
816                $user['cat_password'],
817                $url,
818                null,
819                $action
820            );
821
822            // Because this is a DELETE Call, we are just looking for a boolean
823            if ($response) {
824                $result->status = true;
825            } else {
826                $result->msg = $response->message;
827            }
828        }
829        return $result;
830    }
831
832    /**
833     * Get Download Redirect for an OverDrive Resource
834     *
835     * @param string $overDriveId OverDrive ID
836     *
837     * @return object Object with result. If successful, then data will
838     * have the download URI ($result->data->downloadRedirect)
839     */
840    public function getDownloadRedirect($overDriveId)
841    {
842        $result = $this->getResultObject();
843        $downloadLink = false;
844        if (!$user = $this->getUser()) {
845            $this->error('user is not logged in', false, true);
846            return $result;
847        }
848        if (($config = $this->getConfig()) && !$config->usePatronAPI) {
849            $this->error('Get download redirect - OverDrive patron APIs are disabled.');
850            return $result;
851        }
852        $checkout = $this->getCheckout($overDriveId, false);
853        if ($checkout) {
854            $dlRedirectUrl = $checkout->links->downloadRedirect->href;
855
856            $response = $this->callPatronUrl(
857                $user['cat_username'],
858                $user['cat_password'],
859                $dlRedirectUrl,
860                null,
861                'GET',
862                'redirect'
863            );
864            if (!empty($response)) {
865                $result->status = true;
866                $result->data = (object)[];
867                $result->data->downloadRedirect = $response;
868            } else {
869                $this->debug('OverDriveConnector: problem getting redirect for $overDriveId.');
870                $result->msg
871                    = 'Could not get redirect link for resourceID '
872                    . "[$overDriveId]";
873            }
874        } else {
875            $this->debug('OverDriveConnector: could not find checkout.');
876            $result->msg
877                = 'Could not get download redirect link for resourceID '
878                . "[$overDriveId]";
879        }
880        return $result;
881    }
882
883    /**
884     * Find the authentication header
885     *
886     * @return object
887     */
888    public function getAuthHeader()
889    {
890        $result = $this->getResultObject();
891        if (!$user = $this->getUser()) {
892            $this->error('user is not logged in (getauth header)', [], true);
893            return $result;
894        }
895        // todo: check result
896        $patronTokenData = $this->connectToPatronAPI(
897            $user['cat_username'],
898            $user['cat_password'],
899            $forceNewConnection = false
900        );
901        $authorizationData = $patronTokenData->token_type .
902            ' ' . $patronTokenData->access_token;
903        $header = "Authorization: $authorizationData";
904        $result->data->authheader = $header;
905        $result->status = true;
906        return $result;
907    }
908
909    /**
910     * Get Configuration
911     * Sets up a local copy of configurations for convenience
912     *
913     * @return bool|\stdClass
914     */
915    public function getConfig()
916    {
917        $conf = new \stdClass();
918        if (!$this->recordConfig) {
919            $this->error(
920                'Could not locate the OverDrive Record Driver '
921                . 'configuration.'
922            );
923            return false;
924        }
925        if ($this->recordConfig->API->productionMode == false) {
926            $conf->productionMode = false;
927            $conf->discURL = $this->recordConfig->API->integrationDiscoveryURL;
928            $conf->circURL = $this->recordConfig->API->integrationCircURL;
929            $conf->libraryID = $this->recordConfig->API->integrationLibraryID;
930            $conf->websiteID = $this->recordConfig->API->integrationWebsiteID;
931        } else {
932            $conf->productionMode = true;
933            $conf->discURL = $this->recordConfig->API->productionDiscoveryURL;
934            $conf->circURL = $this->recordConfig->API->productionCircURL;
935            $conf->libraryID = $this->recordConfig->API->productionLibraryID;
936            $conf->websiteID = $this->recordConfig->API->productionWebsiteID;
937        }
938
939        $conf->clientKey = $this->recordConfig->API->clientKey;
940        $conf->clientSecret = $this->recordConfig->API->clientSecret;
941        $conf->tokenURL = $this->recordConfig->API->tokenURL;
942        $conf->patronTokenURL = $this->recordConfig->API->patronTokenURL;
943        $conf->usePatronAPI = (bool)($this->recordConfig->API->usePatronAPI ?? true);
944        $conf->idField = $this->recordConfig->Overdrive->overdriveIdMarcField;
945        $conf->idSubfield
946            = $this->recordConfig->Overdrive->overdriveIdMarcSubfield;
947        $conf->ILSname = $this->recordConfig->API->ILSname;
948        $conf->isMarc = $this->recordConfig->Overdrive->isMarc;
949        $conf->displayDateFormat = $this->mainConfig->Site->displayDateFormat;
950        $conf->consortiumSupport
951            = $this->recordConfig->Overdrive->consortiumSupport;
952        $conf->showMyContent
953            = strtolower($this->recordConfig->Overdrive->showMyContent);
954        $conf->noAccessString = $this->recordConfig->Overdrive->noAccessString;
955        $conf->tokenCacheLifetime
956            = $this->recordConfig->API->tokenCacheLifetime;
957        $conf->libraryURL = $this->recordConfig->Overdrive->overdriveLibraryURL;
958        $conf->enableAjaxStatus = $this->recordConfig->Overdrive->enableAjaxStatus;
959        $conf->showOverdriveAdminMenu = $this->recordConfig->Overdrive->showOverdriveAdminMenu;
960        return $conf;
961    }
962
963    /**
964     * Returns an array of OverDrive Formats and translation tokens
965     *
966     * @return array
967     */
968    public function getFormatNames()
969    {
970        return [
971            'ebook-kindle' => 'od_ebook-kindle',
972            'ebook-overdrive' => 'od_ebook-overdrive',
973            'ebook-epub-adobe' => 'od_ebook-epub-adobe',
974            'ebook-epub-open' => 'od_ebook-epub-open',
975            'ebook-pdf-adobe' => 'od_ebook-pdf-adobe',
976            'ebook-pdf-open' => 'od_ebook-pdf-open',
977            'ebook-mediado' => 'od_ebook-mediado',
978            'audiobook-overdrive' => 'od_audiobook-overdrive',
979            'audiobook-mp3' => 'od_audiobook-mp3',
980            'video-streaming' => 'od_video-streaming',
981            'magazine-overdrive' => 'od_magazine-overdrive',
982        ];
983    }
984
985    /**
986     * Returns permanant links for Ovedrive resources
987     *
988     * @param array $overDriveIds An array of overdrive IDs we need links for
989     *
990     * @return array<string>
991     */
992    public function getPermanentLinks($overDriveIds = [])
993    {
994        $links = [];
995        if (!$overDriveIds || count($overDriveIds) < 1) {
996            $this->logWarning('OverDriveConnector: getPermanentLinks: no overdrive content IDs were passed in.');
997            return [];
998        }
999        if ($conf = $this->getConfig()) {
1000            $libraryURL = $conf->libraryURL;
1001            $md = $this->getMetadata($overDriveIds);
1002
1003            foreach ($md as $item) {
1004                $links[$item->id] = "$libraryURL/media/" . $item->crossRefId;
1005            }
1006        }
1007        return $links;
1008    }
1009
1010    /**
1011     * Returns all the issues for an overdrive magazine title
1012     *
1013     * @param string $overDriveId OverDrive Identifier for magazine title
1014     * @param bool   $checkouts   Whether to add checkout information to each issue
1015     * @param int    $limit       maximum number of issues to retrieve (default 100)
1016     * @param int    $offset      page of results (default 0)
1017     *
1018     * @return object results of metadata fetch
1019     */
1020    public function getMagazineIssues($overDriveId = false, $checkouts = false, $limit = 100, $offset = 0)
1021    {
1022        $result = $this->getResultObject();
1023        if (!$overDriveId) {
1024            $this->logWarning('OverDriveConnector - getMagazineIssues: no overdrive content ID was passed in.');
1025            $result->msg = 'no overdrive content ID was passed in.';
1026            return $result;
1027        }
1028        if ($conf = $this->getConfig()) {
1029            $libraryURL = $conf->libraryURL;
1030            $productsKey = $this->getCollectionToken();
1031            $baseUrl = $conf->discURL;
1032            $issuesURL = "$baseUrl/v1/collections/$productsKey/products/$overDriveId/issues";
1033            $issuesURL .= "?limit=$limit&offset=$offset";
1034            $response = $this->callUrl($issuesURL);
1035            if ($response) {
1036                $result->status = true;
1037                $result->data = (object)[];
1038                $result->data = $response;
1039            }
1040
1041            if ($checkouts) {
1042                $checkoutResult = $this->getCheckouts();
1043                $checkoutData = $checkoutResult->data;
1044                foreach ($result->data->products as $key => $issue) {
1045                    $issue->checkedout = isset($checkoutData[strtolower($issue->id)]);
1046                }
1047            }
1048        }
1049        return $result;
1050    }
1051
1052    /**
1053     * Returns a hash of metadata keyed on overdrive reserveID
1054     *
1055     * @param array $overDriveIds Set of OverDrive IDs
1056     *
1057     * @return array results of metadata fetch
1058     *
1059     * @todo if more than 25 passed in, make multiple calls
1060     */
1061    public function getMetadata($overDriveIds = [])
1062    {
1063        $metadata = [];
1064        if (!$overDriveIds || count($overDriveIds) < 1) {
1065            $this->logWarning('OverDriveConnector - getMetadata: no overdrive content IDs were passed in.');
1066            return [];
1067        }
1068        if ($conf = $this->getConfig()) {
1069            $libraryURL = $conf->libraryURL;
1070            $productsKey = $this->getCollectionToken();
1071            $baseUrl = $conf->discURL;
1072            $metadataUrl = "$baseUrl/v1/collections/$productsKey/";
1073            $metadataUrl .= 'bulkmetadata?reserveIds=' . implode(
1074                ',',
1075                $overDriveIds
1076            );
1077            $res = $this->callUrl($metadataUrl);
1078            $md = $res->metadata;
1079            foreach ($md as $item) {
1080                $item->external_url = "$libraryURL/media/" . $item->crossRefId;
1081                $metadata[$item->id] = $item;
1082            }
1083        }
1084        return $metadata;
1085    }
1086
1087    /**
1088     * For  array of titles passed in this will return the same array
1089     * with metadata attached to the records with the property name of 'metadata'
1090     *
1091     * @param array $overDriveTitles Assoc array of objects with OD IDs as keys (generally what
1092     *                               you get from getCheckouts and getHolds)
1093     *
1094     * @return array initial array with results of metadata attached as "metadata" property
1095     */
1096    public function getMetadataForTitles($overDriveTitles = [])
1097    {
1098        if (!$overDriveTitles || count($overDriveTitles) < 1) {
1099            $this->logWarning('OverDriveConnector - getMetadataForTitles: no overdrive content was passed in.');
1100            return [];
1101        }
1102        $metadata = $this->getMetadata(array_column($overDriveTitles, 'reserveId', 'reserveId'));
1103        foreach ($overDriveTitles as &$title) {
1104            $id = $title->reserveId;
1105            if (!isset($metadata[strtolower($id)])) {
1106                $this->logWarning('no metadata found for ' . strtolower($id));
1107            } else {
1108                $title->metadata = $metadata[strtolower($id)];
1109            }
1110        }
1111        return $overDriveTitles;
1112    }
1113
1114    /**
1115     * Get OverDrive Checkout
1116     *
1117     * Get the overdrive checkout object for an overdrive title
1118     * for the current user
1119     *
1120     * @param string $overDriveId OverDrive resource id
1121     * @param bool   $refresh     Whether or not to ignore cache and get latest
1122     *
1123     * @return object|false PHP object that represents the checkout or false
1124     * the checkout is not in the current list of checkouts for the current
1125     * user.
1126     */
1127    public function getCheckout($overDriveId, $refresh = true)
1128    {
1129        $result = $this->getCheckouts($refresh);
1130        if ($result->status) {
1131            $checkouts = $result->data;
1132            foreach ($checkouts as $checkout) {
1133                if (
1134                    strtolower($checkout->reserveId) == strtolower(
1135                        $overDriveId
1136                    )
1137                ) {
1138                    return $checkout;
1139                }
1140            }
1141            return false;
1142        } else {
1143            return false;
1144        }
1145    }
1146
1147    /**
1148     * Get OverDrive Hold
1149     *
1150     * Get the overdrive hold object for an overdrive title
1151     * for the current user
1152     *
1153     * @param string $overDriveId OverDrive resource id
1154     * @param bool   $refresh     Whether or not to ignore cache and get latest
1155     *
1156     * @return object|false PHP object that represents the checkout or false
1157     * the checkout is not in the current list of checkouts for the current
1158     * user.
1159     */
1160    public function getHold($overDriveId, $refresh = true)
1161    {
1162        $result = $this->getHolds($refresh);
1163        if ($result->status) {
1164            $holds = $result->data;
1165            foreach ($holds as $hold) {
1166                if (strtolower($hold->reserveId) == strtolower($overDriveId)) {
1167                    return $hold;
1168                }
1169            }
1170            return false;
1171        } else {
1172            return false;
1173        }
1174    }
1175
1176    /**
1177     * Get OverDrive Checkouts (for a user)
1178     *
1179     * @param bool $refresh Whether or not to ignore cache and get latest
1180     *
1181     * @return object Results of the call
1182     */
1183    public function getCheckouts($refresh = true)
1184    {
1185        // The checkouts are cached in the session, but we can force a refresh
1186        $result = $this->getResultObject();
1187
1188        if (!$user = $this->getUser()) {
1189            $this->error('OverDriveConnector - user is not logged in (getcheckouts)');
1190            return $result;
1191        }
1192
1193        $checkouts = $this->getSessionContainer()->checkouts;
1194        if (!$checkouts || $refresh) {
1195            if ($config = $this->getConfig()) {
1196                if (!$config->usePatronAPI) {
1197                    $this->error('Get checkouts - OverDrive patron APIs are disabled.');
1198                    return $result;
1199                }
1200                $url = $config->circURL . '/v1/patrons/me/checkouts';
1201
1202                $response = $this->callPatronUrl(
1203                    $user['cat_username'],
1204                    $user['cat_password'],
1205                    $url,
1206                    null
1207                );
1208                if (!empty($response)) {
1209                    $result->status = true;
1210                    $result->message = '';
1211                    if (isset($response->checkouts)) {
1212                        //reset checkouts array to be keyed by id
1213                        $mycheckouts = [];
1214                        foreach ($response->checkouts as $co) {
1215                            $mycheckouts[$co->reserveId] = $co;
1216                        }
1217
1218                        // get the metadata for these so we can check for magazines.
1219                        $result->data = (object)[];
1220                        $result->data = $this->getMetadataForTitles($mycheckouts);
1221
1222                        foreach ($mycheckouts as $key => $checkout) {
1223                            // Convert dates to desired format
1224                            $coExpires = new \DateTime($checkout->expires);
1225                            $result->data[$key]->expires = $coExpires->format(
1226                                $config->displayDateFormat
1227                            );
1228                            $result->data[$key]->isReturnable
1229                                = !$checkout->isFormatLockedIn;
1230                        }
1231                        $this->getSessionContainer()->checkouts
1232                            = $result->data;
1233                    } else {
1234                        $result->data = [];
1235                        $this->getSessionContainer()->checkouts = false;
1236                    }
1237                } else {
1238                    $accessResult = $this->getAccess();
1239                    return $accessResult;
1240                }
1241            } // no config...
1242        } else {
1243            $result->status = true;
1244            $result->msg = [];
1245            $result->data = (object)[];
1246            $result->data = $this->getSessionContainer()->checkouts;
1247        }
1248        return $result;
1249    }
1250
1251    /**
1252     * Get OverDrive Holds (for a user)
1253     *
1254     * @param bool $refresh Whether or not to ignore cache and get latest
1255     *
1256     * @return \stdClass Results of the call. the data property will be set
1257     *     to an empty array if there are no  holds.
1258     */
1259    public function getHolds($refresh = true)
1260    {
1261        $this->debug('OverDriveConnector - getHolds');
1262        $result = $this->getResultObject();
1263        if (!$user = $this->getUser()) {
1264            $this->error('OverDriveConnector - user is not logged in (getholds)');
1265            return $result;
1266        }
1267
1268        $holds = $this->getSessionContainer()->holds;
1269        if (!$holds || $refresh) {
1270            if ($config = $this->getConfig()) {
1271                if (!$config->usePatronAPI) {
1272                    $this->error('Get holds - OverDrive patron APIs are disabled.');
1273                    return $result;
1274                }
1275                $url = $config->circURL . '/v1/patrons/me/holds';
1276
1277                $response = $this->callPatronUrl(
1278                    $user['cat_username'],
1279                    $user['cat_password'],
1280                    $url
1281                );
1282
1283                $result->status = false;
1284                $result->message = '';
1285
1286                if (!empty($response)) {
1287                    $result->status = true;
1288                    $result->message = 'hold_place_success_html';
1289                    if (isset($response->holds)) {
1290                        $result->data = $response->holds;
1291                        // Check for holds ready for checkout
1292                        foreach ($response->holds as $key => $hold) {
1293                            // check for hold suspension
1294                            $result->data[$key]->holdSuspension = $hold->holdSuspension ?? false;
1295                            // check if ready for checkout
1296                            foreach ($hold->actions as $action => $value) {
1297                                if ($action == 'checkout') {
1298                                    $result->data[$key]->holdReadyForCheckout = true;
1299                                    // format the expires date.
1300                                    $holdExpires = new \DateTime($hold->holdExpires);
1301                                    $result->data[$key]->holdExpires
1302                                        = $holdExpires->format(
1303                                            (string)$config->displayDateFormat
1304                                        );
1305                                } else {
1306                                    $result->data[$key]->holdReadyForCheckout = false;
1307                                }
1308                            }
1309
1310                            $holdPlacedDate = new \DateTime($hold->holdPlacedDate);
1311                            $result->data[$key]->holdPlacedDate
1312                                = $holdPlacedDate->format(
1313                                    (string)$config->displayDateFormat
1314                                );
1315                        } // end foreach
1316                        $this->getSessionContainer()->holds = $response->holds;
1317                    } else {
1318                        // no holds found for this patron
1319                        $result->data = [];
1320                        $this->getSessionContainer()->holds;
1321                    }
1322                } else {
1323                    $result->code = 'od_code_connection_failed';
1324                }
1325            } // no config
1326        } else {
1327            $result->status = true;
1328            $result->message = [];
1329            $result->data = $this->getSessionContainer()->holds;
1330        }
1331        return $result;
1332    }
1333
1334    /**
1335     * Call a URL on the API
1336     *
1337     * @param string $url         The url to call
1338     * @param array  $headers     Headers to set for the request.
1339     *                            if null, then the auth headers are used.
1340     * @param bool   $checkToken  Whether to check and get a new token
1341     * @param string $requestType The request type (GET, POST etc)
1342     *
1343     * @return object|bool The json response from the API call
1344     *  converted to an object. If the call fails at the
1345     *  HTTP level then the error is logged and false is returned.
1346     */
1347    protected function callUrl(
1348        $url,
1349        $headers = null,
1350        $checkToken = true,
1351        $requestType = 'GET'
1352    ) {
1353        if (!$checkToken || $this->connectToAPI()) {
1354            $tokenData = $this->getSessionContainer()->tokenData;
1355            try {
1356                $client = $this->getHttpClient($url);
1357            } catch (Exception $e) {
1358                $this->error(
1359                    'OverDriveConnector - error while setting up the client: ' . $e->getMessage()
1360                );
1361                return false;
1362            }
1363            if ($headers === null) {
1364                $headers = [];
1365                if (
1366                    isset($tokenData->token_type)
1367                    && isset($tokenData->access_token)
1368                ) {
1369                    $headers[] = "Authorization: {$tokenData->token_type} "
1370                        . $tokenData->access_token;
1371                }
1372                $headers[] = 'User-Agent: VuFind';
1373            }
1374            $client->setHeaders($headers);
1375            $client->setMethod($requestType);
1376            $client->setUri($url);
1377            try {
1378                // throw new Exception('testException');
1379                $response = $client->send();
1380            } catch (Exception $ex) {
1381                $this->error(
1382                    'Exception during request: ' .
1383                    $ex->getMessage()
1384                );
1385                return false;
1386            }
1387
1388            if ($response->isServerError()) {
1389                $this->error(
1390                    'OverDrive HTTP Error: ' .
1391                    $response->getStatusCode()
1392                );
1393                $this->debug('Request: ' . $client->getRequest());
1394                $this->debug('Response: ' . $client->getResponse());
1395                return false;
1396            }
1397
1398            $body = $response->getBody();
1399            $returnVal = json_decode($body);
1400            if ($returnVal != null) {
1401                if (isset($returnVal->errorCode)) {
1402                    // In some cases, this should be returned perhaps...
1403                    $this->error('OverDrive Error: ' . $returnVal->errorCode);
1404                    return $returnVal;
1405                } else {
1406                    return $returnVal;
1407                }
1408            } else {
1409                $this->error(
1410                    'OverDrive Error: Nothing returned from API call.'
1411                );
1412                $this->debug(
1413                    'Body return from OD API Call: ' . print_r($body, true)
1414                );
1415            }
1416        }
1417        return false;
1418    }
1419
1420    /**
1421     * Connect to API
1422     *
1423     * @param bool $forceNewConnection Force a new connection (get a new token)
1424     *
1425     * @return string token for the session or false
1426     *     if the token request failed
1427     */
1428    protected function connectToAPI($forceNewConnection = false)
1429    {
1430        $conf = $this->getConfig();
1431        $tokenData = $this->getSessionContainer()->tokenData;
1432        if (
1433            $forceNewConnection || $tokenData == null
1434            || !isset($tokenData->access_token)
1435            || time() >= $tokenData->expirationTime
1436        ) {
1437            $authHeader = base64_encode(
1438                $conf->clientKey . ':' . $conf->clientSecret
1439            );
1440            $headers = [
1441                'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
1442                "Authorization: Basic $authHeader",
1443            ];
1444
1445            try {
1446                $client = $this->getHttpClient();
1447            } catch (Exception $e) {
1448                $this->error(
1449                    'error while setting up the client: ' . $e->getMessage()
1450                );
1451                return false;
1452            }
1453            $client->setHeaders($headers);
1454            $client->setMethod('POST');
1455            $client->setRawBody('grant_type=client_credentials');
1456            $response = $client->setUri($conf->tokenURL)->send();
1457
1458            if ($response->isServerError()) {
1459                $this->error(
1460                    'OverDrive HTTP Error: ' .
1461                    $response->getStatusCode()
1462                );
1463                $this->debug('Request: ' . $client->getRequest());
1464                return false;
1465            }
1466
1467            $body = $response->getBody();
1468            $tokenData = json_decode($body);
1469            if ($tokenData != null) {
1470                if (isset($tokenData->errorCode)) {
1471                    // In some cases, this should be returned perhaps...
1472                    $this->error('OverDrive Error: ' . $tokenData->errorCode);
1473                    return false;
1474                } else {
1475                    $tokenData->expirationTime = time()
1476                        + ($tokenData->expires_in ?? 0);
1477                    $this->getSessionContainer()->tokenData = $tokenData;
1478                    return $tokenData;
1479                }
1480            } else {
1481                $this->error(
1482                    'OverDrive Error: Nothing returned from API call.'
1483                );
1484                $this->debug(
1485                    'Body return from OD API Call: ' . print_r($body, true)
1486                );
1487            }
1488        }
1489        return $tokenData;
1490    }
1491
1492    /**
1493     * Call a Patron URL on the API
1494     *
1495     * The patron URL is used for the circulation API's and requires a patron
1496     * specific token.
1497     *
1498     * @param string $patronBarcode Patrons barcode
1499     * @param string $patronPin     Patrons password
1500     * @param string $url           The url to call
1501     * @param array  $params        parameters to call
1502     * @param string $requestType   HTTP request type (default=GET)
1503     * @param string $returnType    options are json(def),body,redirect
1504     *
1505     * @return object|bool The json response from the API call
1506     *  converted to an object. If body is specified, the raw body is returned.
1507     *  If redirect, then it returns the URL specified in the redirect header.
1508     *  If the call fails at the HTTP level then the error is logged and false is returned.
1509     */
1510    protected function callPatronUrl(
1511        $patronBarcode,
1512        $patronPin,
1513        $url,
1514        $params = null,
1515        $requestType = 'GET',
1516        $returnType = 'json'
1517    ) {
1518        if ($this->connectToPatronAPI($patronBarcode, $patronPin, false)) {
1519            $patronTokenData = $this->getSessionContainer()->patronTokenData;
1520            $authorizationData = $patronTokenData->token_type .
1521                ' ' . $patronTokenData->access_token;
1522            $headers = [
1523                "Authorization: $authorizationData",
1524                'User-Agent: VuFind',
1525                'Content-Type: application/json',
1526            ];
1527            try {
1528                $client = $this->getHttpClient(null, $returnType != 'redirect');
1529            } catch (Exception $e) {
1530                $this->error(
1531                    'error while setting up the client: ' . $e->getMessage()
1532                );
1533                return false;
1534            }
1535            $client->setHeaders($headers);
1536            $client->setMethod($requestType);
1537            $this->debug('callPatronURL method: ' . $client->getMethod() . " url: $url");
1538            $client->setUri($url);
1539            if ($params != null) {
1540                $jsonData = ['fields' => []];
1541                foreach ($params as $key => $value) {
1542                    $jsonData['fields'][] = [
1543                        'name' => $key,
1544                        'value' => $value,
1545                    ];
1546                }
1547                $postData = json_encode($jsonData);
1548                $client->setRawBody($postData);
1549                $this->debug("patron data sent: $postData");
1550            }
1551
1552            try {
1553                $response = $client->send();
1554            } catch (Exception $ex) {
1555                $this->error(
1556                    'Exception during request: ' .
1557                    $ex->getMessage()
1558                );
1559                return false;
1560            }
1561            $body = $response->getBody();
1562
1563            // if all goes well for DELETE, the code will be 204
1564            // and response is empty.
1565            if ($requestType == 'DELETE' || $requestType == 'PUT') {
1566                if ($response->getStatusCode() == 204) {
1567                    return true;
1568                } else {
1569                    $this->error(
1570                        $requestType . ' Patron call failed. HTTP return code: ' .
1571                        $response->getStatusCode() . " body: $body"
1572                    );
1573                    return false;
1574                }
1575            }
1576
1577            if ($returnType == 'body') {
1578                // probably need to check for return status
1579                return $body;
1580            } elseif ($returnType == 'redirect') {
1581                $headers = $response->getHeaders();
1582                if ($headers->has('location')) {
1583                    $loc = $headers->get('location');
1584                    $uri = $loc->getUri();
1585                    return $uri;
1586                } else {
1587                    $this->error(
1588                        'OverDrive Error: returnType is redirect but no redirect found.'
1589                    );
1590                    return false;
1591                }
1592            } else {
1593                $returnVal = json_decode($body);
1594                if ($returnVal != null) {
1595                    if (
1596                        !isset($returnVal->message)
1597                        || $returnVal->message != 'An unexpected error has occurred.'
1598                    ) {
1599                        return $returnVal;
1600                    } else {
1601                        $this->debug(
1602                            'OverDrive API problem: ' . $returnVal->message
1603                        );
1604                    }
1605                } else {
1606                    $this->error(
1607                        'OverDrive Error: Nothing returned from API call.'
1608                    );
1609                    return false;
1610                }
1611            }
1612        } else {
1613            $this->error('OverDrive Error: Not connected to the Patron API.');
1614        }
1615        return false;
1616    }
1617
1618    /**
1619     * Connect to Patron API
1620     *
1621     * @param string $patronBarcode      Patrons barcode
1622     * @param string $patronPin          Patrons password
1623     * @param bool   $forceNewConnection force a new connection (get a new
1624     *                                   token)
1625     *
1626     * @return object|bool token for the session
1627     */
1628    protected function connectToPatronAPI(
1629        $patronBarcode,
1630        $patronPin = '1234',
1631        $forceNewConnection = false
1632    ) {
1633        $patronTokenData = $this->getSessionContainer()->patronTokenData;
1634        $config = $this->getConfig();
1635        if (!$config->usePatronAPI) {
1636            return false;
1637        }
1638        if (
1639            $forceNewConnection
1640            || $patronTokenData == null
1641            || (isset($patronTokenData->expirationTime)
1642            and time() >= $patronTokenData->expirationTime)
1643        ) {
1644            $url = $config->patronTokenURL;
1645            $websiteId = $config->websiteID;
1646            $ilsname = $config->ILSname;
1647            $authHeader = base64_encode(
1648                $config->clientKey . ':' . $config->clientSecret
1649            );
1650            $headers = [
1651                'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
1652                "Authorization: Basic $authHeader",
1653                'User-Agent: VuFind',
1654            ];
1655            try {
1656                $client = $this->getHttpClient($url);
1657            } catch (Exception $e) {
1658                $this->error(
1659                    'error while setting up the client: ' . $e->getMessage()
1660                );
1661                return false;
1662            }
1663            $client->setHeaders($headers);
1664            $client->setMethod('POST');
1665            if ($patronPin == null) {
1666                $postFields = "grant_type=password&username={$patronBarcode}";
1667                $postFields .= '&password=ignore&password_required=false';
1668                $postFields .= "&scope=websiteId:{$websiteId}%20";
1669                $postFields .= "authorizationname:{$ilsname}";
1670            } else {
1671                $postFields = "grant_type=password&username={$patronBarcode}";
1672                $postFields .= "&password={$patronPin}&scope=websiteId";
1673                $postFields .= ":{$websiteId}%20authorizationname:{$ilsname}";
1674            }
1675            $client->setRawBody($postFields);
1676            $response = $client->setUri($url)->send();
1677            $body = $response->getBody();
1678            $patronTokenData = json_decode($body);
1679
1680            if (isset($patronTokenData->expires_in)) {
1681                $patronTokenData->expirationTime = time()
1682                    + $patronTokenData->expires_in;
1683            } else {
1684                $this->debug(
1685                    'problem with OD patron API token Call: ' .
1686                    print_r(
1687                        $patronTokenData,
1688                        true
1689                    )
1690                );
1691                // If we have an unauthorized error, then we are going
1692                // to cache that in the session so we don't keep making
1693                // unnecessary calls; otherwise, just don't store the tokenData
1694                // object so that it gets checked again next time
1695                if ($patronTokenData->error == 'unauthorized_client') {
1696                    $this->getSessionContainer()->odAccessMessage
1697                        = $patronTokenData->error_description;
1698                    $this->getSessionContainer()->patronTokenData
1699                        = $patronTokenData;
1700                } else {
1701                    $patronTokenData = null;
1702                }
1703                return false;
1704            }
1705            $this->getSessionContainer()->patronTokenData = $patronTokenData;
1706        }
1707        if (isset($patronTokenData->error)) {
1708            return false;
1709        }
1710        return $patronTokenData;
1711    }
1712
1713    /**
1714     * Get an HTTP client
1715     *
1716     * @param string $url            URL for client to use
1717     * @param bool   $allowRedirects Whether to allow the client to follow redirects
1718     *
1719     * @return \Laminas\Http\Client
1720     * @throws \Exception
1721     */
1722    protected function getHttpClient($url = null, $allowRedirects = true)
1723    {
1724        if (null === $this->httpService) {
1725            throw new Exception('HTTP service missing.');
1726        }
1727        if (!$this->client) {
1728            $this->client = $this->httpService->createClient($url);
1729            // Set keep alive to true since we are sending to the same server
1730            $this->client->setOptions(['keepalive', true]);
1731        }
1732        $this->client->resetParameters();
1733        // set keep alive to true since we are sending to the same server
1734        $options = ['keepalive' => true];
1735        if (!$allowRedirects) {
1736            $options['maxredirects'] = 0;
1737        }
1738        $this->client->setOptions($options);
1739        return $this->client;
1740    }
1741
1742    /**
1743     * Set a cache storage object.
1744     *
1745     * @param StorageInterface $cache Cache storage interface
1746     *
1747     * @return void
1748     */
1749    public function setCacheStorage(StorageInterface $cache = null)
1750    {
1751        $this->cache = $cache;
1752    }
1753
1754    /**
1755     * Helper function for fetching cached data.
1756     * Data is cached for up to $this->cacheLifetime seconds so that it would
1757     * be
1758     * faster to process e.g. requests where multiple calls to the backend are
1759     * made.
1760     *
1761     * @param string $key Cache entry key
1762     *
1763     * @return mixed|null Cached entry or null if not cached or expired
1764     */
1765    protected function getCachedData($key)
1766    {
1767        // No cache object, no cached results!
1768        if (null === $this->cache) {
1769            return null;
1770        }
1771        $conf = $this->getConfig();
1772        $fullKey = $this->getCacheKey($key);
1773        $item = $this->cache->getItem($fullKey);
1774        if (null !== $item) {
1775            // Return value if still valid:
1776            if (time() - $item['time'] < $conf->tokenCacheLifetime) {
1777                return $item['entry'];
1778            }
1779
1780            // Clear expired item from cache:
1781            $this->cache->removeItem($fullKey);
1782        }
1783        return null;
1784    }
1785
1786    /**
1787     * Helper function for storing cached data.
1788     * Data is cached for up to $this->cacheLifetime seconds so that it would
1789     * be
1790     * faster to process e.g. requests where multiple calls to the backend are
1791     * made.
1792     *
1793     * @param string $key   Cache entry key
1794     * @param mixed  $entry Entry to be cached
1795     *
1796     * @return void
1797     */
1798    protected function putCachedData($key, $entry)
1799    {
1800        // Don't write to cache if we don't have a cache!
1801        if (null === $this->cache) {
1802            return;
1803        }
1804        $item = [
1805            'time' => time(),
1806            'entry' => $entry,
1807        ];
1808        $this->cache->setItem($this->getCacheKey($key), $item);
1809    }
1810
1811    /**
1812     * Helper function for removing cached data.
1813     *
1814     * @param string $key Cache entry key
1815     *
1816     * @return void
1817     */
1818    protected function removeCachedData($key)
1819    {
1820        // Don't write to cache if we don't have a cache!
1821        if (null === $this->cache) {
1822            return;
1823        }
1824        $this->cache->removeItem($this->getCacheKey($key));
1825    }
1826
1827    /**
1828     * Get Result Object
1829     *
1830     * @param bool   $status Whether it succeeded
1831     * @param string $msg    More information
1832     * @param string $code   code used for end user display/translation
1833     *
1834     * @return object
1835     */
1836    public function getResultObject($status = false, $msg = '', $code = '')
1837    {
1838        return (object)[
1839            'status' => $status,
1840            'msg' => $msg,
1841            'data' => false,
1842            'code' => $code,
1843        ];
1844    }
1845}