Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
Folio.php 41.69 KiB
<?php
/**
 * FOLIO REST API driver
 *
 * PHP version 7
 *
 * Copyright (C) Villanova University 2018.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2,
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 *
 * @category VuFind
 * @package  ILS_Drivers
 * @author   Chris Hallberg <challber@villanova.edu>
 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
 */
namespace VuFind\ILS\Driver;

use VuFind\Exception\ILS as ILSException;
use VuFind\I18n\Translator\TranslatorAwareInterface;
use VuFindHttp\HttpServiceAwareInterface as HttpServiceAwareInterface;

/**
 * FOLIO REST API driver
 *
 * @category VuFind
 * @package  ILS_Drivers
 * @author   Chris Hallberg <challber@villanova.edu>
 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
 */
class Folio extends AbstractAPI implements
    HttpServiceAwareInterface, TranslatorAwareInterface
{
    use \VuFindHttp\HttpServiceAwareTrait;
    use \VuFind\I18n\Translator\TranslatorAwareTrait;
    use \VuFind\Log\LoggerAwareTrait {
        logWarning as warning;
        logError as error;
    }

    /**
     * Authentication tenant (X-Okapi-Tenant)
     *
     * @var string
     */
    protected $tenant = null;

    /**
     * Authentication token (X-Okapi-Token)
     *
     * @var string
     */
    protected $token = null;

    /**
     * Factory function for constructing the SessionContainer.
     *
     * @var Callable
     */
    protected $sessionFactory;

    /**
     * Session cache
     *
     * @var \Zend\Session\Container
     */
    protected $sessionCache;

    /**
     * Constructor
     *
     * @param \VuFind\Date\Converter $dateConverter  Date converter object
     * @param Callable               $sessionFactory Factory function returning
     * SessionContainer object
     */
    public function __construct(\VuFind\Date\Converter $dateConverter,
        $sessionFactory
    ) {
        $this->dateConverter = $dateConverter;
        $this->sessionFactory = $sessionFactory;
    }

    /**
     * Set the configuration for the driver.
     *
     * @param array $config Configuration array (usually loaded from a VuFind .ini
     * file whose name corresponds with the driver class name).
     *
     * @throws ILSException if base url excluded
     * @return void
     */
    public function setConfig($config)
    {
        parent::setConfig($config);
        $this->tenant = $this->config['API']['tenant'];
    }

    /**
     * Get the type of FOLIO ID used to match up with VuFind's bib IDs.
     *
     * @return string
     */
    protected function getBibIdType()
    {
        // Normalize string to tolerate minor variations in config file:
        return trim(strtolower($this->config['IDs']['type'] ?? 'instance'));
    }

    /**
     * Function that obscures and logs debug data
     *
     * @param string             $method      Request method GET/POST/PUT/DELETE/etc
     * @param string             $path        Request URL
     * @param array              $params      Request parameters
     * @param \Zend\Http\Headers $req_headers Headers object
     *
     * @return void
     */
    protected function debugRequest($method, $path, $params, $req_headers)
    {
        // Only log non-GET requests
        if ($method == 'GET') {
            return;
        }
        // remove passwords
        $logParams = $params;
        if (isset($logParams['password'])) {
            unset($logParams['password']);
        }
        // truncate headers for token obscuring
        $logHeaders = $req_headers->toArray();
        if (isset($logHeaders['X-Okapi-Token'])) {
            $logHeaders['X-Okapi-Token'] = substr(
                $logHeaders['X-Okapi-Token'], 0, 30
            ) . '...';
        }

        $this->debug(
            $method . ' request.' .
            ' URL: ' . $path . '.' .
            ' Params: ' . print_r($logParams, true) . '.' .
            ' Headers: ' . print_r($logHeaders, true)
        );
    }

    /**
     * (From AbstractAPI) Allow default corrections to all requests
     *
     * Add X-Okapi headers and Content-Type to every request
     *
     * @param \Zend\Http\Headers $headers the request headers
     * @param object             $params  the parameters object
     *
     * @return array
     */
    public function preRequest(\Zend\Http\Headers $headers, $params)
    {
        $headers->addHeaderLine('Accept', 'application/json');
        if (!$headers->has('Content-Type')) {
            $headers->addHeaderLine('Content-Type', 'application/json');
        }
        $headers->addHeaderLine('X-Okapi-Tenant', $this->tenant);
        if ($this->token != null) {
            $headers->addHeaderLine('X-Okapi-Token', $this->token);
        }
        return [$headers, $params];
    }

    /**
     * Login and receive a new token
     *
     * @return void
     */
    protected function renewTenantToken()
    {
        $this->token = null;
        $auth = [
            'username' => $this->config['API']['username'],
            'password' => $this->config['API']['password'],
        ];
        $response = $this->makeRequest('POST', '/authn/login', json_encode($auth));
        if ($response->getStatusCode() >= 400) {
            throw new ILSException($response->getBody());
        }
        $this->token = $response->getHeaders()->get('X-Okapi-Token')
            ->getFieldValue();
        $this->sessionCache->folio_token = $this->token;
        $this->debug(
            'Token renewed. Tenant: ' . $auth['username'] .
            ' Token: ' . substr($this->token, 0, 30) . '...'
        );
    }

    /**
     * Check if our token is still valid
     *
     * Method taken from Stripes JS (loginServices.js:validateUser)
     *
     * @return void
     */
    protected function checkTenantToken()
    {
        $response = $this->makeRequest('GET', '/users');
        if ($response->getStatusCode() >= 400) {
            $this->token = null;
            $this->renewTenantToken();
        }
    }

    /**
     * Initialize the driver.
     *
     * Check or renew our auth token
     *
     * @return void
     */
    public function init()
    {
        $factory = $this->sessionFactory;
        $this->sessionCache = $factory($this->tenant);
        if ($this->sessionCache->folio_token ?? false) {
            $this->token = $this->sessionCache->folio_token;
            $this->debug(
                'Token taken from cache: ' . substr($this->token, 0, 30) . '...'
            );
        }
        if ($this->token == null) {
            $this->renewTenantToken();
        } else {
            $this->checkTenantToken();
        }
    }

    /**
     * Given some kind of identifier (instance, holding or item), retrieve the
     * associated instance object from FOLIO.
     *
     * @param string $instanceId Instance ID, if available.
     * @param string $holdingId  Holding ID, if available.
     * @param string $itemId     Item ID, if available.
     *
     * @return object
     */
    protected function getInstanceById($instanceId = null, $holdingId = null,
        $itemId = null
    ) {
        if ($instanceId == null) {
            if ($holdingId == null) {
                if ($itemId == null) {
                    throw new \Exception('No IDs provided to getInstanceObject.');
                }
                $response = $this->makeRequest(
                    'GET',
                    '/item-storage/items/' . $itemId
                );
                $item = json_decode($response->getBody());
                $holdingId = $item->holdingsRecordId;
            }
            $response = $this->makeRequest(
                'GET', '/holdings-storage/holdings/' . $holdingId
            );
            $holding = json_decode($response->getBody());
            $instanceId = $holding->instanceId;
        }
        $response = $this->makeRequest(
            'GET', '/inventory/instances/' . $instanceId
        );
        return json_decode($response->getBody());
    }

    /**
     * Given an instance object or identifer, or a holding or item identifier,
     * determine an appropriate value to use as VuFind's bibliographic ID.
     *
     * @param string $instanceOrInstanceId Instance object or ID (will be looked up
     * using holding or item ID if not provided)
     * @param string $holdingId            Holding-level id (optional)
     * @param string $itemId               Item-level id (optional)
     *
     * @return string Appropriate bib id retrieved from FOLIO identifiers
     */
    protected function getBibId($instanceOrInstanceId = null, $holdingId = null,
        $itemId = null
    ) {
        $idType = $this->getBibIdType();

        // Special case: if we're using instance IDs and we already have one,
        // short-circuit the lookup process:
        if ($idType === 'instance' && is_string($instanceOrInstanceId)) {
            return $instanceOrInstanceId;
        }

        $instance = is_object($instanceOrInstanceId)
            ? $instanceOrInstanceId
            : $this->getInstanceById($instanceOrInstanceId, $holdingId, $itemId);

        switch ($idType) {
        case 'hrid':
            return $instance->hrid;
        case 'instance':
            return $instance->id;
        }

        throw new \Exception('Unsupported ID type: ' . $idType);
    }

    /**
     * Escape a string for use in a CQL query.
     *
     * @param string $in Input string
     *
     * @return string
     */
    protected function escapeCql($in)
    {
        return str_replace('"', '\"', str_replace('&', '%26', $in));
    }

    /**
     * Retrieve FOLIO instance using VuFind's chosen bibliographic identifier.
     *
     * @param string $bibId Bib-level id
     *
     * @throw
     * @return array
     */
    protected function getInstanceByBibId($bibId)
    {
        // Figure out which ID type to use in the CQL query; if the user configured
        // instance IDs, use the 'id' field, otherwise pass the setting through
        // directly:
        $idType = $this->getBibIdType();
        $idField = $idType === 'instance' ? 'id' : $idType;

        $query = [
            'query' => '(' . $idField . '=="' . $this->escapeCql($bibId) . '")'
        ];
        $response = $this->makeRequest('GET', '/instance-storage/instances', $query);
        $instances = json_decode($response->getBody());
        if (count($instances->instances) == 0) {
            throw new ILSException("Item Not Found");
        }
        return $instances->instances[0];
    }

    /**
     * Get raw object of item from inventory/items/
     *
     * @param string $itemId Item-level id
     *
     * @return array
     */
    public function getStatus($itemId)
    {
        return $this->getHolding($itemId);
    }

    /**
     * This method calls getStatus for an array of records or implement a bulk method
     *
     * @param array $idList Item-level ids
     *
     * @return array values from getStatus
     */
    public function getStatuses($idList)
    {
        $status = [];
        foreach ($idList as $id) {
            $status[] = $this->getStatus($id);
        }
        return $status;
    }

    /**
     * Retrieves renew, hold and cancel settings from the driver ini file.
     *
     * @param string $function The name of the feature to be checked
     * @param array  $params   Optional feature-specific parameters (array)
     *
     * @return array An array with key-value pairs.
     *
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     */
    public function getConfig($function, $params = null)
    {
        return $this->config[$function] ?? false;
    }

    /**
     * This method queries the ILS for holding information.
     *
     * @param string $bibId   Bib-level id
     * @param array  $patron  Patron login information from $this->patronLogin
     * @param array  $options Extra options (not currently used)
     *
     * @return array An array of associative holding arrays
     *
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     */
    public function getHolding($bibId, array $patron = null, array $options = [])
    {
        $instance = $this->getInstanceByBibId($bibId);
        $query = ['query' => '(instanceId=="' . $instance->id . '")'];
        $holdingResponse = $this->makeRequest(
            'GET',
            '/holdings-storage/holdings',
            $query
        );
        $holdingBody = json_decode($holdingResponse->getBody());
        $items = [];
        for ($i = 0; $i < count($holdingBody->holdingsRecords); $i++) {
            $holding = $holdingBody->holdingsRecords[$i];
            $locationName = '';
            if (!empty($holding->permanentLocationId)) {
                $locationResponse = $this->makeRequest(
                    'GET',
                    '/locations/' . $holding->permanentLocationId
                );
                $location = json_decode($locationResponse->getBody());
                $locationName = $location->name;
            }

            $query = ['query' => '(holdingsRecordId=="' . $holding->id . '")'];
            $itemResponse = $this->makeRequest('GET', '/item-storage/items', $query);
            $itemBody = json_decode($itemResponse->getBody());
            for ($j = 0; $j < count($itemBody->items); $j++) {
                $item = $itemBody->items[$j];
                $items[] = [
                    'id' => $bibId,
                    'item_id' => $itemBody->items[$j]->id,
                    'holding_id' => $holding->id,
                    'number' => count($items),
                    'barcode' => $item->barcode ?? '',
                    'status' => $item->status->name,
                    'availability' => $item->status->name == 'Available',
                    'notes' => $item->notes ?? [],
                    'callnumber' => $holding->callNumber ?? '',
                    'location' => $locationName,
                    'reserve' => 'TODO',
                    'addLink' => true
                ];
            }
        }
        return $items;
    }

    /**
     * Patron Login
     *
     * This is responsible for authenticating a patron against the catalog.
     *
     * @param string $username The patron username
     * @param string $password The patron password
     *
     * @return mixed Associative array of patron info on successful login,
     *               null on unsuccessful login.
     *
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     */
    public function patronLogin($username, $password)
    {
        // Get user id
        $query = ['query' => 'username == ' . $username];
        $response = $this->makeRequest('GET', '/users', $query);
        $json = json_decode($response->getBody());
        if (count($json->users) == 0) {
            throw new ILSException("User not found");
        }
        $profile = $json->users[0];
        $credentials = [
            'userId' => $profile->id,
            'username' => $username,
            'password' => $password,
        ];
        // Get token
        try {
            $response = $this->makeRequest(
                'POST',
                '/authn/login',
                json_encode($credentials)
            );
            // Replace admin with user as tenant
            $this->token = $response->getHeaders()->get('X-Okapi-Token')
                ->getFieldValue();
            $this->debug(
                'User logged in. User: ' . $username . '.' .
                ' Token: ' . substr($this->token, 0, 30) . '...'
            );
            return [
                'id' => $profile->id,
                'username' => $username,
                'cat_username' => $username,
                'cat_password' => $password,
                'firstname' => $profile->personal->firstName ?? null,
                'lastname' => $profile->personal->lastName ?? null,
                'email' => $profile->personal->email ?? null,
            ];
        } catch (Exception $e) {
            return null;
        }
    }

    /**
     * This method queries the ILS for a patron's current profile information
     *
     * @param array $patron Patron login information from $this->patronLogin
     *
     * @return array Profile data in associative array
     */
    public function getMyProfile($patron)
    {
        $query = ['query' => 'username == "' . $patron['username'] . '"'];
        $response = $this->makeRequest('GET', '/users', $query);
        $users = json_decode($response->getBody());
        $profile = $users->users[0];
        return [
            'id' => $profile->id,
            'firstname' => $profile->personal->firstName ?? null,
            'lastname' => $profile->personal->lastName ?? null,
            'address1' => $profile->personal->addresses[0]->addressLine1 ?? null,
            'city' => $profile->personal->addresses[0]->city ?? null,
            'country' => $profile->personal->addresses[0]->countryId ?? null,
            'zip' => $profile->personal->addresses[0]->postalCode ?? null,
            'phone' => $profile->personal->phone ?? null,
            'mobile_phone' => $profile->personal->mobilePhone ?? null,
            'expiration_date' => $profile->expirationDate ?? null,
        ];
    }

    /**
     * This method queries the ILS for a patron's current checked out items
     *
     * Input: Patron array returned by patronLogin method
     * Output: Returns an array of associative arrays.
     *         Each associative array contains these keys:
     *         duedate - The item's due date (a string).
     *         dueTime - The item's due time (a string, optional).
     *         dueStatus - A special status – may be 'due' (for items due very soon)
     *                     or 'overdue' (for overdue items). (optional).
     *         id - The bibliographic ID of the checked out item.
     *         source - The search backend from which the record may be retrieved
     *                  (optional - defaults to Solr). Introduced in VuFind 2.4.
     *         barcode - The barcode of the item (optional).
     *         renew - The number of times the item has been renewed (optional).
     *         renewLimit - The maximum number of renewals allowed
     *                      (optional - introduced in VuFind 2.3).
     *         request - The number of pending requests for the item (optional).
     *         volume – The volume number of the item (optional).
     *         publication_year – The publication year of the item (optional).
     *         renewable – Whether or not an item is renewable
     *                     (required for renewals).
     *         message – A message regarding the item (optional).
     *         title - The title of the item (optional – only used if the record
     *                                        cannot be found in VuFind's index).
     *         item_id - this is used to match up renew responses and must match
     *                   the item_id in the renew response.
     *         institution_name - Display name of the institution that owns the item.
     *         isbn - An ISBN for use in cover image loading
     *                (optional – introduced in release 2.3)
     *         issn - An ISSN for use in cover image loading
     *                (optional – introduced in release 2.3)
     *         oclc - An OCLC number for use in cover image loading
     *                (optional – introduced in release 2.3)
     *         upc - A UPC for use in cover image loading
     *               (optional – introduced in release 2.3)
     *         borrowingLocation - A string describing the location where the item
     *                         was checked out (optional – introduced in release 2.4)
     *
     * @param array $patron Patron login information from $this->patronLogin
     *
     * @return array Transactions associative arrays
     */
    public function getMyTransactions($patron)
    {
        $query = ['query' => 'userId==' . $patron['id']];
        $response = $this->makeRequest("GET", '/circulation/loans', $query);
        $json = json_decode($response->getBody());
        if (count($json->loans) == 0) {
            return [];
        }
        $transactions = [];
        foreach ($json->loans as $trans) {
            $date = date_create($trans->dueDate);
            $transactions[] = [
                'duedate' => date_format($date, "j M Y"),
                'dueTime' => date_format($date, "g:i:s a"),
                // TODO: Due Status
                // 'dueStatus' => $trans['itemId'],
                'id' => $this->getBibId($trans->item->instanceId),
                'item_id' => $trans->item->id,
                'barcode' => $trans->item->barcode,
                'title' => $trans->item->title,
            ];
        }
        return $transactions;
    }

    /**
     * Get Pick Up Locations
     *
     * This is responsible get a list of valid locations for holds / recall
     * retrieval
     *
     * @param array $patron Patron information returned by $this->patronLogin
     *
     * @return array An array of associative arrays with locationID and
     * locationDisplay keys
     */
    public function getPickupLocations($patron)
    {
        $response = $this->makeRequest('GET', '/locations');
        $json = json_decode($response->getBody());
        $locations = [];
        foreach ($json->locations as $location) {
            $locations[] = [
                'locationID' => $location->id,
                'locationDisplay' => $location->name
            ];
        }
        return $locations;
    }

    /**
     * This method queries the ILS for a patron's current holds
     *
     * Input: Patron array returned by patronLogin method
     * Output: Returns an array of associative arrays, one for each hold associated
     * with the specified account. Each associative array contains these keys:
     *     type - A string describing the type of hold – i.e. hold vs. recall
     * (optional).
     *     id - The bibliographic record ID associated with the hold (optional).
     *     source - The search backend from which the record may be retrieved
     * (optional - defaults to Solr). Introduced in VuFind 2.4.
     *     location - A string describing the pickup location for the held item
     * (optional). In VuFind 1.2, this should correspond with a locationID value from
     * getPickUpLocations. In VuFind 1.3 and later, it may be either
     * a locationID value or a raw ready-to-display string.
     *     reqnum - A control number for the request (optional).
     *     expire - The expiration date of the hold (a string).
     *     create - The creation date of the hold (a string).
     *     position – The position of the user in the holds queue (optional)
     *     available – Whether or not the hold is available (true/false) (optional)
     *     item_id – The item id the request item (optional).
     *     volume – The volume number of the item (optional)
     *     publication_year – The publication year of the item (optional)
     *     title - The title of the item
     * (optional – only used if the record cannot be found in VuFind's index).
     *     isbn - An ISBN for use in cover image loading (optional)
     *     issn - An ISSN for use in cover image loading (optional)
     *     oclc - An OCLC number for use in cover image loading (optional)
     *     upc - A UPC for use in cover image loading (optional)
     *     cancel_details - The cancel token, or a blank string if cancel is illegal
     * for this hold; if omitted, this will be dynamically generated using
     * getCancelHoldDetails(). You should only fill this in if it is more efficient
     * to calculate the value up front; if it is an expensive calculation, you should
     * omit the value entirely and let getCancelHoldDetails() do its job on demand.
     * This optional feature was introduced in release 3.1.
     *
     * @param array $patron Patron login information from $this->patronLogin
     *
     * @return array Associative array of holds information
     */
    public function getMyHolds($patron)
    {
        $query = [
            'query' => 'requesterId == "' . $patron['id'] . '"'
        ];
        $response = $this->makeRequest('GET', '/request-storage/requests', $query);
        $json = json_decode($response->getBody());
        $holds = [];
        foreach ($json->requests as $hold) {
            $requestDate = date_create($hold->requestDate);
            // Set expire date if it was included in the response
            $expireDate = isset($hold->requestExpirationDate)
                ? date_create($hold->requestExpirationDate) : null;
            $holds[] = [
                'type' => 'Hold',
                'create' => date_format($requestDate, "j M Y"),
                'expire' => isset($expireDate)
                    ? date_format($expireDate, "j M Y") : "",
                'id' => $this->getBibId(null, null, $hold->itemId),
                'title' => $hold->item->title
            ];
        }
        return $holds;
    }

    /**
     * Place Hold
     *
     * Attempts to place a hold or recall on a particular item and returns
     * an array with result details.
     *
     * @param array $holdDetails An array of item and patron data
     *
     * @return mixed An array of data on the request including
     * whether or not it was successful and a system message (if available)
     */
    public function placeHold($holdDetails)
    {
        try {
            $requiredBy = date_create_from_format(
                'm-d-Y',
                $holdDetails['requiredBy']
            );
        } catch (Exception $e) {
            throw new ILSException('hold_date_invalid');
        }
        // Get status to separate Holds (on checked out items) and Pages on a
        $status = $this->getStatus($holdDetails['item_id']);
        $requestBody = [
            'requestType' => $item->status->name == 'Available' ? 'Page' : 'Hold',
            'requestDate' => date('c'),
            'requesterId' => $holdDetails['patron']['id'],
            'requester' => [
                'firstName' => $holdDetails['patron']['firstname'] ?? '',
                'lastName' => $holdDetails['patron']['lastname'] ?? ''
            ],
            'itemId' => $holdDetails['item_id'],
            'fulfilmentPreference' => 'Hold Shelf',
            'requestExpirationDate' => date_format($requiredBy, 'Y-m-d'),
        ];
        $response = $this->makeRequest(
            'POST',
            '/request-storage/requests',
            json_encode($requestBody)
        );
        if ($response->isSuccess()) {
            return [
                'success' => true,
                'status' => $response->getBody()
            ];
        } else {
            throw new ILSException($response->getBody());
        }
    }

    /**
     * Obtain a list of course resources, creating an id => value associative array.
     *
     * @param string $type        Type of resource to retrieve from the API.
     * @param string $responseKey Key containing useful values in response (defaults
     * to $type if unspecified)
     * @param string $valueKey    Key containing value to extract from response
     * (defaults to 'name')
     *
     * @return array
     */
    protected function getCourseResourceList($type, $responseKey = null,
        $valueKey = 'name'
    ) {
        $retVal = [];
        $limit = 1000; // how many records to retrieve at once
        $offset = 0;

        // Results can be paginated, so let's loop until we've gotten everything:
        do {
            $response = $this->makeRequest(
                'GET',
                '/coursereserves/' . $type,
                compact('offset', 'limit')
            );
            $json = json_decode($response->getBody());
            $total = $json->totalRecords ?? 0;
            $preCount = count($retVal);
            foreach ($json->{$responseKey ?? $type} ?? [] as $item) {
                $retVal[$item->id] = $item->$valueKey ?? '';
            }
            $postCount = count($retVal);
            $offset += $limit;
            // Loop has a safety valve: if the count of records doesn't change
            // in a full iteration, something has gone wrong, and we should stop
            // so we don't loop forever!
        } while ($total && $postCount < $total && $preCount != $postCount);
        return $retVal;
    }

    /**
     * Get Departments
     *
     * Obtain a list of departments for use in limiting the reserves list.
     *
     * @return array An associative array with key = dept. ID, value = dept. name.
     */
    public function getDepartments()
    {
        return $this->getCourseResourceList('departments');
    }

    /**
     * Get Instructors
     *
     * Obtain a list of instructors for use in limiting the reserves list.
     *
     * @return array An associative array with key = ID, value = name.
     */
    public function getInstructors()
    {
        $retVal = [];
        $ids = array_keys(
            $this->getCourseResourceList('courselistings', 'courseListings')
        );
        foreach ($ids as $id) {
            $retVal += $this->getCourseResourceList(
                'courselistings/' . $id . '/instructors', 'instructors'
            );
        }
        return $retVal;
    }

    /**
     * Get Courses
     *
     * Obtain a list of courses for use in limiting the reserves list.
     *
     * @return array An associative array with key = ID, value = name.
     */
    public function getCourses()
    {
        return $this->getCourseResourceList('courses');
    }

    /**
     * Given a course listing ID, get an array of associated courses.
     *
     * @param string $courseListingId Course listing ID
     *
     * @return array
     */
    protected function getCourseDetails($courseListingId)
    {
        $values = empty($courseListingId)
            ? []
            : $this->getCourseResourceList(
                'courselistings/' . $courseListingId . '/courses',
                'courses',
                'departmentId'
            );
        // Return an array with empty values in it if we can't find any values,
        // because we want to loop at least once to build our reserves response.
        return empty($values) ? ['' => ''] : $values;
    }

    /**
     * Given a course listing ID, get an array of associated instructors.
     *
     * @param string $courseListingId Course listing ID
     *
     * @return array
     */
    protected function getInstructorIds($courseListingId)
    {
        $values = empty($courseListingId)
            ? []
            : $this->getCourseResourceList(
                'courselistings/' . $courseListingId . '/instructors', 'instructors'
            );
        // Return an array with null in it if we can't find any values, because
        // we want to loop at least once to build our course reserves response.
        return empty($values) ? [null] : array_keys($values);
    }

    /**
     * Find Reserves
     *
     * Obtain information on course reserves.
     *
     * @param string $course ID from getCourses (empty string to match all)
     * @param string $inst   ID from getInstructors (empty string to match all)
     * @param string $dept   ID from getDepartments (empty string to match all)
     *
     * @return mixed An array of associative arrays representing reserve items.
     */
    public function findReserves($course, $inst, $dept)
    {
        $retVal = [];
        $limit = 1000; // how many records to retrieve at once
        $offset = 0;

        // Results can be paginated, so let's loop until we've gotten everything:
        do {
            $response = $this->makeRequest(
                'GET',
                '/coursereserves/reserves',
                compact('offset', 'limit')
            );
            $json = json_decode($response->getBody());
            $total = $json->totalRecords ?? 0;
            $preCount = count($retVal);
            foreach ($json->reserves ?? [] as $item) {
                try {
                    $bibId = $this->getBibId(null, null, $item->itemId);
                } catch (\Exception $e) {
                    $bibId = null;
                }
                if ($bibId !== null) {
                    $courseData = $this->getCourseDetails(
                        $item->courseListingId ?? null
                    );
                    $instructorIds = $this->getInstructorIds(
                        $item->courseListingId ?? null
                    );
                    foreach ($courseData as $courseId => $departmentId) {
                        foreach ($instructorIds as $instructorId) {
                            $retVal[] = [
                                'BIB_ID' => $bibId,
                                'COURSE_ID' => $courseId == '' ? null : $courseId,
                                'DEPARTMENT_ID' => $departmentId == ''
                                    ? null : $departmentId,
                                'INSTRUCTOR_ID' => $instructorId,
                            ];
                        }
                    }
                }
            }
            $postCount = count($retVal);
            $offset += $limit;
            // Loop has a safety valve: if the count of records doesn't change
            // in a full iteration, something has gone wrong, and we should stop
            // so we don't loop forever!
        } while ($total && $postCount < $total && $preCount != $postCount);

        // If the user has requested a filter, apply it now:
        if (!empty($course) || !empty($inst) || !empty($dept)) {
            $filter = function ($value) use ($course, $inst, $dept) {
                return (empty($course) || $course == $value['COURSE_ID'])
                    && (empty($inst) || $inst == $value['INSTRUCTOR_ID'])
                    && (empty($dept) || $dept == $value['DEPARTMENT_ID']);
            };
            return array_filter($retVal, $filter);
        }
        return $retVal;
    }

    // @codingStandardsIgnoreStart
    /** NOT FINISHED BELOW THIS LINE **/

    /**
     * Check for request blocks.
     *
     * @param array $patron The patron array with username and password
     *
     * @return array|boolean    An array of block messages or false if there are no
     *                          blocks
     * @author Michael Birkner
     */
    public function getRequestBlocks($patron)
    {
        return false;
    }

    /**
     * This method returns information on recently received issues of a serial.
     *
     *     Input: Bibliographic record ID
     *     Output: Array of associative arrays, each with a single key:
     *         issue - String describing the issue
     *
     * Currently, most drivers do not implement this method, instead always returning
     * an empty array. It is only necessary to implement this in more detail if you
     * want to populate the “Most Recent Received Issues” section of the record
     * holdings tab.
     */
    public function getPurchaseHistory($bibID)
    {
        return [];
    }
    /**
     * This method queries the ILS for a patron's current fines
     *
     *     Input: Patron array returned by patronLogin method
     *     Output: Returns an array of associative arrays, one for each fine
     * associated with the specified account. Each associative array contains
     * these keys:
     *         amount - The total amount of the fine IN PENNIES. Be sure to adjust
     * decimal points appropriately (i.e. for a $1.00 fine, amount should be 100).
     *         checkout - A string representing the date when the item was
     * checked out.
     *         fine - A string describing the reason for the fine
     * (i.e. “Overdue”, “Long Overdue”).
     *         balance - The unpaid portion of the fine IN PENNIES.
     *         createdate – A string representing the date when the fine was accrued
     * (optional)
     *         duedate - A string representing the date when the item was due.
     *         id - The bibliographic ID of the record involved in the fine.
     *         source - The search backend from which the record may be retrieved
     * (optional - defaults to Solr). Introduced in VuFind 2.4.
     *
     */
    public function getMyFines($patron)
    {
        $query = ['query' => 'userId==' . $patron['id'] . ' and status.name<>Closed'];
        $response = $this->makeRequest("GET", '/accounts', $query);
        $json = json_decode($response->getBody());
        if (count($json->accounts) == 0) {
            return [];
        }
        $fines = [];
        foreach ($json->accounts as $fine) {
            $date = date_create($fine->metadata->createdDate);
            $title = (isset($fine->title) ? $fine->title : null);
            $fines[] = [
                'id' => $fine->id,
                'amount' => $fine->amount * 100,
                'balance' => $fine->remaining * 100,
                'status' => $fine->paymentStatus->name,
                'type' => $fine->feeFineType,
                'title' => $title,
                'createdate' => date_format($date, "j M Y")
            ];
        }
        return $fines;
    }

    /**
     * Get a list of funds that can be used to limit the “new item” search. Note that
     * “fund” may be a misnomer – if funds are not an appropriate way to limit your
     * new item results, you can return a different set of values from this function.
     * For example, you might just make this a wrapper for getDepartments(). The
     * important thing is that whatever you return from this function, the IDs can be
     * used as a limiter to the getNewItems() function, and the names are appropriate
     * for display on the new item search screen. If you do not want or support such
     * limits, just return an empty array here and the limit control on the new item
     * search screen will disappear.
     *
     *     Output: An associative array with key = fund ID, value = fund name.
     *
     * IMPORTANT: The return value for this method changed in r2184. If you are using
     * VuFind 1.0RC2 or earlier, this function returns a flat array of options
     * (no ID-based keys), and empty return values may cause problems. It is
     * recommended that you update to newer code before implementing the new item
     * feature in your driver.
     */
    public function getFunds()
    {
        return [];
    }

    /**
     * This method retrieves a patron's historic transactions
     * (previously checked out items).
     *
     * :!: The getConfig method must return a non-false value for this feature to be
     * enabled. For privacy reasons, the entire feature should be disabled by default
     * unless explicitly turned on in the driver's .ini file.
     *
     * This feature was added in VuFind 5.0.
     *
     *     getConfig may return the following keys if the service supports paging on
     * the ILS side:
     *         max_results - Maximum number of results that can be requested at once.
     * Overrides the config.ini Catalog section setting historic_loan_page_size.
     *         page_size - An array of allowed page sizes
     * (number of records per page)
     *         default_page_size - Default number of records per page
     *     getConfig may return the following keys if the service supports sorting:
     *         sort - An associative array where each key is a sort key and its
     * value is a translation key
     *         default_sort - Default sort key
     *     Input: Patron array returned by patronLogin method and an array of
     * optional parameters (keys = 'limit', 'page', 'sort').
     *     Output: Returns an array of associative arrays containing some or all of
     * these keys:
     *         title - item title
     *         checkoutDate - date checked out
     *         dueDate - date due
     *         id - bibliographic ID
     *         barcode - item barcode
     *         returnDate - date returned
     *         publication_year - publication year
     *         volume - item volume
     *         institution_name - owning institution
     *         borrowingLocation - checkout location
     *         message - message about the transaction
     *
     */
    public function getMyTransactionHistory($patron)
    {
        return[];
    }

    /**
     * This method queries the ILS for new items
     *
     *     Input: getNewItems takes the following parameters:
     *         page - page number of results to retrieve (counting starts at 1)
     *         limit - the size of each page of results to retrieve
     *         daysOld - the maximum age of records to retrieve in days (maximum 30)
     *         fundID - optional fund ID to use for limiting results (use a value
     * returned by getFunds, or exclude for no limit); note that “fund” may be a
     * misnomer – if funds are not an appropriate way to limit your new item results,
     * you can return a different set of values from getFunds. The important thing is
     * that this parameter supports an ID returned by getFunds, whatever that may
     * mean.
     *     Output: An associative array with two keys: 'count' (the number of items
     * in the 'results' array) and 'results' (an array of associative arrays, each
     * with a single key: 'id', a record ID).
     *
     * IMPORTANT: The fundID parameter changed behavior in r2184. In VuFind 1.0RC2
     * and earlier versions, it receives one of the VALUES returned by getFunds();
     * in more recent code, it receives one of the KEYS from getFunds(). See getFunds
     * for additional notes.
     */
    public function getNewItems($page = 1, $limit, $daysOld = 30, $fundID = null)
    {
        return [];
    }

    // @codingStandardsIgnoreEnd
}