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 # ======================================================================
20 # We define a handler class for each vendor API (OneClickdigital, OverDrive, etc.).
21 # See EbookAPI/Test.pm for a reference implementation with required methods,
22 # arguments, and return values.
23 # ======================================================================
25 package OpenILS::Application::EbookAPI;
30 use Time::HiRes qw/gettimeofday/;
31 use Digest::MD5 qw/md5_hex/;
33 use OpenILS::Application;
34 use base qw/OpenILS::Application/;
35 use OpenSRF::AppSession;
36 use OpenILS::Utils::CStoreEditor qw/:funcs/;
37 use OpenSRF::EX qw(:try);
38 use OpenSRF::Utils::SettingsClient;
39 use OpenSRF::Utils::Logger qw($logger);
40 use OpenSRF::Utils::Cache;
41 use OpenSRF::Utils::JSON;
42 use OpenILS::Utils::HTTPClient;
47 my $default_request_timeout;
49 # map EbookAPI vendor codes to corresponding packages
50 our %vendor_handlers = (
51 'ebook_test' => 'OpenILS::Application::EbookAPI::Test',
52 'oneclickdigital' => 'OpenILS::Application::EbookAPI::OneClickdigital',
53 'overdrive' => 'OpenILS::Application::EbookAPI::OverDrive'
57 $cache = OpenSRF::Utils::Cache->new;
59 my $sclient = OpenSRF::Utils::SettingsClient->new();
60 $cache_timeout = $sclient->config_value("apps", "open-ils.ebook_api", "app_settings", "cache_timeout" ) || 300;
61 $default_request_timeout = $sclient->config_value("apps", "open-ils.ebook_api", "app_settings", "request_timeout" ) || 60;
64 # returns the cached object (if successful)
66 my $cache_obj = shift;
67 my $overlay = shift || 0;
69 if ($cache_obj->{session_id}) {
70 $cache_key = $cache_obj->{session_id};
72 $logger->error("EbookAPI: cannot update cache with unknown cache object");
76 # Optionally, keep old cached field values unless a new value for that
77 # field is explicitly provided. This makes it easier for asynchronous
78 # requests (e.g. for circs and holds) to cache their results.
80 if (my $orig_cache = $cache->get_cache($cache_key)) {
81 $logger->info("EbookAPI: overlaying new values on existing cache object");
82 foreach my $k (%$cache_obj) {
83 # Add/overwrite existing cached value if a new value is defined.
84 $orig_cache->{$k} = $cache_obj->{$k} if (defined $cache_obj->{$k});
86 # The cache object we want to save is the (updated) original one.
87 $cache_obj = $orig_cache;
91 try { # fail silently if there's no pre-existing cache to delete
92 $cache->delete_cache($cache_key);
93 } catch Error with {};
94 if (my $success_key = $cache->put_cache($cache_key, $cache_obj, $cache_timeout)) {
95 return $cache->get_cache($success_key);
97 $logger->error("EbookAPI: error when updating cache with object");
102 sub retrieve_session {
103 my $session_id = shift;
104 unless ($session_id) {
105 $logger->info("EbookAPI: no session ID provided");
108 my $cached_session = $cache->get_cache($session_id) || undef;
109 if ($cached_session) {
110 return $cached_session;
112 $logger->info("EbookAPI: could not find cached session with id $session_id");
117 # prepare new handler from session
118 # (will retrieve cached session unless a session object is provided)
120 my $session_id = shift;
121 my $ses = shift || retrieve_session($session_id);
123 $logger->error("EbookAPI: could not start handler - no cached session with ID $session_id");
126 my $module = ref($ses);
127 $logger->info("EbookAPI: starting new $module handler from cached session $session_id...");
129 my $handler = $module->new($ses);
137 my $session_id = shift;
141 return start_session($self, $conn, $vendor, $ou) unless $session_id;
143 my $cached_session = retrieve_session($session_id);
144 if ($cached_session) {
145 # re-authorize cached session, if applicable
146 my $handler = new_handler($session_id, $cached_session);
147 $handler->do_client_auth();
148 if (update_cache($handler)) {
151 $logger->error("EbookAPI: error updating session cache");
155 return start_session($self, $conn, $vendor, $ou);
158 __PACKAGE__->register_method(
159 method => 'check_session',
160 api_name => 'open-ils.ebook_api.check_session',
164 desc => "Validate an existing EbookAPI session, or initiate a new one",
167 name => 'session_id',
168 desc => 'The EbookAPI session ID being checked',
173 desc => 'The ebook vendor (e.g. "oneclickdigital")',
178 desc => 'The context org unit ID',
183 desc => 'Returns an EbookAPI session ID',
192 $ou = $ou || 1; # default to top-level org unit
196 # determine EbookAPI handler from vendor name
197 # TODO handle API versions?
198 if ($vendor_handlers{$vendor}) {
199 $module = $vendor_handlers{$vendor};
201 $logger->error("EbookAPI: No handler module found for $vendor!");
205 # TODO cache session? reuse an existing one if available?
207 # generate session ID
208 my ($sec, $usec) = gettimeofday();
210 my $session_id = "ebook_api.ses." . md5_hex("$sec-$usec-$r");
215 session_id => $session_id
219 $handler = $module->new($args); # create new handler object
220 $handler->initialize(); # set handler attributes
221 $handler->do_client_auth(); # authorize client session against API, if applicable
223 # our "session" is actually just our handler object, serialized and cached
224 my $ckey = $handler->{session_id};
225 $cache->put_cache($ckey, $handler, $cache_timeout);
227 return $handler->{session_id};
235 return _start_session($vendor, $ou);
237 __PACKAGE__->register_method(
238 method => 'start_session',
239 api_name => 'open-ils.ebook_api.start_session',
243 desc => "Initiate an EbookAPI session",
247 desc => 'The ebook vendor (e.g. "oneclickdigital")',
252 desc => 'The context org unit ID',
257 desc => 'Returns an EbookAPI session ID',
263 sub cache_patron_password {
266 my $session_id = shift;
267 my $password = shift;
269 # We don't need the handler module for this.
270 # Let's just update the cache directly.
271 if (my $ses = $cache->get_cache($session_id)) {
272 $ses->{patron_password} = $password;
273 if (update_cache($ses)) {
276 $logger->error("EbookAPI: there was an error caching patron password");
281 __PACKAGE__->register_method(
282 method => 'cache_patron_password',
283 api_name => 'open-ils.ebook_api.patron.cache_password',
287 desc => "Cache patron password on login for use during EbookAPI patron authentication",
290 name => 'session_id',
291 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
295 name => 'patron_password',
296 desc => 'The patron password',
300 return => { desc => 'A session key, or undef' }
304 # Submit an HTTP request to a specified API endpoint.
308 # $req - hashref containing the following:
309 # method: HTTP request method (defaults to GET)
310 # uri: API endpoint URI (required)
311 # header: arrayref of HTTP headers (optional, but see below)
312 # content: content of HTTP request (optional)
313 # request_timeout (defaults to value in opensrf.xml)
314 # $session_id - id of cached EbookAPI session
316 # A "Content-Type: application/json" header is automatically added to each
317 # request. If no Authorization header is provided via the $req param, the
318 # following header will also be automatically added:
320 # Authorization: basic $basic_token
322 # ... where $basic_token is derived from the cached session identified by the
323 # $session_id param. If this does not meet the needs of your API, include the
324 # correct Authorization header in $req->{header}.
328 my $session_id = shift;
331 if (!defined ($req->{uri})) {
332 $logger->error('EbookAPI: attempted an HTTP request but no URI was provided');
338 my $method = defined $req->{method} ? $req->{method} : 'GET';
339 my $headers = defined $req->{headers} ? $req->{headers} : {};
340 my $content = defined $req->{content} ? $req->{content} : undef;
341 my $request_timeout = defined $req->{request_timeout} ? $req->{request_timeout} : $default_request_timeout;
343 # JSON as default content type
344 if ( !defined ($headers->{'Content-Type'}) ) {
345 $headers->{'Content-Type'} = 'application/json';
348 # all requests also require an Authorization header;
349 # let's default to using our basic token, if available
350 if ( !defined ($headers->{'Authorization'}) ) {
352 $logger->error("EbookAPI: HTTP request requires session info but no session ID was provided");
355 my $ses = retrieve_session($session_id);
357 my $basic_token = $ses->{basic_token};
358 $headers->{'Authorization'} = "basic $basic_token";
362 my $client = OpenILS::Utils::HTTPClient->new();
363 my $res = $client->request(
370 if (!defined ($res)) {
371 $logger->error('EbookAPI: no HTTP response received');
374 $logger->info("EbookAPI: response received from server: " . $res->status_line);
376 is_success => $res->is_success,
377 status => $res->status_line,
378 content => OpenSRF::Utils::JSON->JSON2perl($res->decoded_content)
384 my ($self, $conn, $session_id, $title_id) = @_;
385 my $handler = new_handler($session_id);
386 return $handler->get_title_info($title_id);
388 __PACKAGE__->register_method(
389 method => 'get_details',
390 api_name => 'open-ils.ebook_api.title.details',
394 desc => "Get basic metadata for an ebook title",
397 name => 'session_id',
398 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
403 desc => 'The title ID (ISBN, unique identifier, etc.)',
408 desc => 'Success: { title => "Title", author => "Author Name" } / Failure: { error => "Title not found" }',
414 sub get_availability {
415 my ($self, $conn, $session_id, $title_id) = @_;
416 my $handler = new_handler($session_id);
417 return $handler->do_availability_lookup($title_id);
419 __PACKAGE__->register_method(
420 method => 'get_availability',
421 api_name => 'open-ils.ebook_api.title.availability',
425 desc => "Get availability info for an ebook title",
428 name => 'session_id',
429 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
434 desc => 'The title ID (ISBN, unique identifier, etc.)',
439 desc => 'Returns 1 if title is available, 0 if not available, or undef if availability info could not be retrieved',
446 my ($self, $conn, $session_id, $title_id) = @_;
447 my $handler = new_handler($session_id);
448 return $handler->do_holdings_lookup($title_id);
450 __PACKAGE__->register_method(
451 method => 'get_holdings',
452 api_name => 'open-ils.ebook_api.title.holdings',
456 desc => "Get detailed holdings info (copy counts and formats) for an ebook title, or basic availability if holdings info is unavailable",
459 name => 'session_id',
460 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
465 desc => 'The title ID (ISBN, unique identifier, etc.)',
470 desc => 'Returns a hashref of holdings info with one or more of the following keys: available (0 or 1), copies_owned, copies_available, formats (arrayref of strings)',
476 # Wrapper function for performing transactions that require an authenticated
477 # patron and a title identifier (checkout, checkin, renewal, etc).
480 # - title_id: ISBN (OneClickdigital), title identifier (OverDrive)
481 # - barcode: patron barcode
484 my ($self, $conn, $auth, $session_id, $title_id, $barcode, $param) = @_;
487 if ($self->api_name =~ /checkout/) {
488 $action = 'checkout';
489 } elsif ($self->api_name =~ /checkin/) {
491 } elsif ($self->api_name =~ /renew/) {
493 } elsif ($self->api_name =~ /place_hold/) {
494 $action = 'place_hold';
495 } elsif ($self->api_name =~ /cancel_hold/) {
496 $action = 'cancel_hold';
498 $logger->info("EbookAPI: doing $action for title $title_id...");
500 # verify that user is authenticated in EG
501 my $e = new_editor(authtoken => $auth);
502 if (!$e->checkauth) {
503 $logger->error("EbookAPI: authentication failed: " . $e->die_event);
507 my $handler = new_handler($session_id);
508 my $user_token = $handler->do_patron_auth($barcode);
510 # handler method constructs and submits request (and handles any external authentication)
512 if ($action eq 'checkout') {
513 # checkout has format as optional additional param
514 $res = $handler->checkout($title_id, $user_token, $param);
515 } elsif ($action eq 'place_hold') {
516 # place_hold has email as optional additional param
517 $res = $handler->place_hold($title_id, $user_token, $param);
519 $res = $handler->$action($title_id, $user_token);
521 if (defined ($res)) {
524 $logger->error("EbookAPI: could not do $action for title $title_id and patron $barcode");
528 __PACKAGE__->register_method(
530 api_name => 'open-ils.ebook_api.checkout',
534 desc => "Checkout an ebook title to a patron",
538 desc => 'Authentication token',
542 name => 'session_id',
543 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
548 desc => 'The identifier of the title',
553 desc => 'The barcode of the patron to whom the title will be checked out',
558 desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Checkout limit reached." }',
563 __PACKAGE__->register_method(
565 api_name => 'open-ils.ebook_api.renew',
569 desc => "Renew an ebook title for a patron",
573 desc => 'Authentication token',
577 name => 'session_id',
578 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
583 desc => 'The identifier of the title to be renewed',
588 desc => 'The barcode of the patron to whom the title is checked out',
593 desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Renewal limit reached." }',
598 __PACKAGE__->register_method(
600 api_name => 'open-ils.ebook_api.checkin',
604 desc => "Check in an ebook title for a patron",
608 desc => 'Authentication token',
612 name => 'session_id',
613 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
618 desc => 'The identifier of the title to be checked in',
623 desc => 'The barcode of the patron to whom the title is checked out',
628 desc => 'Success: { } / Failure: { error_msg => "Checkin failed." }',
633 __PACKAGE__->register_method(
635 api_name => 'open-ils.ebook_api.place_hold',
639 desc => "Place a hold on an ebook title for a patron",
643 desc => 'Authentication token',
647 name => 'session_id',
648 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
653 desc => 'The identifier of the title',
658 desc => 'The barcode of the patron for whom the title is being held',
663 desc => 'Success: { queue_position => 1, queue_size => 1, expire_date => "2017-01-01" } / Failure: { error_msg => "Could not place hold." }',
668 __PACKAGE__->register_method(
670 api_name => 'open-ils.ebook_api.cancel_hold',
674 desc => "Cancel a hold on an ebook title for a patron",
678 desc => 'Authentication token',
682 name => 'session_id',
683 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
688 desc => 'The identifier of the title',
693 desc => 'The barcode of the patron',
698 desc => 'Success: { } / Failure: { error_msg => "Could not cancel hold." }',
704 sub _get_patron_xacts {
705 my ($xact_type, $auth, $session_id, $barcode) = @_;
707 $logger->info("EbookAPI: getting $xact_type for patron $barcode");
709 # verify that user is authenticated in EG
710 my $e = new_editor(authtoken => $auth);
711 if (!$e->checkauth) {
712 $logger->error("EbookAPI: authentication failed: " . $e->die_event);
716 my $handler = new_handler($session_id);
717 my $user_token = $handler->do_patron_auth($barcode);
720 if ($xact_type eq 'checkouts') {
721 $xacts = $handler->get_patron_checkouts($user_token);
722 } elsif ($xact_type eq 'holds') {
723 $xacts = $handler->get_patron_holds($user_token);
725 $logger->error("EbookAPI: invalid transaction type '$xact_type'");
729 # cache and return transaction details
730 $handler->{$xact_type} = $xacts;
731 # Overlay transactions onto existing cached handler.
732 if (update_cache($handler, 1)) {
733 return $handler->{$xact_type};
735 $logger->error("EbookAPI: error caching transaction details ($xact_type)");
740 sub get_patron_xacts {
741 my ($self, $conn, $auth, $session_id, $barcode) = @_;
743 if ($self->api_name =~ /checkouts/) {
744 $xact_type = 'checkouts';
745 } elsif ($self->api_name =~ /holds/) {
746 $xact_type = 'holds';
748 return _get_patron_xacts($xact_type, $auth, $session_id, $barcode);
750 __PACKAGE__->register_method(
751 method => 'get_patron_xacts',
752 api_name => 'open-ils.ebook_api.patron.get_checkouts',
756 desc => "Get information about a patron's ebook checkouts",
760 desc => 'Authentication token',
764 name => 'session_id',
765 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
770 desc => 'The barcode of the patron',
775 desc => 'Returns an array of transaction details, or undef if no details available',
780 __PACKAGE__->register_method(
781 method => 'get_patron_xacts',
782 api_name => 'open-ils.ebook_api.patron.get_holds',
786 desc => "Get information about a patron's ebook holds",
790 desc => 'Authentication token',
794 name => 'session_id',
795 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
800 desc => 'The barcode of the patron',
805 desc => 'Returns an array of transaction details, or undef if no details available',
811 sub get_all_patron_xacts {
812 my ($self, $conn, $auth, $session_id, $barcode) = @_;
813 my $checkouts = _get_patron_xacts('checkouts', $auth, $session_id, $barcode);
814 my $holds = _get_patron_xacts('holds', $auth, $session_id, $barcode);
816 checkouts => $checkouts,
820 __PACKAGE__->register_method(
821 method => 'get_all_patron_xacts',
822 api_name => 'open-ils.ebook_api.patron.get_transactions',
826 desc => "Get information about a patron's ebook checkouts and holds",
830 desc => 'Authentication token',
834 name => 'session_id',
835 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
840 desc => 'The barcode of the patron',
845 desc => 'Returns a hashref of transactions: { checkouts => [], holds => [], failed => [] }',
851 sub get_download_link {
852 my ($self, $conn, $auth, $session_id, $request_link) = @_;
853 my $handler = new_handler($session_id);
854 return $handler->do_get_download_link($request_link);
856 __PACKAGE__->register_method(
857 method => 'get_download_link',
858 api_name => 'open-ils.ebook_api.title.get_download_link',
862 desc => "Get download link for an OverDrive title that has been checked out",
866 desc => 'Authentication token',
870 name => 'session_id',
871 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
875 name => 'request_link',
876 desc => 'The URL used to request a download link',
881 desc => 'Success: { url => "http://example.com/download-link" } / Failure: { error_msg => "Download link request failed." }',