From abcf3e95b587ba7822e3449fe15b36fbfdebfa06 Mon Sep 17 00:00:00 2001
From: Chris Hallberg <crhallberg@gmail.com>
Date: Fri, 13 Jul 2018 07:40:25 -0400
Subject: [PATCH] Initial work on Alma driver.

---
 config/vufind/Alma.ini                       |  27 +
 config/vufind/config.ini                     |   1 +
 module/VuFind/src/VuFind/ILS/Driver/Alma.php | 708 +++++++++++++++++++
 3 files changed, 736 insertions(+)
 create mode 100644 config/vufind/Alma.ini
 create mode 100644 module/VuFind/src/VuFind/ILS/Driver/Alma.php

diff --git a/config/vufind/Alma.ini b/config/vufind/Alma.ini
new file mode 100644
index 00000000000..4e09d407615
--- /dev/null
+++ b/config/vufind/Alma.ini
@@ -0,0 +1,27 @@
+[Catalog]
+; The base URL for your Alma instance (example is public demo):
+apiBaseUrl = "https://api-eu.hosted.exlibrisgroup.com/almaws/v1"
+; An API key configured to allow access to Alma:
+apiKey = "your-key-here"
+
+[Holds]
+; HMACKeys - A list of hold form element names that will be analyzed for consistency
+; during hold form processing. Most users should not need to change this setting.
+HMACKeys = id:item_id:holding_id
+
+; defaultRequiredDate - A colon-separated list used to set the default "not required
+; after" date for holds in the format days:months:years
+; e.g. 0:1:0 will set a "not required after" date of 1 month from the current date
+defaultRequiredDate = 0:1:0
+
+; extraHoldFields - A colon-separated list used to display extra visible fields in the
+; place holds form. Supported values are "comments", "requiredByDate",
+; "pickUpLocation" and "requestGroup"
+extraHoldFields = comments:requiredByDate:pickUpLocation
+
+; A Pick Up Location Code used to pre-select the pick up location drop down list and
+; provide a default option if others are not available. Must be one of the following:
+; 1) empty string to indicate that the first location is default (default setting)
+; 2) "user-selected" to indicate that the user always has to choose the location
+; 3) a value within the Location IDs returned by getPickUpLocations()
+defaultPickUpLocation = ""
diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index 16839227931..c5f897538d5 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -191,6 +191,7 @@ session_name = VUFIND_SESSION
 ;
 ; Available drivers:
 ;   - Aleph
+;   - Alma
 ;   - Amicus
 ;   - DAIA (using either XML or JSON API)
 ;   - Demo (fake ILS driver returning complex responses)
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Alma.php b/module/VuFind/src/VuFind/ILS/Driver/Alma.php
new file mode 100644
index 00000000000..f826980a074
--- /dev/null
+++ b/module/VuFind/src/VuFind/ILS/Driver/Alma.php
@@ -0,0 +1,708 @@
+<?php
+/**
+ * Alma ILS Driver
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2017.
+ *
+ * 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., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  ILS_Drivers
+ * @author   Demian Katz <demian.katz@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;
+
+/**
+ * Alma ILS Driver
+ *
+ * @category VuFind
+ * @package  ILS_Drivers
+ * @author   Demian Katz <demian.katz@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 Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
+{
+    use \VuFindHttp\HttpServiceAwareTrait;
+
+    /**
+     * Alma API base URL.
+     *
+     * @var string
+     */
+    protected $baseUrl;
+
+    /**
+     * Alma API key.
+     *
+     * @var string
+     */
+    protected $apiKey;
+
+    protected $dateConverter;
+
+    /**
+     * Constructor
+     *
+     * @param \VuFind\Date\Converter $dateConverter Date converter object
+     */
+    public function __construct(\VuFind\Date\Converter $dateConverter)
+    {
+        $this->dateConverter = $dateConverter;
+    }
+
+    /**
+     * Initialize the driver.
+     *
+     * Validate configuration and perform all resource-intensive tasks needed to
+     * make the driver active.
+     *
+     * @throws ILSException
+     * @return void
+     */
+    public function init()
+    {
+        if (empty($this->config)) {
+            throw new ILSException('Configuration needs to be set.');
+        }
+        $this->baseUrl = $this->config['Catalog']['apiBaseUrl'];
+        $this->apiKey = $this->config['Catalog']['apiKey'];
+    }
+
+    /**
+     * Make an HTTP request against Alma
+     *
+     * @param string $path   Path to retrieve from API (excluding base URL/API key)
+     * @param string $params Additional GET params
+     *
+     * @return \SimpleXMLElement
+     */
+    protected function makeRequest($path, $params = [])
+    {
+        // TODO: Support requests of different methods
+        if (!isset($params['apiKey'])) {
+            $params['apiKey'] = $this->apiKey;
+        }
+        $url = strpos($path, '://') === false ? $this->baseUrl . $path : $path;
+        $client = $this->httpService->createClient($url);
+        $client->setParameterGet($params);
+        $result = $client->send();
+        if ($result->isSuccess()) {
+            return simplexml_load_string($result->getBody());
+        } else {
+            // TODO: Throw an error
+            error_log($client->getUri());
+            error_log(print_r($params, true));
+            error_log($result->getBody());
+        }
+        return null;
+    }
+
+    /**
+     * Given an item, return the availability status.
+     *
+     * @param \SimpleXMLElement $item Item data
+     *
+     * @return bool
+     */
+    protected function getAvailabilityFromItem($item)
+    {
+        return (string)$item->item_data->base_status === '1';
+    }
+
+    /**
+     * Get Holding
+     *
+     * This is responsible for retrieving the holding information of a certain
+     * record.
+     *
+     * @param string $id     The record id to retrieve the holdings for
+     * @param array  $patron Patron data
+     *
+     * @return array         On success, an associative array with the following
+     * keys: id, availability (boolean), status, location, reserve, callnumber,
+     * duedate, number, barcode.
+     */
+    public function getHolding($id, array $patron = null)
+    {
+        $results = [];
+        $copyCount = 0;
+        $bibPath = '/bibs/' . urlencode($id) . '/holdings';
+        if ($holdings = $this->makeRequest($bibPath)) {
+            foreach ($holdings->holding as $holding) {
+                $holdingId = (string)$holding->holding_id;
+                $itemPath = $bibPath . '/' . urlencode($holdingId) . '/items';
+                if ($currentItems = $this->makeRequest($itemPath)) {
+                    foreach ($currentItems->item as $item) {
+                        $barcode = (string)$item->item_data->barcode;
+                        $results[] = [
+                            'id' => $id,
+                            'source' => 'Solr',
+                            'availability' => $this->getAvailabilityFromItem($item),
+                            'status' => (string)$item->item_data->base_status[0]
+                                ->attributes()['desc'],
+                            'location' => (string)$holding->library[0]
+                                ->attributes()['desc'],
+                            'reserve' => 'N',   // TODO: support reserve status
+                            'callnumber' => (string)$item->holding_data->call_number,
+                            'duedate' => null, // TODO: support due dates
+                            'returnDate' => false, // TODO: support recent returns
+                            'number' => ++$copyCount,
+                            'barcode' => empty($barcode) ? 'n/a' : $barcode,
+                            'item_id' => (string)$item->item_data->pid,
+                            'holding_id' => $holdingId,
+                            'addLink' => 'check'
+                        ];
+                    }
+                }
+            }
+        }
+        return $results;
+    }
+
+    /**
+     * Patron Login
+     *
+     * This is responsible for authenticating a patron against the catalog.
+     *
+     * @param string $barcode  The patron barcode
+     * @param string $password The patron password
+     *
+     * @throws ILSException
+     * @return mixed           Associative array of patron info on successful login,
+     * null on unsuccessful login.
+     */
+    public function patronLogin($barcode, $password)
+    {
+        $client = $this->httpService->createClient(
+            $this->baseUrl . '/users/' . $barcode
+            . '?apiKey=' . urlencode($this->apiKey)
+            . '&op=auth&password=' . urlencode(trim($password))
+        );
+        $client->setMethod(\Zend\Http\Request::METHOD_POST);
+        $response = $client->send();
+        // Test once we have POST access
+        if ($response->isSuccess()) {
+            return [
+                'cat_username' => trim($barcode),
+                'cat_password' => trim($password)
+            ];
+        }
+        return null;
+    }
+
+    /**
+     * Get Patron Profile
+     *
+     * This is responsible for retrieving the profile for a specific patron.
+     *
+     * @param array $patron The patron array
+     *
+     * @return array Array of the patron's profile data on success.
+     */
+    public function getMyProfile($patron)
+    {
+        $xml = $this->makeRequest('/users/' . $patron['cat_username']);
+        if (empty($xml)) {
+            return [];
+        }
+        $profile = [
+            'firstname' => $xml->first_name,
+            'lastname'  => $xml->last_name,
+            'group'     => $xml->user_group['desc']
+        ];
+        $contact = $xml->contact_info;
+        if ($contact) {
+            if ($contact->addresses) {
+                $address = $contact->addresses[0]->address;
+                $profile['address1'] = $address->line1;
+                $profile['address2'] = $address->line2;
+                $profile['address3'] = $address->line3;
+                $profile['zip']      = $address->postal_code;
+                $profile['city']     = $address->city;
+                $profile['country']  = $address->country;
+            }
+            if ($contact->phones) {
+                $profile['phone'] = $contact->phones[0]->phone->phone_number;
+            }
+        }
+        return $profile;
+    }
+
+    /**
+     * Get Patron Fines
+     *
+     * This is responsible for retrieving all fines by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return mixed        Array of the patron's fines on success.
+     */
+    public function getMyFines($patron)
+    {
+        $xml = $this->makeRequest(
+            '/users/' . $patron['cat_username'] . '/fees'
+        );
+        $fineList = [];
+        foreach ($xml as $fee) {
+            $checkout = (string) $fee->status_time;
+            $fineList[] = [
+                "title"   => (string) $fee->type,
+                "amount"   => $fee->original_amount * 100,
+                "balance"  => $fee->balance * 100,
+                "checkout" => $this->dateConverter->convert(
+                    'Y-m-d H:i', 'm-d-Y', $checkout
+                ),
+                "fine"     => (string) $fee->type['desc']
+            ];
+        }
+        return $fineList;
+    }
+
+    /**
+     * Get Patron Holds
+     *
+     * This is responsible for retrieving all holds by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return mixed        Array of the patron's holds on success.
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getMyHolds($patron)
+    {
+        $xml = $this->makeRequest(
+            '/users/' . $patron['cat_username'] . '/requests',
+            ['request_type' => 'HOLD']
+        );
+        $holdList = [];
+        foreach ($xml as $request) {
+            $holdList[] = [
+                'create' => (string) $request->request_date,
+                'expire' => (string) $request->last_interest_date,
+                'id' => (string) $request->request_id,
+                'in_transit' => $request->request_status !== 'IN_PROCESS',
+                'item_id' => (string) $request->mms_id,
+                'location' => (string) $request->pickup_location,
+                'processed' => $request->item_policy === 'InterlibraryLoan'
+                    && $request->request_status !== 'NOT_STARTED',
+                'title' => (string) $request->title,
+                /*
+                // VuFind keys
+                'available'         => $request->,
+                'canceled'          => $request->,
+                'institution_dbkey' => $request->,
+                'institution_id'    => $request->,
+                'institution_name'  => $request->,
+                'position'          => $request->,
+                'reqnum'            => $request->,
+                'requestGroup'      => $request->,
+                'source'            => $request->,
+                // Alma keys
+                "author": null,
+                "comment": null,
+                "desc": "Book"
+                "description": null,
+                "material_type": {
+                "pickup_location": "Burns",
+                "pickup_location_library": "BURNS",
+                "pickup_location_type": "LIBRARY",
+                "place_in_queue": 1,
+                "request_date": "2013-11-12Z"
+                "request_id": "83013520000121",
+                "request_status": "NOT_STARTED",
+                "request_type": "HOLD",
+                "title": "Test title",
+                "value": "BK",
+                */
+            ];
+        }
+        return $holdList;
+    }
+
+    /**
+     * Get Patron Storage Retrieval Requests
+     *
+     * This is responsible for retrieving all call slips by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return mixed        Array of the patron's holds
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getMyStorageRetrievalRequests($patron)
+    {
+        $xml = $this->makeRequest(
+            '/users/' . $patron['cat_username'] . '/requests',
+            ['request_type' => 'MOVE']
+        );
+        $holdList = [];
+        for ($i = 0; $i < count($xml->user_requests); $i++) {
+            $request = $xml->user_requests[$i];
+            if (!isset($request->item_policy)
+                || $request->item_policy !== 'Archive'
+            ) {
+                continue;
+            }
+            $holdList[] = [
+                'create' => $request->request_date,
+                'expire' => $request->last_interest_date,
+                'id' => $request->request_id,
+                'in_transit' => $request->request_status !== 'IN_PROCESS',
+                'item_id' => $request->mms_id,
+                'location' => $request->pickup_location,
+                'processed' => $request->item_policy === 'InterlibraryLoan'
+                    && $request->request_status !== 'NOT_STARTED',
+                'title' => $request->title,
+            ];
+        }
+        return $holdList;
+    }
+
+    /**
+     * Get Patron ILL Requests
+     *
+     * This is responsible for retrieving all ILL requests by a specific patron.
+     *
+     * @param array $patron The patron array from patronLogin
+     *
+     * @return mixed        Array of the patron's ILL requests
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getMyILLRequests($patron)
+    {
+        $xml = $this->makeRequest(
+            '/users/' . $patron['cat_username'] . '/requests',
+            ['request_type' => 'MOVE']
+        );
+        $holdList = [];
+        for ($i = 0; $i < count($xml->user_requests); $i++) {
+            $request = $xml->user_requests[$i];
+            if (!isset($request->item_policy)
+                || $request->item_policy !== 'InterlibraryLoan'
+            ) {
+                continue;
+            }
+            $holdList[] = [
+                'create' => $request->request_date,
+                'expire' => $request->last_interest_date,
+                'id' => $request->request_id,
+                'in_transit' => $request->request_status !== 'IN_PROCESS',
+                'item_id' => $request->mms_id,
+                'location' => $request->pickup_location,
+                'processed' => $request->item_policy === 'InterlibraryLoan'
+                    && $request->request_status !== 'NOT_STARTED',
+                'title' => $request->title,
+            ];
+        }
+        return $holdList;
+    }
+
+    /**
+     * Get Status
+     *
+     * This is responsible for retrieving the status information of a certain
+     * record.
+     *
+     * @param string $id The record id to retrieve the holdings for
+     *
+     * @return mixed     On success, an associative array with the following keys:
+     * id, availability (boolean), status, location, reserve, callnumber.
+     */
+    public function getStatus($id)
+    {
+        return $this->getHolding($id);
+    }
+
+    /**
+     * Get Statuses
+     *
+     * This is responsible for retrieving the status information for a
+     * collection of records.
+     *
+     * @param array $ids The array of record ids to retrieve the status for
+     *
+     * @return array An array of getStatus() return values on success.
+     */
+    public function getStatuses($ids)
+    {
+        $results = [];
+        $copyCount = 0;
+        $params = [
+            'mms_id' => implode(',', $ids),
+            'expand' => 'p_avail,e_avail,d_avail'
+        ];
+        if ($bibs = $this->makeRequest('/bibs', $params)) {
+            foreach ($bibs as $num => $bib) {
+                $marc = new \File_MARCXML(
+                    $bib->record->asXML(),
+                    \File_MARCXML::SOURCE_STRING
+                );
+                $status = [];
+                $tmpl = [
+                    'id' => (string) $bib->mms_id,
+                    'source' => 'Solr',
+                    'callnumber' => isset($bib->isbn)
+                        ? (string) $bib->isbn
+                        : ''
+                ];
+                if ($record = $marc->next()) {
+                    // Physical
+                    $physicalItems = $record->getFields('AVA');
+                    foreach ($physicalItems as $field) {
+                        $avail = $field->getSubfield('e')->getData();
+                        $item = $tmpl;
+                        $item['availability'] = strtolower($avail) === 'available';
+                        $item['location'] = (string) $field->getSubfield('c')
+                            ->getData();
+                        $status[] = $item;
+                    }
+                    // Electronic
+                    $electronicItems = $record->getFields('AVE');
+                    foreach ($electronicItems as $field) {
+                        $avail = $field->getSubfield('e')->getData();
+                        $item = $tmpl;
+                        $item['availability'] = strtolower($avail) === 'available';
+                        $status[] = $item;
+                    }
+                    // Digital
+                    $digitalItems = $record->getFields('AVD');
+                    foreach ($digitalItems as $field) {
+                        $avail = $field->getSubfield('e')->getData();
+                        $item = $tmpl;
+                        $item['availability'] = strtolower($avail) === 'available';
+                        $status[] = $item;
+                    }
+                } else {
+                    // TODO: Throw error
+                    error_log('no record');
+                }
+                $results[] = $status;
+            }
+        }
+        return $results;
+    }
+
+    /**
+     * Get Purchase History
+     *
+     * This is responsible for retrieving the acquisitions history data for the
+     * specific record (usually recently received issues of a serial).
+     *
+     * @param string $id The record id to retrieve the info for
+     *
+     * @return array     An array with the acquisitions data on success.
+     */
+    public function getPurchaseHistory($id)
+    {
+        // TODO: Alma getPurchaseHistory
+        return [];
+    }
+
+    /**
+     * Public Function which 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)
+    {
+        if (isset($this->config[$function])) {
+            $functionConfig = $this->config[$function];
+        } else {
+            $functionConfig = false;
+        }
+
+        return $functionConfig;
+    }
+
+    /**
+     * Ref: https://developers.exlibrisgroup.com/alma/apis/users
+     * POST /almaws/v1/users/{user_id}/requests
+     *
+     * @param array $holdDetails An associative array w/ atleast patron and item_id
+     *
+     * @return array success: bool, sysMessage: string
+     */
+    public function placeHold($holdDetails)
+    {
+        $client = $this->httpService->createClient(
+            $this->baseUrl . '/bibs/' . $holdDetails['id']
+            . '/holdings/' . urlencode($holdDetails['holding_id'])
+            . '/items/' . urlencode($holdDetails['item_id'])
+            . '/requests?apiKey=' . urlencode($this->apiKey)
+            . '&user_id=' . urlencode($holdDetails['patron']['cat_username'])
+            . '&format=json'
+        );
+        $client->setHeaders(
+            [
+                'Content-type: application/json',
+                'Accept: application/json'
+            ]
+        );
+        $client->setMethod(\Zend\Http\Request::METHOD_POST);
+        $body = ['request_type' => 'HOLD'];
+        if (isset($holdDetails['comment']) && !empty($holdDetails['comment'])) {
+            $body['comment'] = $holdDetails['comment'];
+        }
+        if (isset($holdDetails['requiredBy'])) {
+            $date = $this->dateConverter->convertFromDisplayDate(
+                'Y-m-d', $holdDetails['requiredBy']
+            );
+            $body['last_interest_date'] = $date;
+        }
+        if (isset($holdDetails['pickUpLocation'])) {
+            $body['pickup_location_type'] = 'LIBRARY';
+            $body['pickup_location_library'] = $holdDetails['pickUpLocation'];
+        }
+        $client->setRawBody(json_encode($body));
+        $response = $client->send();
+
+        if ($response->isSuccess()) {
+            return [
+                'success' => true,
+                'status' => 'hold_request_success'
+            ];
+        } else {
+            // TODO: Throw an error
+            error_log($response->getBody());
+        }
+        $error = json_decode($response->getBody());
+        if (!$error) {
+            $error = simplexml_load_string($response->getBody());
+        }
+        return [
+            'success' => false,
+            'sysMessage' => $error->errorList->error[0]->errorMessage
+        ];
+    }
+
+    /**
+     * Get Pick Up Locations
+     *
+     * This is responsible get a list of valid library locations for holds / recall
+     * retrieval
+     *
+     * @param array $patron Patron information returned by the patronLogin
+     * method.
+     *
+     * @return array An array of associative arrays with locationID and
+     * locationDisplay keys
+     */
+    public function getPickupLocations($patron)
+    {
+        $xml = $this->makeRequest('/conf/libraries');
+        $libraries = [];
+        foreach ($xml as $library) {
+            $libraries[] = [
+                'locationID' => $library->code,
+                'locationDisplay' => $library->name
+            ];
+        }
+        return $libraries;
+    }
+
+    /**
+     * @return array with key = course ID, value = course name
+     */
+    public function getCourses() {
+        // https://developers.exlibrisgroup.com/alma/apis/courses
+        // GET /​almaws/​v1/​courses
+        $xml = $this->makeRequest('/courses');
+        $courses = [];
+        foreach ($xml as $course) {
+            $courses[$course->id] = $course->name;
+        }
+        return $courses;
+    }
+
+    /**
+     * @param string $courseID     Value from getCourses
+     * @param string $instructorID Value from getInstructors
+     * @param string $departmentID Value from getDepartments
+     *
+     * @return array With key BIB_ID - The record ID of the current reserve item.
+     *               Not currently used:
+     *               DISPLAY_CALL_NO, AUTHOR, TITLE, PUBLISHER, PUBLISHER_DATE
+     */
+    public function findReserves($courseID, $instructorID, $departmentID) {
+        // https://developers.exlibrisgroup.com/alma/apis/courses
+        // GET /​almaws/​v1/​courses/​{course_id}/​reading-lists
+        $xml = $this->makeRequest('/courses/​' . $courseID . '/​reading-lists');
+        $reserves = [];
+        foreach ($xml as $list) {
+            $listXML = $this->makeRequest(
+                "/courses/${$courseID}/reading-lists/${$list->id}/citations"
+            );
+            foreach ($listXML as $citation) {
+                $reserves[$citation->id] = $citation->metadata;
+            }
+        }
+        return $reserves;
+    }
+
+    // @codingStandardsIgnoreStart
+
+    /**
+     * @return array with key = course ID, value = course name
+     * /
+    public function getFunds() {
+        // https://developers.exlibrisgroup.com/alma/apis/acq
+        // GET /​almaws/​v1/​acq/​funds
+    }
+    */
+
+    /**
+     * @param string $bibID Bibligraphic ID
+     *
+     * @return boolean
+     * /
+    public function hasHoldings($bibID) {
+        // https://developers.exlibrisgroup.com/alma/apis/bibs
+        // GET /almaws/v1/bibs/{mms_id}/holdings
+    }
+    */
+
+    /* ================= METHODS INACCESSIBLE OUTSIDE OF GET ================== */
+
+    /**
+     * @param array $cancelDetails An associative array with two keys:
+     *                  patron  (array returned by the driver's patronLogin method)
+     *                  details (array returned by the driver's getCancelHoldDetails)
+     *
+     * @return array count – The number of items successfully cancelled
+     *               items – Associative array where keyed by item_id (getMyHolds)
+     *                    success – Boolean true or false
+     *                    status – A status message from the language file (required)
+     *                    sysMessage - A system supplied failure message (optional)
+     * /
+    public function cancelHolds($cancelDetails) {
+        // https://developers.exlibrisgroup.com/alma/apis/users
+        // DELETE /almaws/v1/users/{user_id}/requests/{request_id}
+    }
+    */
+    // @codingStandardsIgnoreEnd
+}
-- 
GitLab