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 OpenSRF::Utils::JSON;
33 use OpenILS::Application::AppUtils;
37 my( $class, $args ) = @_;
38 $class = ref $class || $class;
39 return bless $args, $class;
49 return $self->{vendor};
54 return $self->{session_id};
59 return $self->{account_id};
64 return $self->{websiteid};
67 sub authorizationname {
69 return $self->{authorizationname};
74 return $self->{basic_token};
79 return $self->{bearer_token};
82 sub collection_token {
84 return $self->{collection_token};
87 sub granted_auth_uri {
89 return $self->{granted_auth_uri};
92 sub password_required {
94 return $self->{password_required};
99 return $self->{patron_token};
104 my $ou = $self->{ou};
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';
111 my $account_id = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.account_id');
113 $self->{account_id} = $account_id;
115 $logger->error("EbookAPI: no OverDrive account ID found for org unit $ou");
119 my $websiteid = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.websiteid');
121 $self->{websiteid} = $websiteid;
123 $logger->error("EbookAPI: no OverDrive website ID found for org unit $ou");
127 my $authorizationname = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.authorizationname');
128 if ($authorizationname) {
129 $self->{authorizationname} = $authorizationname;
131 $logger->error("EbookAPI: no OverDrive authorization name found for org unit $ou");
135 my $basic_token = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.basic_token');
137 $self->{basic_token} = $basic_token;
139 $logger->error("EbookAPI: no OverDrive basic token found for org unit $ou");
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;
148 my $password_required = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.password_required') || 0;
149 $self->{password_required} = $password_required;
155 # Wrapper method for HTTP requests.
156 sub handle_http_request {
159 my $session_id = shift;
160 my $do_not_redirect = shift;
162 # Prep our request using defaults.
163 $req->{method} = 'GET' if (!$req->{method});
164 $req = $self->set_http_headers($req);
167 my $res = $self->request($req, $self->{session_id}, $do_not_redirect);
169 $logger->info("EbookAPI: raw OverDrive HTTP response: " . Dumper $res);
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...");
175 # Always re-auth client to ensure we have an up-to-date client token.
176 $self->do_client_auth();
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();
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);
190 # For any non-401 response (including no response at all),
191 # just return whatever response we got (if any).
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 {
203 $req->{headers} = {} if (!$req->{headers});
204 if (!$req->{headers}->{Authorization}) {
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};
216 $auth_type = 'Basic';
217 $token = $self->{basic_token};
220 $logger->error("EbookAPI: unable to set HTTP Authorization header without token");
221 $logger->error("EbookAPI: failed request: " . Dumper $req);
224 $req->{headers}->{Authorization} = "$auth_type $token";
230 # POST /token HTTP/1.1
231 # Host: oauth.overdrive.com
232 # Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
234 # grant_type=client_credentials
239 uri => 'https://oauth.overdrive.com/token',
241 'Authorization' => 'Basic ' . $self->{basic_token},
242 'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8'
244 content => 'grant_type=client_credentials'
246 my $res = $self->request($req, $self->{session_id});
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();
256 $logger->error("EbookAPI: bearer token not received from OverDrive API");
257 $logger->error("EbookAPI: bad response: " . Dumper $res);
260 $logger->error("EbookAPI: no client authentication response from OverDrive API");
268 if ($self->{granted_auth_uri}) {
269 return $self->do_granted_patron_auth(@args);
271 return $self->do_basic_patron_auth(@args);
276 sub do_granted_patron_auth {
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
284 # grant_type=password&username=1234567890&password=1234&scope=websiteid:12345 authorizationname:default
286 # grant_type=password&username=1234567890&password=[ignore]&password_required=false&scope=websiteid:12345 authorizationname:default
287 sub do_basic_patron_auth {
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");
299 if (!$self->{patron_barcode}) {
300 $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
302 $barcode = $self->{patron_barcode};
306 # TODO handle cached/expired tokens?
307 # Making a request using an expired token will give a 401 Unauthorized error.
308 # Handle this appropriately.
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";
317 $content .= '&password=xxx&password_required=false'
319 $content .= '&scope=websiteid:' . $self->{websiteid} . ' authorizationname:' . $self->{authorizationname};
323 uri => 'https://oauth-patron.overdrive.com/patrontoken',
325 'Authorization' => 'Basic ' . $self->{basic_token},
326 'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8'
330 my $res = $self->request($req, $self->{session_id});
332 if (defined ($res)) {
333 if ($res->{content}->{access_token}) {
334 $self->{patron_token} = $res->{content}->{access_token};
335 return $self->{patron_token};
337 $logger->error("EbookAPI: patron access token not received from OverDrive API");
340 $logger->error("EbookAPI: no patron authentication response from OverDrive API");
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 {
351 my $library_id = $self->{account_id};
354 uri => $self->{discovery_base_uri} . "/libraries/$library_id"
356 if (my $res = $self->handle_http_request($req, $self->{session_id})) {
357 $self->{collection_token} = $res->{content}->{collectionToken};
358 return $self->{collection_token};
360 $logger->error("EbookAPI: OverDrive Library Account API request failed");
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
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};
377 uri => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
379 if (my $res = $self->handle_http_request($req, $self->{session_id})) {
380 if ($res->{content}->{title}) {
382 title => $res->{content}->{title},
383 author => $res->{content}->{creators}[0]{name}
385 # Append format information (useful for checkouts).
386 $info->{formats} = $self->get_formats($title_id);
389 $logger->error("EbookAPI: OverDrive metadata lookup failed for $title_id");
392 $logger->error("EbookAPI: no metadata response from OverDrive API");
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 {
403 my $title_id = shift;
404 $self->do_client_auth() if (!$self->{bearer_token});
405 $self->get_library_info() if (!$self->{collection_token});
408 uri => $self->{discovery_base_uri} . "/collections/" . $self->{collection_token} . "/products/$title_id/availability"
410 if (my $res = $self->handle_http_request($req, $self->{session_id})) {
411 return $res->{content}->{available};
413 $logger->error("EbookAPI: could not retrieve OverDrive availability for title $title_id");
418 # Holdings lookup has two parts:
420 # 1. Copy availability: as above, but grab more details.
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
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};
434 # prepare data structure to be used as return value
437 copies_available => 0,
441 # request copy availability totals
444 uri => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/availability"
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};
450 $logger->error("EbookAPI: failed to retrieve OverDrive holdings counts for title $title_id");
453 # request available formats
454 $holdings->{formats} = $self->get_formats($title_id);
459 # Returns a list of available formats for a given title.
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};
470 uri => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
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} };
478 $logger->info("EbookAPI: OverDrive holdings format request for title $title_id contained no format information");
481 $logger->error("EbookAPI: failed to retrieve OverDrive holdings formats for title $title_id");
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
491 # Request content looks like this:
495 # "name": "reserveId",
496 # "value": "76C1B7D0-17F4-4C05-8397-C66C17411584"
501 # Response looks like this:
503 # "reserveId": "76C1B7D0-17F4-4C05-8397-C66C17411584",
504 # "expires": "10/14/2013 10:56:00 AM",
505 # "isFormatLockedIn": false,
508 # "reserveId": "76C1B7D0-17F4-4C05-8397-C66C17411584",
509 # "formatType": "ebook-overdrive",
512 # "href": "https://patron.api.overdrive.com/v1/patrons/me/checkouts/76C1B7D0-17F4-4C05-8397-C66C17411584/formats/ebook-overdrive/downloadlink?errorpageurl={errorpageurl}&odreadauthurl={odreadauthurl}",
523 # Our return value looks like this:
525 # due_date => "10/14/2013 10:56:00 AM",
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}",
532 my ($self, $title_id, $patron_token, $format) = @_;
533 my $request_content = {
542 push @{$request_content->{fields}}, { name => 'formatType', value => $format };
546 uri => $self->{circulation_base_uri} . "/patrons/me/checkouts",
547 content => OpenSRF::Utils::JSON->perl2JSON($request_content)
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}) {
554 foreach my $f (@{$res->{content}->{formats}}) {
555 my $ftype = $f->{formatType};
556 $formats->{$ftype} = $f->{linkTemplates}->{downloadLink}->{href};
558 $checkout->{formats} = $formats;
560 if ($res->{content}->{links}->{downloadRedirect}->{href}) {
561 my $redir = $res->{content}->{links}->{downloadRedirect}->{href};
566 if (my $res2 = $self->handle_http_request($req2, $self->{session_id}, 1)) {
567 if ($res2->{location}) {
568 $checkout->{download_redirect} = $res2->{location};
574 $logger->error("EbookAPI: checkout failed for OverDrive title $title_id");
575 return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Unknown checkout error' ) };
577 $logger->error("EbookAPI: no response received from OverDrive server");
581 # renew is not supported by OverDrive API
583 $logger->error("EbookAPI: OverDrive API does not support renewals");
584 return { error_msg => "Title cannot be renewed." };
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
593 my ($self, $title_id, $patron_token) = @_;
596 uri => $self->{circulation_base_uri} . "/patrons/me/checkouts/$title_id"
598 if (my $res = $self->handle_http_request($req, $self->{session_id})) {
599 if ($res->{status} =~ /^204/) {
602 $logger->error("EbookAPI: checkin failed for OverDrive title $title_id");
603 return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Checkin failed' ) };
606 $logger->error("EbookAPI: no response received from OverDrive server");
611 my ($self, $title_id, $patron_token, $email) = @_;
619 push @$fields, { name => 'emailAddress', value => $email };
620 # TODO: Use autoCheckout=true when we have a patron email?
622 push @$fields, { name => 'ignoreEmail', value => 'true' };
624 my $request_content = { fields => $fields };
627 uri => $self->{circulation_base_uri} . "/patrons/me/holds",
628 content => OpenSRF::Utils::JSON->perl2JSON($request_content)
630 if (my $res = $self->handle_http_request($req, $self->{session_id})) {
631 if ($res->{content}->{holdPlacedDate}) {
633 queue_position => $res->{content}->{holdListPosition},
634 queue_size => $res->{content}->{numberOfHolds},
635 expire_date => (defined $res->{content}->{holdExpires}) ? $res->{content}->{holdExpires} : undef
638 $logger->error("EbookAPI: place hold failed for OverDrive title $title_id");
639 return { error_msg => "Could not place hold." };
641 $logger->error("EbookAPI: no response received from OverDrive server");
646 my ($self, $title_id, $patron_token) = @_;
649 uri => $self->{circulation_base_uri} . "/patrons/me/holds/$title_id"
651 if (my $res = $self->handle_http_request($req, $self->{session_id})) {
652 if ($res->{status} =~ /^204/) {
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' ) };
659 $logger->error("EbookAPI: no response received from OverDrive server");
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
669 # Response looks like this:
672 # "totalCheckouts": 2,
675 # "reserveId": "A03EAC2C-C088-46C6-B9E9-59D6C11A3596",
676 # "expires": "2015-08-11T18:53:00Z",
683 # To get title metadata (e.g. title/author), do get_title_info(reserveId).
684 sub get_patron_checkouts {
686 my $patron_token = shift;
687 if (my $res = $self->do_get_patron_xacts('checkouts', $patron_token)) {
689 foreach my $checkout (@{$res->{content}->{checkouts}}) {
690 my $title_id = $checkout->{reserveId};
691 my $title_info = $self->get_title_info($title_id);
693 foreach my $f (@{$checkout->{formats}}) {
694 my $ftype = $f->{formatType};
695 $formats->{$ftype} = $f->{linkTemplates}->{downloadLink}->{href};
697 my $download_redirect = '';
698 my $redirect = $checkout->{links}->{downloadRedirect}->{href};
704 if (my $res2 = $self->handle_http_request($req2, $self->{session_id}, 1)) {
705 if ($res2->{location}) {
706 $download_redirect = $res2->{location};
711 title_id => $title_id,
712 due_date => $checkout->{expires},
713 title => $title_info->{title},
714 author => $title_info->{author},
716 download_redirect => $download_redirect
719 $self->{checkouts} = $checkouts;
720 return $self->{checkouts};
722 $logger->error("EbookAPI: unable to retrieve OverDrive checkouts for patron " . $self->{patron_barcode});
727 sub get_patron_holds {
729 my $patron_token = shift;
730 if (my $res = $self->do_get_patron_xacts('holds', $patron_token)) {
732 foreach my $hold (@{$res->{content}->{holds}}) {
733 my $title_id = $hold->{reserveId};
734 my $title_info = $self->get_title_info($title_id);
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}
747 # TODO: hold suspensions
748 push @$holds, $this_hold;
750 $self->{holds} = $holds;
751 return $self->{holds};
753 $logger->error("EbookAPI: unable to retrieve OverDrive holds for patron " . $self->{patron_barcode});
758 # generic function for retrieving patron transactions
759 sub do_get_patron_xacts {
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();
768 $logger->error("EbookAPI: Cannot retrieve OverDrive $xact_type with no patron information");
773 uri => $self->{circulation_base_uri} . "/patrons/me/$xact_type"
775 return $self->handle_http_request($req, $self->{session_id});
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
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:/;
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} };
801 return { error_msg => ( (defined $res->{content}) ? $res->{content} : 'Could not get content link' ) };
803 $logger->error("EbookAPI: no response received from OverDrive server");