97a301a9b2802b225523a7796977ea2ad0b2a9c8
[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 OpenILS::Application::AppUtils;
33 use Data::Dumper;
34
35 sub new {
36     my( $class, $args ) = @_;
37     $class = ref $class || $class;
38     return bless $args, $class;
39 }
40
41 sub ou {
42     my $self = shift;
43     return $self->{ou};
44 }
45
46 sub vendor {
47     my $self = shift;
48     return $self->{vendor};
49 }
50
51 sub session_id {
52     my $self = shift;
53     return $self->{session_id};
54 }
55
56 sub account_id {
57     my $self = shift;
58     return $self->{account_id};
59 }
60
61 sub websiteid {
62     my $self = shift;
63     return $self->{websiteid};
64 }
65
66 sub authorizationname {
67     my $self = shift;
68     return $self->{authorizationname};
69 }
70
71 sub basic_token {
72     my $self = shift;
73     return $self->{basic_token};
74 }
75
76 sub bearer_token {
77     my $self = shift;
78     return $self->{bearer_token};
79 }
80
81 sub collection_token {
82     my $self = shift;
83     return $self->{collection_token};
84 }
85
86 sub granted_auth_uri {
87     my $self = shift;
88     return $self->{granted_auth_uri};
89 }
90
91 sub password_required {
92     my $self = shift;
93     return $self->{password_required};
94 }
95
96 sub patron_token {
97     my $self = shift;
98     return $self->{patron_token};
99 }
100
101 sub initialize {
102     my $self = shift;
103     my $ou = $self->{ou};
104
105     my $discovery_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.discovery_base_uri');
106     $self->{discovery_base_uri} = $discovery_base_uri || 'https://api.overdrive.com/v1';
107     my $circulation_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.circulation_base_uri');
108     $self->{circulation_base_uri} = $circulation_base_uri || 'https://patron.api.overdrive.com/v1';
109
110     my $account_id = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.account_id');
111     if ($account_id) {
112         $self->{account_id} = $account_id;
113     } else {
114         $logger->error("EbookAPI: no OverDrive account ID found for org unit $ou");
115         return;
116     }
117
118     my $websiteid = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.websiteid');
119     if ($websiteid) {
120         $self->{websiteid} = $websiteid;
121     } else {
122         $logger->error("EbookAPI: no OverDrive website ID found for org unit $ou");
123         return;
124     }
125
126     my $authorizationname = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.authorizationname');
127     if ($authorizationname) {
128         $self->{authorizationname} = $authorizationname;
129     } else {
130         $logger->error("EbookAPI: no OverDrive authorization name found for org unit $ou");
131         return;
132     }
133
134     my $basic_token = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.basic_token');
135     if ($basic_token) {
136         $self->{basic_token} = $basic_token;
137     } else {
138         $logger->error("EbookAPI: no OverDrive basic token found for org unit $ou");
139         return;
140     }
141
142     my $granted_auth_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.granted_auth_redirect_uri');
143     if ($granted_auth_uri) {
144         $self->{granted_auth_uri} = $granted_auth_uri;
145     }
146
147     my $password_required = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.password_required') || 0;
148     $self->{password_required} = $password_required;
149
150     return $self;
151
152 }
153
154 # Wrapper method for HTTP requests.
155 sub handle_http_request {
156     my $self = shift;
157     my $req = shift;
158
159     # Prep our request using defaults.
160     $req->{method} = 'GET' if (!$req->{method});
161     $req = $self->set_http_headers($req);
162
163     # Send the request.
164     my $res = $self->request($req, $self->{session_id});
165
166     $logger->info("EbookAPI: raw OverDrive HTTP response: " . Dumper $res);
167
168     # A "401 Unauthorized" response means we need to re-auth our client or patron.
169     if (defined ($res) && $res->{status} =~ /^401/) {
170         $logger->info("EbookAPI: 401 response received from OverDrive, re-authorizing...");
171
172         # Always re-auth client to ensure we have an up-to-date client token.
173         $self->do_client_auth();
174
175         # If we're using a Circulation API, redo patron auth too.
176         my $circulation_base_uri = $self->{circulation_base_uri};
177         if ($req->{uri} =~ /^$circulation_base_uri/) {
178             $self->do_patron_auth();
179         }
180
181         # Now we can update our headers with our fresh client/patron tokens
182         # and re-send our request.
183         $req = $self->set_http_headers($req);
184         return $self->request($req, $self->{session_id});
185     }
186
187     # For any non-401 response (including no response at all),
188     # just return whatever response we got (if any).
189     return $res;
190 }
191
192 # Set the correct headers for our request.
193 # Authorization headers are determined by which API we're using:
194 # - Circulation APIs use a patron access token.
195 # - Discovery APIs use a regular access token.
196 # - For other APIs, fallback to our basic token.
197 sub set_http_headers {
198     my $self = shift;
199     my $req = shift;
200     $req->{headers} = {} if (!$req->{headers});
201     if (!$req->{headers}->{Authorization}) {
202         my $auth_type;
203         my $token;
204         my $circulation_base_uri = $self->{circulation_base_uri};
205         my $discovery_base_uri = $self->{discovery_base_uri};
206         if ($req->{uri} =~ /^$circulation_base_uri/) {
207             $auth_type = 'Bearer';
208             $token = $self->{patron_token};
209         } elsif ($req->{uri} =~ /^$discovery_base_uri/) {
210             $auth_type = 'Bearer';
211             $token = $self->{bearer_token};
212         } else {
213             $auth_type = 'Basic';
214             $token = $self->{basic_token};
215         }
216         if (!$token) {
217             $logger->error("EbookAPI: unable to set HTTP Authorization header without token");
218             $logger->error("EbookAPI: failed request: " . Dumper $req);
219             return;
220         } else {
221             $req->{headers}->{Authorization} = "$auth_type $token";
222         }
223     }
224     return $req;
225 }
226
227 # POST /token HTTP/1.1
228 # Host: oauth.overdrive.com
229 # Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
230
231 # grant_type=client_credentials
232 sub do_client_auth {
233     my $self = shift;
234     my $req = {
235         method  => 'POST',
236         uri     => 'https://oauth.overdrive.com/token',
237         headers => {
238             'Authorization' => 'Basic ' . $self->{basic_token},
239             'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
240         },
241         content => 'grant_type=client_credentials'
242     };
243     my $res = $self->request($req, $self->{session_id});
244
245     if (defined ($res)) {
246         if ($res->{content}->{access_token}) {
247             # save our access token for future use
248             $self->{bearer_token} = $res->{content}->{access_token};
249             # use access token to grab other library info (e.g. collection token)
250             $self->get_library_info();
251             return $res;
252         } else {
253             $logger->error("EbookAPI: bearer token not received from OverDrive API");
254             $logger->error("EbookAPI: bad response: " . Dumper $res);
255         }
256     } else {
257         $logger->error("EbookAPI: no client authentication response from OverDrive API");
258     }
259     return;
260 }
261
262 sub do_patron_auth {
263     my $self = shift;
264     my @args = @_;
265     if ($self->{granted_auth_uri}) {
266         return $self->do_granted_patron_auth(@args);
267     } else {
268         return $self->do_basic_patron_auth(@args);
269     }
270 }
271
272 # TODO
273 sub do_granted_patron_auth {
274 }
275
276 # POST /patrontoken HTTP/1.1
277 # Host: oauth-patron.overdrive.com
278 # Authorization: Basic {Base64-encoded string}
279 # Content-Type: application/x-www-form-urlencoded;charset=UTF-8
280
281 # grant_type=password&username=1234567890&password=1234&scope=websiteid:12345 authorizationname:default
282 # OR:
283 # grant_type=password&username=1234567890&password=[ignore]&password_required=false&scope=websiteid:12345 authorizationname:default
284 sub do_basic_patron_auth {
285     my $self = shift;
286     my $barcode = shift;
287
288     if ($barcode) {
289         if (!$self->{patron_barcode}) {
290             $self->{patron_barcode} = $barcode;
291         } elsif ($barcode ne $self->{patron_barcode}) {
292             $logger->error("EbookAPI: patron barcode in auth request does not match patron barcode for this session");
293             return;
294         }
295     } else {
296         if (!$self->{patron_barcode}) {
297             $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
298         } else {
299             $barcode = $self->{patron_barcode};
300         }
301     }
302
303     # TODO handle cached/expired tokens?
304     # Making a request using an expired token will give a 401 Unauthorized error.
305     # Handle this appropriately.
306
307     # request content is an ugly url-encoded string
308     my $pw = (defined $self->{patron_password}) ? $self->{patron_password} : '';
309     my $content = 'grant_type=password';
310     $content .= "&username=$barcode";
311     if ($self->{password_required}) {
312         $content .= "&password=$pw";
313     } else {
314         $content .= '&password=xxx&password_required=false'
315     }
316     $content .= '&scope=websiteid:' . $self->{websiteid} . ' authorizationname:' . $self->{authorizationname};
317
318     my $req = {
319         method  => 'POST',
320         uri     => 'https://oauth-patron.overdrive.com/patrontoken',
321         headers => {
322             'Authorization' => 'Basic ' . $self->{basic_token},
323             'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
324         },
325         content => $content
326     };
327     my $res = $self->request($req, $self->{session_id});
328
329     if (defined ($res)) {
330         if ($res->{content}->{access_token}) {
331             $self->{patron_token} = $res->{content}->{access_token};
332             return $self->{patron_token};
333         } else {
334             $logger->error("EbookAPI: patron access token not received from OverDrive API");
335         }
336     } else {
337         $logger->error("EbookAPI: no patron authentication response from OverDrive API");
338     }
339     return;
340 }
341
342 # GET http://api.overdrive.com/v1/libraries/1225
343 # User-Agent: {Your application}
344 # Authorization: Bearer {OAuth access token}
345 # Host: api.overdrive.com
346 sub get_library_info {
347     my $self = shift;
348     my $library_id = $self->{account_id};
349     my $req = {
350         method  => 'GET',
351         uri     => $self->{discovery_base_uri} . "/libraries/$library_id"
352     };
353     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
354         $self->{collection_token} = $res->{content}->{collectionToken};
355         return $self->{collection_token};
356     } else {
357         $logger->error("EbookAPI: OverDrive Library Account API request failed");
358         return;
359     }
360 }
361
362 # GET http://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
363 # User-Agent: {Your application}
364 # Authorization: Bearer {OAuth access token}
365 # Host: api.overdrive.com
366 sub get_title_info {
367     my $self = shift;
368     my $title_id = shift;
369     $self->do_client_auth() if (!$self->{bearer_token});
370     $self->get_library_info() if (!$self->{collection_token});
371     my $collection_token = $self->{collection_token};
372     my $req = {
373         method  => 'GET',
374         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
375     };
376     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
377         if ($res->{content}->{title}) {
378             return {
379                 title  => $res->{content}->{title},
380                 author => $res->{content}->{creators}[0]{name}
381             };
382         } else {
383             $logger->error("EbookAPI: OverDrive metadata lookup failed for $title_id");
384         }
385     } else {
386         $logger->error("EbookAPI: no metadata response from OverDrive API");
387     }
388     return;
389 }
390
391 # GET http://api.overdrive.com/v1/collections/L1BAAEAAA2i/products/76C1B7D0-17F4-4C05-8397-C66C17411584/availability
392 # User-Agent: {Your application}
393 # Authorization: Bearer {OAuth access token}
394 # Host: api.overdrive.com
395 sub do_availability_lookup {
396     my $self = shift;
397     my $title_id = shift;
398     $self->do_client_auth() if (!$self->{bearer_token});
399     $self->get_library_info() if (!$self->{collection_token});
400     my $req = {
401         method  => 'GET',
402         uri     => $self->{discovery_base_uri} . "/collections/" . $self->{collection_token} . "/products/$title_id/availability"
403     };
404     if (my $res = $self->handle_http_request($req, $self->{session_id})) {
405         return $res->{content}->{available};
406     } else {
407         $logger->error("EbookAPI: could not retrieve OverDrive availability for title $title_id");
408         return;
409     }
410 }
411
412 # Holdings lookup has two parts:
413 #
414 # 1. Copy availability: as above, but grab more details.
415 #
416 # 2. Formats:
417 #     GET https://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
418 #     User-Agent: {Your application}
419 #     Authorization: Bearer {OAuth access token}
420 #     Host: api.overdrive.com
421 #
422 sub do_holdings_lookup {
423     my ($self, $title_id) = @_;
424     $self->do_client_auth() if (!$self->{bearer_token});
425     $self->get_library_info() if (!$self->{collection_token});
426     my $collection_token = $self->{collection_token};
427
428     # prepare data structure to be used as return value
429     my $holdings = {
430         copies_owned => 0,
431         copies_available => 0,
432         formats => []
433     };
434
435     # request copy availability totals
436     my $avail_req = {
437         method  => 'GET',
438         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/availability"
439     };
440     if (my $avail_res = $self->handle_http_request($avail_req, $self->{session_id})) {
441         $holdings->{copies_owned} = $avail_res->{content}->{copiesOwned};
442         $holdings->{copies_available} = $avail_res->{content}->{copiesAvailable};
443     } else {
444         $logger->error("EbookAPI: failed to retrieve OverDrive holdings counts for title $title_id");
445     }
446
447     # request available formats
448     my $format_req = {
449         method  => 'GET',
450         uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
451     };
452     if (my $format_res = $self->handle_http_request($format_req, $self->{session_id})) {
453         if ($format_res->{content}->{formats}) {
454             foreach my $f (@{$format_res->{content}->{formats}}) {
455                 push @{$holdings->{formats}}, $f->{name};
456             }
457         } else {
458             $logger->info("EbookAPI: OverDrive holdings format request for title $title_id contained no format information");
459         }
460     } else {
461         $logger->error("EbookAPI: failed to retrieve OverDrive holdings formats for title $title_id");
462     }
463
464     return $holdings;
465 }
466
467 # List of patron checkouts:
468 # GET http://patron.api.overdrive.com/v1/patrons/me/checkouts
469 # User-Agent: {Your application}
470 # Authorization: Bearer {OAuth patron access token}
471 # Host: patron.api.overdrive.com
472 #
473 # Response looks like this:
474 # {
475 #     "totalItems": 4,
476 #     "totalCheckouts": 2,
477 #     "checkouts": [
478 #         {
479 #             "reserveId": "A03EAC2C-C088-46C6-B9E9-59D6C11A3596",
480 #             "expires": "2015-08-11T18:53:00Z",
481 #             ...
482 #         }
483 #     ],
484 #     ...
485 # }
486 #
487 # To get title metadata (e.g. title/author), do get_title_info(reserveId).
488 sub get_patron_checkouts {
489     my $self = shift;
490     my $patron_token = shift;
491     if (my $res = $self->do_get_patron_xacts('checkouts', $patron_token)) {
492         my $checkouts = [];
493         foreach my $checkout (@{$res->{content}->{checkouts}}) {
494             my $title_id = $checkout->{reserveId};
495             my $title_info = $self->get_title_info($title_id);
496             # TODO get download URL - need to "lock in" a format first, see OD Checkouts API docs
497             push @$checkouts, {
498                 title_id => $title_id,
499                 due_date => $checkout->{expires},
500                 title => $title_info->{title},
501                 author => $title_info->{author}
502             }
503         };
504         $self->{checkouts} = $checkouts;
505         return $self->{checkouts};
506     } else {
507         $logger->error("EbookAPI: unable to retrieve OverDrive checkouts for patron " . $self->{patron_barcode});
508         return;
509     }
510 }
511
512 sub get_patron_holds {
513     my $self = shift;
514     my $patron_token = shift;
515     if (my $res = $self->do_get_patron_xacts('holds', $patron_token)) {
516         my $holds = [];
517         foreach my $hold (@{$res->{content}->{holds}}) {
518             my $title_id = $hold->{reserveId};
519             my $title_info = $self->get_title_info($title_id);
520             my $this_hold = {
521                 title_id => $title_id,
522                 queue_position => $hold->{holdListPosition},
523                 queue_size => $hold->{numberOfHolds},
524                 # TODO: special handling for ready-to-checkout holds
525                 is_ready => ( $hold->{actions}->{checkout} ) ? 1 : 0,
526                 create_date => $hold->{holdPlacedDate},
527                 expire_date => ( $hold->{holdExpires} ) ? $hold->{holdExpires} : '-',
528                 title => $title_info->{title},
529                 author => $title_info->{author}
530             };
531             # TODO: hold suspensions
532             push @$holds, $this_hold;
533         }
534         $self->{holds} = $holds;
535         return $self->{holds};
536     } else {
537         $logger->error("EbookAPI: unable to retrieve OverDrive holds for patron " . $self->{patron_barcode});
538         return;
539     }
540 }
541
542 # generic function for retrieving patron transactions
543 sub do_get_patron_xacts {
544     my $self = shift;
545     my $xact_type = shift;
546     my $patron_token = shift;
547     if (!$patron_token) {
548         if ($self->{patron_barcode}) {
549             $self->do_client_auth() if (!$self->{bearer_token});
550             $self->do_patron_auth();
551         } else {
552             $logger->error("EbookAPI: Cannot retrieve OverDrive $xact_type with no patron information");
553         }
554     }
555     my $req = {
556         method  => 'GET',
557         uri     => $self->{circulation_base_uri} . "/patrons/me/$xact_type"
558     };
559     return $self->handle_http_request($req, $self->{session_id});
560 }
561
562 1;