3 # Copyright (C) 2015 BC Libraries Cooperative
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.
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.
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.
19 package OpenILS::Application::EbookAPI::OverDrive;
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;
36 my( $class, $args ) = @_;
37 $class = ref $class || $class;
38 return bless $args, $class;
48 return $self->{vendor};
53 return $self->{session_id};
58 return $self->{account_id};
63 return $self->{websiteid};
66 sub authorizationname {
68 return $self->{authorizationname};
73 return $self->{basic_token};
78 return $self->{bearer_token};
81 sub collection_token {
83 return $self->{collection_token};
86 sub granted_auth_uri {
88 return $self->{granted_auth_uri};
91 sub password_required {
93 return $self->{password_required};
98 return $self->{patron_token};
103 my $ou = $self->{ou};
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';
110 my $account_id = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.account_id');
112 $self->{account_id} = $account_id;
114 $logger->error("EbookAPI: no OverDrive account ID found for org unit $ou");
118 my $websiteid = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.websiteid');
120 $self->{websiteid} = $websiteid;
122 $logger->error("EbookAPI: no OverDrive website ID found for org unit $ou");
126 my $authorizationname = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.authorizationname');
127 if ($authorizationname) {
128 $self->{authorizationname} = $authorizationname;
130 $logger->error("EbookAPI: no OverDrive authorization name found for org unit $ou");
134 my $basic_token = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.basic_token');
136 $self->{basic_token} = $basic_token;
138 $logger->error("EbookAPI: no OverDrive basic token found for org unit $ou");
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;
147 my $password_required = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.password_required') || 0;
148 $self->{password_required} = $password_required;
154 # Wrapper method for HTTP requests.
155 sub handle_http_request {
159 # Prep our request using defaults.
160 $req->{method} = 'GET' if (!$req->{method});
161 $req = $self->set_http_headers($req);
164 my $res = $self->request($req, $self->{session_id});
166 $logger->info("EbookAPI: raw OverDrive HTTP response: " . Dumper $res);
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...");
172 # Always re-auth client to ensure we have an up-to-date client token.
173 $self->do_client_auth();
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();
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});
187 # For any non-401 response (including no response at all),
188 # just return whatever response we got (if any).
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 {
200 $req->{headers} = {} if (!$req->{headers});
201 if (!$req->{headers}->{Authorization}) {
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};
213 $auth_type = 'Basic';
214 $token = $self->{basic_token};
217 $logger->error("EbookAPI: unable to set HTTP Authorization header without token");
218 $logger->error("EbookAPI: failed request: " . Dumper $req);
221 $req->{headers}->{Authorization} = "$auth_type $token";
227 # POST /token HTTP/1.1
228 # Host: oauth.overdrive.com
229 # Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
231 # grant_type=client_credentials
236 uri => 'https://oauth.overdrive.com/token',
238 'Authorization' => 'Basic ' . $self->{basic_token},
239 'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8'
241 content => 'grant_type=client_credentials'
243 my $res = $self->request($req, $self->{session_id});
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();
253 $logger->error("EbookAPI: bearer token not received from OverDrive API");
254 $logger->error("EbookAPI: bad response: " . Dumper $res);
257 $logger->error("EbookAPI: no client authentication response from OverDrive API");
265 if ($self->{granted_auth_uri}) {
266 return $self->do_granted_patron_auth(@args);
268 return $self->do_basic_patron_auth(@args);
273 sub do_granted_patron_auth {
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
281 # grant_type=password&username=1234567890&password=1234&scope=websiteid:12345 authorizationname:default
283 # grant_type=password&username=1234567890&password=[ignore]&password_required=false&scope=websiteid:12345 authorizationname:default
284 sub do_basic_patron_auth {
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");
296 if (!$self->{patron_barcode}) {
297 $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
299 $barcode = $self->{patron_barcode};
303 # TODO handle cached/expired tokens?
304 # Making a request using an expired token will give a 401 Unauthorized error.
305 # Handle this appropriately.
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";
314 $content .= '&password=xxx&password_required=false'
316 $content .= '&scope=websiteid:' . $self->{websiteid} . ' authorizationname:' . $self->{authorizationname};
320 uri => 'https://oauth-patron.overdrive.com/patrontoken',
322 'Authorization' => 'Basic ' . $self->{basic_token},
323 'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8'
327 my $res = $self->request($req, $self->{session_id});
329 if (defined ($res)) {
330 if ($res->{content}->{access_token}) {
331 $self->{patron_token} = $res->{content}->{access_token};
332 return $self->{patron_token};
334 $logger->error("EbookAPI: patron access token not received from OverDrive API");
337 $logger->error("EbookAPI: no patron authentication response from OverDrive API");
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 {
348 my $library_id = $self->{account_id};
351 uri => $self->{discovery_base_uri} . "/libraries/$library_id"
353 if (my $res = $self->handle_http_request($req, $self->{session_id})) {
354 $self->{collection_token} = $res->{content}->{collectionToken};
355 return $self->{collection_token};
357 $logger->error("EbookAPI: OverDrive Library Account API request failed");
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
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};
374 uri => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
376 if (my $res = $self->handle_http_request($req, $self->{session_id})) {
377 if ($res->{content}->{title}) {
379 title => $res->{content}->{title},
380 author => $res->{content}->{creators}[0]{name}
383 $logger->error("EbookAPI: OverDrive metadata lookup failed for $title_id");
386 $logger->error("EbookAPI: no metadata response from OverDrive API");
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 {
397 my $title_id = shift;
398 $self->do_client_auth() if (!$self->{bearer_token});
399 $self->get_library_info() if (!$self->{collection_token});
402 uri => $self->{discovery_base_uri} . "/collections/" . $self->{collection_token} . "/products/$title_id/availability"
404 if (my $res = $self->handle_http_request($req, $self->{session_id})) {
405 return $res->{content}->{available};
407 $logger->error("EbookAPI: could not retrieve OverDrive availability for title $title_id");
412 # Holdings lookup has two parts:
414 # 1. Copy availability: as above, but grab more details.
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
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};
428 # prepare data structure to be used as return value
431 copies_available => 0,
435 # request copy availability totals
438 uri => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/availability"
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};
444 $logger->error("EbookAPI: failed to retrieve OverDrive holdings counts for title $title_id");
447 # request available formats
450 uri => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
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};
458 $logger->info("EbookAPI: OverDrive holdings format request for title $title_id contained no format information");
461 $logger->error("EbookAPI: failed to retrieve OverDrive holdings formats for title $title_id");
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
473 # Response looks like this:
476 # "totalCheckouts": 2,
479 # "reserveId": "A03EAC2C-C088-46C6-B9E9-59D6C11A3596",
480 # "expires": "2015-08-11T18:53:00Z",
487 # To get title metadata (e.g. title/author), do get_title_info(reserveId).
488 sub get_patron_checkouts {
490 my $patron_token = shift;
491 if (my $res = $self->do_get_patron_xacts('checkouts', $patron_token)) {
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
498 title_id => $title_id,
499 due_date => $checkout->{expires},
500 title => $title_info->{title},
501 author => $title_info->{author}
504 $self->{checkouts} = $checkouts;
505 return $self->{checkouts};
507 $logger->error("EbookAPI: unable to retrieve OverDrive checkouts for patron " . $self->{patron_barcode});
512 sub get_patron_holds {
514 my $patron_token = shift;
515 if (my $res = $self->do_get_patron_xacts('holds', $patron_token)) {
517 foreach my $hold (@{$res->{content}->{holds}}) {
518 my $title_id = $hold->{reserveId};
519 my $title_info = $self->get_title_info($title_id);
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}
531 # TODO: hold suspensions
532 push @$holds, $this_hold;
534 $self->{holds} = $holds;
535 return $self->{holds};
537 $logger->error("EbookAPI: unable to retrieve OverDrive holds for patron " . $self->{patron_barcode});
542 # generic function for retrieving patron transactions
543 sub do_get_patron_xacts {
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();
552 $logger->error("EbookAPI: Cannot retrieve OverDrive $xact_type with no patron information");
557 uri => $self->{circulation_base_uri} . "/patrons/me/$xact_type"
559 return $self->handle_http_request($req, $self->{session_id});