]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
b6997d16ced19e7ec621fe363b17df4c8f54b646
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / EbookAPI / OverDrive.pm
1 #!/usr/bin/perl
2
3 # Copyright (C) 2015 BC Libraries Cooperative
4 #
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; either version 2
8 # of the License, or (at your option) any later version.
9
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18
19 package OpenILS::Application::EbookAPI::OverDrive;
20
21 use strict;
22 use warnings;
23
24 use OpenILS::Application;
25 use OpenILS::Application::EbookAPI;
26 use base qw/OpenILS::Application::EbookAPI/;
27 use OpenSRF::AppSession;
28 use OpenSRF::EX qw(:try);
29 use OpenSRF::Utils::SettingsClient;
30 use OpenSRF::Utils::Logger qw($logger);
31 use OpenSRF::Utils::Cache;
32 use OpenSRF::Utils::JSON;
33 use OpenILS::Application::AppUtils;
34 use Data::Dumper;
35
36 sub new {
37     my( $class, $args ) = @_;
38     $class = ref $class || $class;
39     return bless $args, $class;
40 }
41
42 sub ou {
43     my $self = shift;
44     return $self->{ou};
45 }
46
47 sub vendor {
48     my $self = shift;
49     return $self->{vendor};
50 }
51
52 sub session_id {
53     my $self = shift;
54     return $self->{session_id};
55 }
56
57 sub account_id {
58     my $self = shift;
59     return $self->{account_id};
60 }
61
62 sub websiteid {
63     my $self = shift;
64     return $self->{websiteid};
65 }
66
67 sub authorizationname {
68     my $self = shift;
69     return $self->{authorizationname};
70 }
71
72 sub basic_token {
73     my $self = shift;
74     return $self->{basic_token};
75 }
76
77 sub bearer_token {
78     my $self = shift;
79     return $self->{bearer_token};
80 }
81
82 sub collection_token {
83     my $self = shift;
84     return $self->{collection_token};
85 }
86
87 sub granted_auth_uri {
88     my $self = shift;
89     return $self->{granted_auth_uri};
90 }
91
92 sub password_required {
93     my $self = shift;
94     return $self->{password_required};
95 }
96
97 sub patron_token {
98     my $self = shift;
99     return $self->{patron_token};
100 }
101
102 sub initialize {
103     my $self = shift;
104     my $ou = $self->{ou};
105
106     my $discovery_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.discovery_base_uri');
107     $self->{discovery_base_uri} = $discovery_base_uri || 'https://api.overdrive.com/v1';
108     my $circulation_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.circulation_base_uri');
109     $self->{circulation_base_uri} = $circulation_base_uri || 'https://patron.api.overdrive.com/v1';
110
111     my $account_id = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.account_id');
112     if ($account_id) {
113         $self->{account_id} = $account_id;
114     } else {
115         $logger->error("EbookAPI: no OverDrive account ID found for org unit $ou");
116         return;
117     }
118
119     my $websiteid = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.websiteid');
120     if ($websiteid) {
121         $self->{websiteid} = $websiteid;
122     } else {
123         $logger->error("EbookAPI: no OverDrive website ID found for org unit $ou");
124         return;
125     }
126
127     my $authorizationname = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.authorizationname');
128     if ($authorizationname) {
129         $self->{authorizationname} = $authorizationname;
130     } else {
131         $logger->error("EbookAPI: no OverDrive authorization name found for org unit $ou");
132         return;
133     }
134
135     my $basic_token = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.basic_token');
136     if ($basic_token) {
137         $self->{basic_token} = $basic_token;
138     } else {
139         $logger->error("EbookAPI: no OverDrive basic token found for org unit $ou");
140         return;
141     }
142
143     my $granted_auth_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.granted_auth_redirect_uri');
144     if ($granted_auth_uri) {
145         $self->{granted_auth_uri} = $granted_auth_uri;
146     }
147
148     my $password_required = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.password_required') || 0;
149     $self->{password_required} = $password_required;
150
151     return $self;
152
153 }
154
155 # Wrapper method for HTTP requests.
156 sub handle_http_request {
157     my $self = shift;
158     my $req = shift;
159
160     # Prep our request using defaults.
161     $req->{method} = 'GET' if (!$req->{method});
162     $req = $self->set_http_headers($req);
163
164     # Send the request.
165     my $res = $self->request($req, $self->{session_id});
166
167     $logger->info("EbookAPI: raw OverDrive HTTP response: " . Dumper $res);
168
169     # A "401 Unauthorized" response means we need to re-auth our client or patron.
170     if (defined ($res) && $res->{status} =~ /^401/) {
171         $logger->info("EbookAPI: 401 response received from OverDrive, re-authorizing...");
172
173         # Always re-auth client to ensure we have an up-to-date client token.
174         $self->do_client_auth();
175
176         # If we're using a Circulation API, redo patron auth too.
177         my $circulation_base_uri = $self->{circulation_base_uri};
178         if ($req->{uri} =~ /^$circulation_base_uri/) {
179             $self->do_patron_auth();
180         }
181
182         # Now we can update our headers with our fresh client/patron tokens
183         # and re-send our request.
184         $req = $self->set_http_headers($req);
185         return $self->request($req, $self->{session_id});
186     }
187
188     # For any non-401 response (including no response at all),
189     # just return whatever response we got (if any).
190     return $res;
191 }
192
193 # Set the correct headers for our request.
194 # Authorization headers are determined by which API we're using:
195 # - Circulation APIs use a patron access token.
196 # - Discovery APIs use a regular access token.
197 # - For other APIs, fallback to our basic token.
198 sub set_http_headers {
199     my $self = shift;
200     my $req = shift;
201     $req->{headers} = {} if (!$req->{headers});
202     if (!$req->{headers}->{Authorization}) {
203         my $auth_type;
204         my $token;
205         my $circulation_base_uri = $self->{circulation_base_uri};
206         my $discovery_base_uri = $self->{discovery_base_uri};
207         if ($req->{uri} =~ /^$circulation_base_uri/) {
208             $auth_type = 'Bearer';
209             $token = $self->{patron_token};
210         } elsif ($req->{uri} =~ /^$discovery_base_uri/) {
211             $auth_type = 'Bearer';
212             $token = $self->{bearer_token};
213         } else {
214             $auth_type = 'Basic';
215             $token = $self->{basic_token};
216         }
217         if (!$token) {
218             $logger->error("EbookAPI: unable to set HTTP Authorization header without token");
219             $logger->error("EbookAPI: failed request: " . Dumper $req);
220             return;
221         } else {
222             $req->{headers}->{Authorization} = "$auth_type $token";
223         }
224     }
225     return $req;
226 }
227
228 # POST /token HTTP/1.1
229 # Host: oauth.overdrive.com
230 # Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
231
232 # grant_type=client_credentials
233 sub do_client_auth {
234     my $self = shift;
235     my $req = {
236         method  => 'POST',
237         uri     => 'https://oauth.overdrive.com/token',
238         headers => {
239             'Authorization' => 'Basic ' . $self->{basic_token},
240             'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
241         },
242         content => 'grant_type=client_credentials'
243     };
244     my $res = $self->request($req, $self->{session_id});
245
246     if (defined ($res)) {
247         if ($res->{content}->{access_token}) {
248             # save our access token for future use
249             $self->{bearer_token} = $res->{content}->{access_token};
250             # use access token to grab other library info (e.g. collection token)
251             $self->get_library_info();
252             return $res;
253         } else {
254             $logger->error("EbookAPI: bearer token not received from OverDrive API");
255             $logger->error("EbookAPI: bad response: " . Dumper $res);
256         }
257     } else {
258         $logger->error("EbookAPI: no client authentication response from OverDrive API");
259     }
260     return;
261 }
262
263 sub do_patron_auth {
264     my $self = shift;
265     my @args = @_;
266     if ($self->{granted_auth_uri}) {
267         return $self->do_granted_patron_auth(@args);
268     } else {
269         return $self->do_basic_patron_auth(@args);
270     }
271 }
272
273 # TODO
274 sub do_granted_patron_auth {
275 }
276
277 # POST /patrontoken HTTP/1.1
278 # Host: oauth-patron.overdrive.com
279 # Authorization: Basic {Base64-encoded string}
280 # Content-Type: application/x-www-form-urlencoded;charset=UTF-8
281
282 # grant_type=password&username=1234567890&password=1234&scope=websiteid:12345 authorizationname:default
283 # OR:
284 # grant_type=password&username=1234567890&password=[ignore]&password_required=false&scope=websiteid:12345 authorizationname:default
285 sub do_basic_patron_auth {
286     my $self = shift;
287     my $barcode = shift;
288
289     if ($barcode) {
290         if (!$self->{patron_barcode}) {
291             $self->{patron_barcode} = $barcode;
292         } elsif ($barcode ne $self->{patron_barcode}) {
293             $logger->error("EbookAPI: patron barcode in auth request does not match patron barcode for this session");
294             return;
295         }
296     } else {
297         if (!$self->{patron_barcode}) {
298             $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
299         } else {
300             $barcode = $self->{patron_barcode};
301         }
302     }
303
304     # TODO handle cached/expired tokens?
305     # Making a request using an expired token will give a 401 Unauthorized error.
306     # Handle this appropriately.
307
308     # request content is an ugly url-encoded string
309     my $pw = (defined $self->{patron_password}) ? $self->{patron_password} : '';
310     my $content = 'grant_type=password';
311     $content .= "&username=$barcode";
312     if ($self->{password_required}) {
313         $content .= "&password=$pw";
314     } else {
315         $content .= '&password=xxx&password_required=false'
316     }
317     $content .= '&scope=websiteid:' . $self->{websiteid} . ' authorizationname:' . $self->{authorizationname};
318
319     my $req = {
320         method  => 'POST',
321         uri     => 'https://oauth-patron.overdrive.com/patrontoken',
322         headers => {
323             'Authorization' => 'Basic ' . $self->{basic_token},
324             'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
325         },
326         content => $content
327     };
328     my $res = $self->request($req, $self->{session_id});
329
330     if (defined ($res)) {
331         if ($res->{content}->{access_token}) {
332             $self->{patron_token} = $res->{content}->{access_token};
333             return $self->{patron_token};
334         } else {
335             $logger->error("EbookAPI: patron access token not received from OverDrive API");
336         }
337     } else {
338         $logger->error("EbookAPI: no patron authentication response from OverDrive API");
339     }
340     return;
341 }
342
343 # GET http://api.overdrive.com/v1/libraries/1225
344 # User-Agent: {Your application}
345 # Authorization: Bearer {OAuth access token}
346 # Host: api.overdrive.com
347 sub get_library_info {
348     my $self = shift;
349     my $library_id = $self->{account_id};
350     my $req = {
351         method  => 'GET',
352         uri     => $self->{discovery_base_uri} . "/libraries/$library_id"
353     };
354     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
355         $self->{collection_token} = $res->{content}->{collectionToken};
356         return $self->{collection_token};
357     } else {
358         $logger->error("EbookAPI: OverDrive Library Account API request failed");
359         return;
360     }
361 }
362
363 # GET http://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
364 # User-Agent: {Your application}
365 # Authorization: Bearer {OAuth access token}
366 # Host: api.overdrive.com
367 sub get_title_info {
368     my $self = shift;
369     my $title_id = shift;
370     $self->do_client_auth() if (!$self->{bearer_token});
371     $self->get_library_info() if (!$self->{collection_token});
372     my $collection_token = $self->{collection_token};
373     my $req = {
374         method  => 'GET',
375         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
376     };
377     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
378         if ($res->{content}->{title}) {
379             my $info = {
380                 title  => $res->{content}->{title},
381                 author => $res->{content}->{creators}[0]{name}
382             };
383             # Append format information (useful for checkouts).
384             $info->{formats} = $self->get_formats($title_id);
385             return $info;
386         } else {
387             $logger->error("EbookAPI: OverDrive metadata lookup failed for $title_id");
388         }
389     } else {
390         $logger->error("EbookAPI: no metadata response from OverDrive API");
391     }
392     return;
393 }
394
395 # GET http://api.overdrive.com/v1/collections/L1BAAEAAA2i/products/76C1B7D0-17F4-4C05-8397-C66C17411584/availability
396 # User-Agent: {Your application}
397 # Authorization: Bearer {OAuth access token}
398 # Host: api.overdrive.com
399 sub do_availability_lookup {
400     my $self = shift;
401     my $title_id = shift;
402     $self->do_client_auth() if (!$self->{bearer_token});
403     $self->get_library_info() if (!$self->{collection_token});
404     my $req = {
405         method  => 'GET',
406         uri     => $self->{discovery_base_uri} . "/collections/" . $self->{collection_token} . "/products/$title_id/availability"
407     };
408     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
409         return $res->{content}->{available};
410     } else {
411         $logger->error("EbookAPI: could not retrieve OverDrive availability for title $title_id");
412         return;
413     }
414 }
415
416 # Holdings lookup has two parts:
417 #
418 # 1. Copy availability: as above, but grab more details.
419 #
420 # 2. Formats:
421 #     GET https://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
422 #     User-Agent: {Your application}
423 #     Authorization: Bearer {OAuth access token}
424 #     Host: api.overdrive.com
425 #
426 sub do_holdings_lookup {
427     my ($self, $title_id) = @_;
428     $self->do_client_auth() if (!$self->{bearer_token});
429     $self->get_library_info() if (!$self->{collection_token});
430     my $collection_token = $self->{collection_token};
431
432     # prepare data structure to be used as return value
433     my $holdings = {
434         copies_owned => 0,
435         copies_available => 0,
436         formats => []
437     };
438
439     # request copy availability totals
440     my $avail_req = {
441         method  => 'GET',
442         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/availability"
443     };
444     if (my $avail_res = $self->handle_http_request($avail_req, $self->{session_id})) {
445         $holdings->{copies_owned} = $avail_res->{content}->{copiesOwned};
446         $holdings->{copies_available} = $avail_res->{content}->{copiesAvailable};
447     } else {
448         $logger->error("EbookAPI: failed to retrieve OverDrive holdings counts for title $title_id");
449     }
450
451     # request available formats
452     $holdings->{formats} = $self->get_formats($title_id);
453
454     return $holdings;
455 }
456
457 # Returns a list of available formats for a given title.
458 sub get_formats {
459     my ($self, $title_id) = @_;
460     $self->do_client_auth() if (!$self->{bearer_token});
461     $self->get_library_info() if (!$self->{collection_token});
462     my $collection_token = $self->{collection_token};
463
464     my $formats = [];
465
466     my $format_req = {
467         method  => 'GET',
468         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
469     };
470     if (my $format_res = $self->handle_http_request($format_req, $self->{session_id})) {
471         if ($format_res->{content}->{formats}) {
472             foreach my $f (@{$format_res->{content}->{formats}}) {
473                 push @$formats, { id => $f->{id}, name => $f->{name} };
474             }
475         } else {
476             $logger->info("EbookAPI: OverDrive holdings format request for title $title_id contained no format information");
477         }
478     } else {
479         $logger->error("EbookAPI: failed to retrieve OverDrive holdings formats for title $title_id");
480     }
481
482     return $formats;
483 }
484
485 # POST https://patron.api.overdrive.com/v1/patrons/me/checkouts
486 # Authorization: Bearer {OAuth patron access token}
487 # Content-Type: application/json; charset=utf-8
488
489 # Request content looks like this:
490 # {
491 #     "fields": [
492 #         {
493 #             "name": "reserveId",
494 #             "value": "76C1B7D0-17F4-4C05-8397-C66C17411584"
495 #         }
496 #     ]
497 # }
498 #
499 # Response looks like this:
500 # {
501 #     "reserveId": "76C1B7D0-17F4-4C05-8397-C66C17411584",
502 #     "expires": "10/14/2013 10:56:00 AM",
503 #     "isFormatLockedIn": false,
504 #     "formats": [
505 #         {
506 #             "reserveId": "76C1B7D0-17F4-4C05-8397-C66C17411584",
507 #             "formatType": "ebook-overdrive",
508 #             "linkTemplates": {
509 #                 "downloadLink": {
510 #                     "href": "https://patron.api.overdrive.com/v1/patrons/me/checkouts/76C1B7D0-17F4-4C05-8397-C66C17411584/formats/ebook-overdrive/downloadlink?errorpageurl={errorpageurl}&odreadauthurl={odreadauthurl}",
511 #                     ...
512 #                 },
513 #                 ...
514 #             },
515 #             ...
516 #         }
517 #     ],
518 #     ...
519 # }
520 #
521 # Our return value looks like this:
522 # {
523 #     due_date => "10/14/2013 10:56:00 AM",
524 #     formats => [
525 #         "ebook-overdrive" => "https://patron.api.overdrive.com/v1/patrons/me/checkouts/76C1B7D0-17F4-4C05-8397-C66C17411584/formats/ebook-overdrive/downloadlink?errorpageurl={errorpageurl}&odreadauthurl={odreadauthurl}",
526 #         ...
527 #     ]
528 # }
529 sub checkout {
530     my ($self, $title_id, $patron_token, $format) = @_;
531     my $request_content = {
532         fields => [
533             {
534                 name  => 'reserveId',
535                 value => $title_id
536             }
537         ]
538     };
539     if ($format) {
540         push @{$request_content->{fields}}, { name => 'formatType', value => $format };
541     }
542     my $req = {
543         method  => 'POST',
544         uri     => $self->{circulation_base_uri} . "/patrons/me/checkouts",
545         content => OpenSRF::Utils::JSON->perl2JSON($request_content)
546     };
547     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
548         if ($res->{content}->{expires}) {
549             my $checkout = { due_date => $res->{content}->{expires} };
550             if (defined $res->{content}->{formats}) {
551                 my $formats = {};
552                 foreach my $f (@{$res->{content}->{formats}}) {
553                     my $ftype = $f->{formatType};
554                     $formats->{$ftype} = $f->{linkTemplates}->{downloadLink}->{href};
555                 }
556                 $checkout->{formats} = $formats;
557             }
558             return $checkout;
559         }
560         $logger->error("EbookAPI: checkout failed for OverDrive title $title_id");
561         return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Unknown checkout error' ) };
562     }
563     $logger->error("EbookAPI: no response received from OverDrive server");
564     return;
565 }
566
567 # renew is not supported by OverDrive API
568 sub renew {
569     $logger->error("EbookAPI: OverDrive API does not support renewals");
570     return { error_msg => "Title cannot be renewed." };
571 }
572
573 # NB: A title cannot be checked in once a format has been locked in.
574 # Successful checkin returns an HTTP 204 response with no content.
575 # DELETE https://patron.api.overdrive.com/v1/patrons/me/checkouts/08F7D7E6-423F-45A6-9A1E-5AE9122C82E7
576 # Authorization: Bearer {OAuth patron access token}
577 # Host: patron.api.overdrive.com
578 sub checkin {
579     my ($self, $title_id, $patron_token) = @_;
580     my $req = {
581         method  => 'DELETE',
582         uri     => $self->{circulation_base_uri} . "/patrons/me/checkouts/$title_id"
583     };
584     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
585         if ($res->{status} =~ /^204/) {
586             return {};
587         } else {
588             $logger->error("EbookAPI: checkin failed for OverDrive title $title_id");
589             return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Checkin failed' ) };
590         }
591     }
592     $logger->error("EbookAPI: no response received from OverDrive server");
593     return;
594 }
595
596 sub place_hold {
597     my ($self, $title_id, $patron_token, $email) = @_;
598     my $fields = [
599         {
600             name  => 'reserveId',
601             value => $title_id
602         }
603     ];
604     if ($email) {
605         push @$fields, { name => 'emailAddress', value => $email };
606         # TODO: Use autoCheckout=true when we have a patron email?
607     } else {
608         push @$fields, { name => 'ignoreEmail', value => 'true' };
609     }
610     my $request_content = { fields => $fields };
611     my $req = {
612         method  => 'POST',
613         uri     => $self->{circulation_base_uri} . "/patrons/me/holds",
614         content => OpenSRF::Utils::JSON->perl2JSON($request_content)
615     };
616     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
617         if ($res->{content}->{holdPlacedDate}) {
618             return {
619                 queue_position => $res->{content}->{holdListPosition},
620                 queue_size => $res->{content}->{numberOfHolds},
621                 expire_date => (defined $res->{content}->{holdExpires}) ? $res->{content}->{holdExpires} : undef
622             };
623         }
624         $logger->error("EbookAPI: place hold failed for OverDrive title $title_id");
625         return { error_msg => "Could not place hold." };
626     }
627     $logger->error("EbookAPI: no response received from OverDrive server");
628     return;
629 }
630
631 sub cancel_hold {
632     my ($self, $title_id, $patron_token) = @_;
633     my $req = {
634         method  => 'DELETE',
635         uri     => $self->{circulation_base_uri} . "/patrons/me/holds/$title_id"
636     };
637     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
638         if ($res->{status} =~ /^204/) {
639             return {};
640         } else {
641             $logger->error("EbookAPI: cancel hold failed for OverDrive title $title_id");
642             return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Could not cancel hold' ) };
643         }
644     }
645     $logger->error("EbookAPI: no response received from OverDrive server");
646     return;
647 }
648
649 # List of patron checkouts:
650 # GET http://patron.api.overdrive.com/v1/patrons/me/checkouts
651 # User-Agent: {Your application}
652 # Authorization: Bearer {OAuth patron access token}
653 # Host: patron.api.overdrive.com
654 #
655 # Response looks like this:
656 # {
657 #     "totalItems": 4,
658 #     "totalCheckouts": 2,
659 #     "checkouts": [
660 #         {
661 #             "reserveId": "A03EAC2C-C088-46C6-B9E9-59D6C11A3596",
662 #             "expires": "2015-08-11T18:53:00Z",
663 #             ...
664 #         }
665 #     ],
666 #     ...
667 # }
668 #
669 # To get title metadata (e.g. title/author), do get_title_info(reserveId).
670 sub get_patron_checkouts {
671     my $self = shift;
672     my $patron_token = shift;
673     if (my $res = $self->do_get_patron_xacts('checkouts', $patron_token)) {
674         my $checkouts = [];
675         foreach my $checkout (@{$res->{content}->{checkouts}}) {
676             my $title_id = $checkout->{reserveId};
677             my $title_info = $self->get_title_info($title_id);
678             my $formats = {};
679             foreach my $f (@{$checkout->{formats}}) {
680                 my $ftype = $f->{formatType};
681                 $formats->{$ftype} = $f->{linkTemplates}->{downloadLink}->{href};
682             };
683             push @$checkouts, {
684                 title_id => $title_id,
685                 due_date => $checkout->{expires},
686                 title => $title_info->{title},
687                 author => $title_info->{author},
688                 formats => $formats
689             }
690         };
691         $self->{checkouts} = $checkouts;
692         return $self->{checkouts};
693     } else {
694         $logger->error("EbookAPI: unable to retrieve OverDrive checkouts for patron " . $self->{patron_barcode});
695         return;
696     }
697 }
698
699 sub get_patron_holds {
700     my $self = shift;
701     my $patron_token = shift;
702     if (my $res = $self->do_get_patron_xacts('holds', $patron_token)) {
703         my $holds = [];
704         foreach my $hold (@{$res->{content}->{holds}}) {
705             my $title_id = $hold->{reserveId};
706             my $title_info = $self->get_title_info($title_id);
707             my $this_hold = {
708                 title_id => $title_id,
709                 queue_position => $hold->{holdListPosition},
710                 queue_size => $hold->{numberOfHolds},
711                 # TODO: special handling for ready-to-checkout holds
712                 is_ready => ( $hold->{actions}->{checkout} ) ? 1 : 0,
713                 create_date => $hold->{holdPlacedDate},
714                 expire_date => ( $hold->{holdExpires} ) ? $hold->{holdExpires} : '-',
715                 title => $title_info->{title},
716                 author => $title_info->{author}
717             };
718             # TODO: hold suspensions
719             push @$holds, $this_hold;
720         }
721         $self->{holds} = $holds;
722         return $self->{holds};
723     } else {
724         $logger->error("EbookAPI: unable to retrieve OverDrive holds for patron " . $self->{patron_barcode});
725         return;
726     }
727 }
728
729 # generic function for retrieving patron transactions
730 sub do_get_patron_xacts {
731     my $self = shift;
732     my $xact_type = shift;
733     my $patron_token = shift;
734     if (!$patron_token) {
735         if ($self->{patron_barcode}) {
736             $self->do_client_auth() if (!$self->{bearer_token});
737             $self->do_patron_auth();
738         } else {
739             $logger->error("EbookAPI: Cannot retrieve OverDrive $xact_type with no patron information");
740         }
741     }
742     my $req = {
743         method  => 'GET',
744         uri     => $self->{circulation_base_uri} . "/patrons/me/$xact_type"
745     };
746     return $self->handle_http_request($req, $self->{session_id});
747 }
748
749 # get download URL for checked-out title
750 sub do_get_download_link {
751     my ($self, $request_link) = @_;
752     # Request links use the same domain as the circulation base URI, but they
753     # are apparently always plain HTTP.  The request link still works if you
754     # use HTTPS instead.  So, if our circulation base URI uses HTTPS, let's
755     # force the request link to HTTPS too, for two reasons:
756     # 1. A preference for HTTPS is implied by the library's circulation base
757     #    URI setting.
758     # 2. The base URI of the request link has to match the circulation base URI
759     #    (including the same protocol) in order for the handle_http_request()
760     #    method above to automatically re-authenticate the patron, if required.
761     if ($self->{circulation_base_uri} =~ /^https:/) {
762         $request_link =~ s/^http:/https:/;
763     }
764     my $req = {
765         method  => 'GET',
766         uri     => $request_link
767     };
768     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
769         if ($res->{content}->{links}->{contentlink}->{href}) {
770             return { url => $res->{content}->{links}->{contentlink}->{href} };
771         }
772         return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Could not get content link' ) };
773     }
774     $logger->error("EbookAPI: no response received from OverDrive server");
775     return;
776 }
777
778 1;