]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
LP2061136 - Stamping 1405 DB upgrade script
[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     my $session_id = shift;
160     my $do_not_redirect = shift;
161
162     # Prep our request using defaults.
163     $req->{method} = 'GET' if (!$req->{method});
164     $req = $self->set_http_headers($req);
165
166     # Send the request.
167     my $res = $self->request($req, $self->{session_id}, $do_not_redirect);
168
169     $logger->info("EbookAPI: raw OverDrive HTTP response: " . Dumper $res);
170
171     # A "401 Unauthorized" response means we need to re-auth our client or patron.
172     if (defined ($res) && $res->{status} =~ /^401/) {
173         $logger->info("EbookAPI: 401 response received from OverDrive, re-authorizing...");
174
175         # Always re-auth client to ensure we have an up-to-date client token.
176         $self->do_client_auth();
177
178         # If we're using a Circulation API, redo patron auth too.
179         my $circulation_base_uri = $self->{circulation_base_uri};
180         if ($req->{uri} =~ /^$circulation_base_uri/) {
181             $self->do_patron_auth();
182         }
183
184         # Now we can update our headers with our fresh client/patron tokens
185         # and re-send our request.
186         $req = $self->set_http_headers($req);
187         return $self->request($req, $self->{session_id}, $do_not_redirect);
188     }
189
190     # For any non-401 response (including no response at all),
191     # just return whatever response we got (if any).
192     return $res;
193 }
194
195 # Set the correct headers for our request.
196 # Authorization headers are determined by which API we're using:
197 # - Circulation APIs use a patron access token.
198 # - Discovery APIs use a regular access token.
199 # - For other APIs, fallback to our basic token.
200 sub set_http_headers {
201     my $self = shift;
202     my $req = shift;
203     $req->{headers} = {} if (!$req->{headers});
204     if (!$req->{headers}->{Authorization}) {
205         my $auth_type;
206         my $token;
207         my $circulation_base_uri = $self->{circulation_base_uri};
208         my $discovery_base_uri = $self->{discovery_base_uri};
209         if ($req->{uri} =~ /^$circulation_base_uri/) {
210             $auth_type = 'Bearer';
211             $token = $self->{patron_token};
212         } elsif ($req->{uri} =~ /^$discovery_base_uri/) {
213             $auth_type = 'Bearer';
214             $token = $self->{bearer_token};
215         } else {
216             $auth_type = 'Basic';
217             $token = $self->{basic_token};
218         }
219         if (!$token) {
220             $logger->error("EbookAPI: unable to set HTTP Authorization header without token");
221             $logger->error("EbookAPI: failed request: " . Dumper $req);
222             return;
223         } else {
224             $req->{headers}->{Authorization} = "$auth_type $token";
225         }
226     }
227     return $req;
228 }
229
230 # POST /token HTTP/1.1
231 # Host: oauth.overdrive.com
232 # Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
233
234 # grant_type=client_credentials
235 sub do_client_auth {
236     my $self = shift;
237     my $req = {
238         method  => 'POST',
239         uri     => 'https://oauth.overdrive.com/token',
240         headers => {
241             'Authorization' => 'Basic ' . $self->{basic_token},
242             'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
243         },
244         content => 'grant_type=client_credentials'
245     };
246     my $res = $self->request($req, $self->{session_id});
247
248     if (defined ($res)) {
249         if ($res->{content}->{access_token}) {
250             # save our access token for future use
251             $self->{bearer_token} = $res->{content}->{access_token};
252             # use access token to grab other library info (e.g. collection token)
253             $self->get_library_info();
254             return $res;
255         } else {
256             $logger->error("EbookAPI: bearer token not received from OverDrive API");
257             $logger->error("EbookAPI: bad response: " . Dumper $res);
258         }
259     } else {
260         $logger->error("EbookAPI: no client authentication response from OverDrive API");
261     }
262     return;
263 }
264
265 sub do_patron_auth {
266     my $self = shift;
267     my @args = @_;
268     if ($self->{granted_auth_uri}) {
269         return $self->do_granted_patron_auth(@args);
270     } else {
271         return $self->do_basic_patron_auth(@args);
272     }
273 }
274
275 # TODO
276 sub do_granted_patron_auth {
277 }
278
279 # POST /patrontoken HTTP/1.1
280 # Host: oauth-patron.overdrive.com
281 # Authorization: Basic {Base64-encoded string}
282 # Content-Type: application/x-www-form-urlencoded;charset=UTF-8
283
284 # grant_type=password&username=1234567890&password=1234&scope=websiteid:12345 authorizationname:default
285 # OR:
286 # grant_type=password&username=1234567890&password=[ignore]&password_required=false&scope=websiteid:12345 authorizationname:default
287 sub do_basic_patron_auth {
288     my $self = shift;
289     my $barcode = shift;
290
291     if ($barcode) {
292         if (!$self->{patron_barcode}) {
293             $self->{patron_barcode} = $barcode;
294         } elsif ($barcode ne $self->{patron_barcode}) {
295             $logger->error("EbookAPI: patron barcode in auth request does not match patron barcode for this session");
296             return;
297         }
298     } else {
299         if (!$self->{patron_barcode}) {
300             $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
301         } else {
302             $barcode = $self->{patron_barcode};
303         }
304     }
305
306     # TODO handle cached/expired tokens?
307     # Making a request using an expired token will give a 401 Unauthorized error.
308     # Handle this appropriately.
309
310     # request content is an ugly url-encoded string
311     my $pw = (defined $self->{patron_password}) ? $self->{patron_password} : '';
312     my $content = 'grant_type=password';
313     $content .= "&username=$barcode";
314     if ($self->{password_required}) {
315         $content .= "&password=$pw";
316     } else {
317         $content .= '&password=xxx&password_required=false'
318     }
319     $content .= '&scope=websiteid:' . $self->{websiteid} . ' authorizationname:' . $self->{authorizationname};
320
321     my $req = {
322         method  => 'POST',
323         uri     => 'https://oauth-patron.overdrive.com/patrontoken',
324         headers => {
325             'Authorization' => 'Basic ' . $self->{basic_token},
326             'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
327         },
328         content => $content
329     };
330     my $res = $self->request($req, $self->{session_id});
331
332     if (defined ($res)) {
333         if ($res->{content}->{access_token}) {
334             $self->{patron_token} = $res->{content}->{access_token};
335             return $self->{patron_token};
336         } else {
337             $logger->error("EbookAPI: patron access token not received from OverDrive API");
338         }
339     } else {
340         $logger->error("EbookAPI: no patron authentication response from OverDrive API");
341     }
342     return;
343 }
344
345 # GET http://api.overdrive.com/v1/libraries/1225
346 # User-Agent: {Your application}
347 # Authorization: Bearer {OAuth access token}
348 # Host: api.overdrive.com
349 sub get_library_info {
350     my $self = shift;
351     my $library_id = $self->{account_id};
352     my $req = {
353         method  => 'GET',
354         uri     => $self->{discovery_base_uri} . "/libraries/$library_id"
355     };
356     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
357         $self->{collection_token} = $res->{content}->{collectionToken};
358         return $self->{collection_token};
359     } else {
360         $logger->error("EbookAPI: OverDrive Library Account API request failed");
361         return;
362     }
363 }
364
365 # GET http://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
366 # User-Agent: {Your application}
367 # Authorization: Bearer {OAuth access token}
368 # Host: api.overdrive.com
369 sub get_title_info {
370     my $self = shift;
371     my $title_id = shift;
372     $self->do_client_auth() if (!$self->{bearer_token});
373     $self->get_library_info() if (!$self->{collection_token});
374     my $collection_token = $self->{collection_token};
375     my $req = {
376         method  => 'GET',
377         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
378     };
379     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
380         if ($res->{content}->{title}) {
381             my $info = {
382                 title  => $res->{content}->{title},
383                 author => $res->{content}->{creators}[0]{name}
384             };
385             # Append format information (useful for checkouts).
386             $info->{formats} = $self->get_formats($title_id);
387             return $info;
388         } else {
389             $logger->error("EbookAPI: OverDrive metadata lookup failed for $title_id");
390         }
391     } else {
392         $logger->error("EbookAPI: no metadata response from OverDrive API");
393     }
394     return;
395 }
396
397 # GET http://api.overdrive.com/v1/collections/L1BAAEAAA2i/products/76C1B7D0-17F4-4C05-8397-C66C17411584/availability
398 # User-Agent: {Your application}
399 # Authorization: Bearer {OAuth access token}
400 # Host: api.overdrive.com
401 sub do_availability_lookup {
402     my $self = shift;
403     my $title_id = shift;
404     $self->do_client_auth() if (!$self->{bearer_token});
405     $self->get_library_info() if (!$self->{collection_token});
406     my $req = {
407         method  => 'GET',
408         uri     => $self->{discovery_base_uri} . "/collections/" . $self->{collection_token} . "/products/$title_id/availability"
409     };
410     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
411         return $res->{content}->{available};
412     } else {
413         $logger->error("EbookAPI: could not retrieve OverDrive availability for title $title_id");
414         return;
415     }
416 }
417
418 # Holdings lookup has two parts:
419 #
420 # 1. Copy availability: as above, but grab more details.
421 #
422 # 2. Formats:
423 #     GET https://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
424 #     User-Agent: {Your application}
425 #     Authorization: Bearer {OAuth access token}
426 #     Host: api.overdrive.com
427 #
428 sub do_holdings_lookup {
429     my ($self, $title_id) = @_;
430     $self->do_client_auth() if (!$self->{bearer_token});
431     $self->get_library_info() if (!$self->{collection_token});
432     my $collection_token = $self->{collection_token};
433
434     # prepare data structure to be used as return value
435     my $holdings = {
436         copies_owned => 0,
437         copies_available => 0,
438         formats => []
439     };
440
441     # request copy availability totals
442     my $avail_req = {
443         method  => 'GET',
444         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/availability"
445     };
446     if (my $avail_res = $self->handle_http_request($avail_req, $self->{session_id})) {
447         $holdings->{copies_owned} = $avail_res->{content}->{copiesOwned};
448         $holdings->{copies_available} = $avail_res->{content}->{copiesAvailable};
449     } else {
450         $logger->error("EbookAPI: failed to retrieve OverDrive holdings counts for title $title_id");
451     }
452
453     # request available formats
454     $holdings->{formats} = $self->get_formats($title_id);
455
456     return $holdings;
457 }
458
459 # Returns a list of available formats for a given title.
460 sub get_formats {
461     my ($self, $title_id) = @_;
462     $self->do_client_auth() if (!$self->{bearer_token});
463     $self->get_library_info() if (!$self->{collection_token});
464     my $collection_token = $self->{collection_token};
465
466     my $formats = [];
467
468     my $format_req = {
469         method  => 'GET',
470         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
471     };
472     if (my $format_res = $self->handle_http_request($format_req, $self->{session_id})) {
473         if ($format_res->{content}->{formats}) {
474             foreach my $f (@{$format_res->{content}->{formats}}) {
475                 push @$formats, { id => $f->{id}, name => $f->{name} };
476             }
477         } else {
478             $logger->info("EbookAPI: OverDrive holdings format request for title $title_id contained no format information");
479         }
480     } else {
481         $logger->error("EbookAPI: failed to retrieve OverDrive holdings formats for title $title_id");
482     }
483
484     return $formats;
485 }
486
487 # POST https://patron.api.overdrive.com/v1/patrons/me/checkouts
488 # Authorization: Bearer {OAuth patron access token}
489 # Content-Type: application/json; charset=utf-8
490
491 # Request content looks like this:
492 # {
493 #     "fields": [
494 #         {
495 #             "name": "reserveId",
496 #             "value": "76C1B7D0-17F4-4C05-8397-C66C17411584"
497 #         }
498 #     ]
499 # }
500 #
501 # Response looks like this:
502 # {
503 #     "reserveId": "76C1B7D0-17F4-4C05-8397-C66C17411584",
504 #     "expires": "10/14/2013 10:56:00 AM",
505 #     "isFormatLockedIn": false,
506 #     "formats": [
507 #         {
508 #             "reserveId": "76C1B7D0-17F4-4C05-8397-C66C17411584",
509 #             "formatType": "ebook-overdrive",
510 #             "linkTemplates": {
511 #                 "downloadLink": {
512 #                     "href": "https://patron.api.overdrive.com/v1/patrons/me/checkouts/76C1B7D0-17F4-4C05-8397-C66C17411584/formats/ebook-overdrive/downloadlink?errorpageurl={errorpageurl}&odreadauthurl={odreadauthurl}",
513 #                     ...
514 #                 },
515 #                 ...
516 #             },
517 #             ...
518 #         }
519 #     ],
520 #     ...
521 # }
522 #
523 # Our return value looks like this:
524 # {
525 #     due_date => "10/14/2013 10:56:00 AM",
526 #     formats => [
527 #         "ebook-overdrive" => "https://patron.api.overdrive.com/v1/patrons/me/checkouts/76C1B7D0-17F4-4C05-8397-C66C17411584/formats/ebook-overdrive/downloadlink?errorpageurl={errorpageurl}&odreadauthurl={odreadauthurl}",
528 #         ...
529 #     ]
530 # }
531 sub checkout {
532     my ($self, $title_id, $patron_token, $format) = @_;
533     my $request_content = {
534         fields => [
535             {
536                 name  => 'reserveId',
537                 value => $title_id
538             }
539         ]
540     };
541     if ($format) {
542         push @{$request_content->{fields}}, { name => 'formatType', value => $format };
543     }
544     my $req = {
545         method  => 'POST',
546         uri     => $self->{circulation_base_uri} . "/patrons/me/checkouts",
547         content => OpenSRF::Utils::JSON->perl2JSON($request_content)
548     };
549     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
550         if ($res->{content}->{expires}) {
551             my $checkout = { due_date => $res->{content}->{expires} };
552             if (defined $res->{content}->{formats}) {
553                 my $formats = {};
554                 foreach my $f (@{$res->{content}->{formats}}) {
555                     my $ftype = $f->{formatType};
556                     $formats->{$ftype} = $f->{linkTemplates}->{downloadLink}->{href};
557                 }
558                 $checkout->{formats} = $formats;
559             }
560             if ($res->{content}->{links}->{downloadRedirect}->{href}) {
561                 my $redir = $res->{content}->{links}->{downloadRedirect}->{href};
562                 my $req2 = {
563                     method => 'GET',
564                     uri    => $redir
565                 };
566                 if (my $res2 = $self->handle_http_request($req2, $self->{session_id}, 1)) {
567                     if ($res2->{location}) {
568                         $checkout->{download_redirect} = $res2->{location};
569                     }
570                 }
571             }
572             return $checkout;
573         }
574         $logger->error("EbookAPI: checkout failed for OverDrive title $title_id");
575         return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Unknown checkout error' ) };
576     }
577     $logger->error("EbookAPI: no response received from OverDrive server");
578     return;
579 }
580
581 # renew is not supported by OverDrive API
582 sub renew {
583     $logger->error("EbookAPI: OverDrive API does not support renewals");
584     return { error_msg => "Title cannot be renewed." };
585 }
586
587 # NB: A title cannot be checked in once a format has been locked in.
588 # Successful checkin returns an HTTP 204 response with no content.
589 # DELETE https://patron.api.overdrive.com/v1/patrons/me/checkouts/08F7D7E6-423F-45A6-9A1E-5AE9122C82E7
590 # Authorization: Bearer {OAuth patron access token}
591 # Host: patron.api.overdrive.com
592 sub checkin {
593     my ($self, $title_id, $patron_token) = @_;
594     my $req = {
595         method  => 'DELETE',
596         uri     => $self->{circulation_base_uri} . "/patrons/me/checkouts/$title_id"
597     };
598     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
599         if ($res->{status} =~ /^204/) {
600             return {};
601         } else {
602             $logger->error("EbookAPI: checkin failed for OverDrive title $title_id");
603             return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Checkin failed' ) };
604         }
605     }
606     $logger->error("EbookAPI: no response received from OverDrive server");
607     return;
608 }
609
610 sub place_hold {
611     my ($self, $title_id, $patron_token, $email) = @_;
612     my $fields = [
613         {
614             name  => 'reserveId',
615             value => $title_id
616         }
617     ];
618     if ($email) {
619         push @$fields, { name => 'emailAddress', value => $email };
620         # TODO: Use autoCheckout=true when we have a patron email?
621     } else {
622         push @$fields, { name => 'ignoreEmail', value => 'true' };
623     }
624     my $request_content = { fields => $fields };
625     my $req = {
626         method  => 'POST',
627         uri     => $self->{circulation_base_uri} . "/patrons/me/holds",
628         content => OpenSRF::Utils::JSON->perl2JSON($request_content)
629     };
630     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
631         if ($res->{content}->{holdPlacedDate}) {
632             return {
633                 queue_position => $res->{content}->{holdListPosition},
634                 queue_size => $res->{content}->{numberOfHolds},
635                 expire_date => (defined $res->{content}->{holdExpires}) ? $res->{content}->{holdExpires} : undef
636             };
637         }
638         $logger->error("EbookAPI: place hold failed for OverDrive title $title_id");
639         return { error_msg => "Could not place hold." };
640     }
641     $logger->error("EbookAPI: no response received from OverDrive server");
642     return;
643 }
644
645 sub cancel_hold {
646     my ($self, $title_id, $patron_token) = @_;
647     my $req = {
648         method  => 'DELETE',
649         uri     => $self->{circulation_base_uri} . "/patrons/me/holds/$title_id"
650     };
651     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
652         if ($res->{status} =~ /^204/) {
653             return {};
654         } else {
655             $logger->error("EbookAPI: cancel hold failed for OverDrive title $title_id");
656             return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Could not cancel hold' ) };
657         }
658     }
659     $logger->error("EbookAPI: no response received from OverDrive server");
660     return;
661 }
662
663 # List of patron checkouts:
664 # GET http://patron.api.overdrive.com/v1/patrons/me/checkouts
665 # User-Agent: {Your application}
666 # Authorization: Bearer {OAuth patron access token}
667 # Host: patron.api.overdrive.com
668 #
669 # Response looks like this:
670 # {
671 #     "totalItems": 4,
672 #     "totalCheckouts": 2,
673 #     "checkouts": [
674 #         {
675 #             "reserveId": "A03EAC2C-C088-46C6-B9E9-59D6C11A3596",
676 #             "expires": "2015-08-11T18:53:00Z",
677 #             ...
678 #         }
679 #     ],
680 #     ...
681 # }
682 #
683 # To get title metadata (e.g. title/author), do get_title_info(reserveId).
684 sub get_patron_checkouts {
685     my $self = shift;
686     my $patron_token = shift;
687     if (my $res = $self->do_get_patron_xacts('checkouts', $patron_token)) {
688         my $checkouts = [];
689         foreach my $checkout (@{$res->{content}->{checkouts}}) {
690             my $title_id = $checkout->{reserveId};
691             my $title_info = $self->get_title_info($title_id);
692             my $formats = {};
693             foreach my $f (@{$checkout->{formats}}) {
694                 my $ftype = $f->{formatType};
695                 $formats->{$ftype} = $f->{linkTemplates}->{downloadLink}->{href};
696             };
697             my $download_redirect = '';
698             my $redirect = $checkout->{links}->{downloadRedirect}->{href};
699             if ($redirect) {
700                 my $req2 = {
701                     method => 'GET',
702                     uri    => $redirect
703                 };
704                 if (my $res2 = $self->handle_http_request($req2, $self->{session_id}, 1)) {
705                     if ($res2->{location}) {
706                         $download_redirect = $res2->{location};
707                     }
708                 }
709             }
710             push @$checkouts, {
711                 title_id => $title_id,
712                 due_date => $checkout->{expires},
713                 title => $title_info->{title},
714                 author => $title_info->{author},
715                 formats => $formats,
716                 download_redirect => $download_redirect
717             }
718         };
719         $self->{checkouts} = $checkouts;
720         return $self->{checkouts};
721     } else {
722         $logger->error("EbookAPI: unable to retrieve OverDrive checkouts for patron " . $self->{patron_barcode});
723         return;
724     }
725 }
726
727 sub get_patron_holds {
728     my $self = shift;
729     my $patron_token = shift;
730     if (my $res = $self->do_get_patron_xacts('holds', $patron_token)) {
731         my $holds = [];
732         foreach my $hold (@{$res->{content}->{holds}}) {
733             my $title_id = $hold->{reserveId};
734             my $title_info = $self->get_title_info($title_id);
735             my $this_hold = {
736                 title_id => $title_id,
737                 queue_position => $hold->{holdListPosition},
738                 queue_size => $hold->{numberOfHolds},
739                 # TODO: special handling for ready-to-checkout holds
740                 is_ready => ( $hold->{actions}->{checkout} ) ? 1 : 0,
741                 is_frozen => ( $hold->{holdSuspension} ) ? 1 : 0,
742                 create_date => $hold->{holdPlacedDate},
743                 expire_date => ( $hold->{holdExpires} ) ? $hold->{holdExpires} : '-',
744                 title => $title_info->{title},
745                 author => $title_info->{author}
746             };
747             # TODO: hold suspensions
748             push @$holds, $this_hold;
749         }
750         $self->{holds} = $holds;
751         return $self->{holds};
752     } else {
753         $logger->error("EbookAPI: unable to retrieve OverDrive holds for patron " . $self->{patron_barcode});
754         return;
755     }
756 }
757
758 # generic function for retrieving patron transactions
759 sub do_get_patron_xacts {
760     my $self = shift;
761     my $xact_type = shift;
762     my $patron_token = shift;
763     if (!$patron_token) {
764         if ($self->{patron_barcode}) {
765             $self->do_client_auth() if (!$self->{bearer_token});
766             $self->do_patron_auth();
767         } else {
768             $logger->error("EbookAPI: Cannot retrieve OverDrive $xact_type with no patron information");
769         }
770     }
771     my $req = {
772         method  => 'GET',
773         uri     => $self->{circulation_base_uri} . "/patrons/me/$xact_type"
774     };
775     return $self->handle_http_request($req, $self->{session_id});
776 }
777
778 # get download URL for checked-out title
779 sub do_get_download_link {
780     my ($self, $request_link) = @_;
781     # Request links use the same domain as the circulation base URI, but they
782     # are apparently always plain HTTP.  The request link still works if you
783     # use HTTPS instead.  So, if our circulation base URI uses HTTPS, let's
784     # force the request link to HTTPS too, for two reasons:
785     # 1. A preference for HTTPS is implied by the library's circulation base
786     #    URI setting.
787     # 2. The base URI of the request link has to match the circulation base URI
788     #    (including the same protocol) in order for the handle_http_request()
789     #    method above to automatically re-authenticate the patron, if required.
790     if ($self->{circulation_base_uri} =~ /^https:/) {
791         $request_link =~ s/^http:/https:/;
792     }
793     my $req = {
794         method  => 'GET',
795         uri     => $request_link
796     };
797     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
798         if ($res->{content}->{links}->{contentlink}->{href}) {
799             return { url => $res->{content}->{links}->{contentlink}->{href} };
800         }
801         return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Could not get content link' ) };
802     }
803     $logger->error("EbookAPI: no response received from OverDrive server");
804     return;
805 }
806
807 1;