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)
383 sub get_availability {
384 my ($self, $conn, $session_id, $title_id) = @_;
385 my $handler = new_handler($session_id);
386 return $handler->do_availability_lookup($title_id);
388 __PACKAGE__->register_method(
389 method => 'get_availability',
390 api_name => 'open-ils.ebook_api.title.availability',
394 desc => "Get availability info 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 => 'Returns 1 if title is available, 0 if not available, or undef if availability info could not be retrieved',
415 my ($self, $conn, $session_id, $title_id) = @_;
416 my $handler = new_handler($session_id);
417 return $handler->do_holdings_lookup($title_id);
419 __PACKAGE__->register_method(
420 method => 'get_holdings',
421 api_name => 'open-ils.ebook_api.title.holdings',
425 desc => "Get detailed holdings info (copy counts and formats) for an ebook title, or basic availability if holdings info is unavailable",
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 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)',
445 # Wrapper function for performing transactions that require an authenticated
446 # patron and a title identifier (checkout, checkin, renewal, etc).
449 # - title_id: ISBN (OneClickdigital), title identifier (OverDrive)
450 # - barcode: patron barcode
453 my ($self, $conn, $auth, $session_id, $title_id, $barcode) = @_;
456 if ($self->api_name =~ /checkout/) {
457 $action = 'checkout';
458 } elsif ($self->api_name =~ /checkin/) {
460 } elsif ($self->api_name =~ /renew/) {
462 } elsif ($self->api_name =~ /place_hold/) {
463 $action = 'place_hold';
464 } elsif ($self->api_name =~ /cancel_hold/) {
465 $action = 'cancel_hold';
467 $logger->info("EbookAPI: doing $action for title $title_id...");
469 # verify that user is authenticated in EG
470 my $e = new_editor(authtoken => $auth);
471 if (!$e->checkauth) {
472 $logger->error("EbookAPI: authentication failed: " . $e->die_event);
476 my $handler = new_handler($session_id);
477 my $user_token = $handler->do_patron_auth($barcode);
479 # handler method constructs and submits request (and handles any external authentication)
480 my $res = $handler->$action($title_id, $user_token);
481 if (defined ($res)) {
484 $logger->error("EbookAPI: could not do $action for title $title_id and patron $barcode");
488 __PACKAGE__->register_method(
490 api_name => 'open-ils.ebook_api.checkout',
494 desc => "Checkout an ebook title to a patron",
498 desc => 'Authentication token',
502 name => 'session_id',
503 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
508 desc => 'The identifier of the title',
513 desc => 'The barcode of the patron to whom the title will be checked out',
518 desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Checkout limit reached." }',
523 __PACKAGE__->register_method(
525 api_name => 'open-ils.ebook_api.renew',
529 desc => "Renew an ebook title for a patron",
533 desc => 'Authentication token',
537 name => 'session_id',
538 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
543 desc => 'The identifier of the title to be renewed',
548 desc => 'The barcode of the patron to whom the title is checked out',
553 desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Renewal limit reached." }',
558 __PACKAGE__->register_method(
560 api_name => 'open-ils.ebook_api.checkin',
564 desc => "Check in an ebook title for a patron",
568 desc => 'Authentication token',
572 name => 'session_id',
573 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
578 desc => 'The identifier of the title to be checked in',
583 desc => 'The barcode of the patron to whom the title is checked out',
588 desc => 'Success: { } / Failure: { error_msg => "Checkin failed." }',
593 __PACKAGE__->register_method(
595 api_name => 'open-ils.ebook_api.place_hold',
599 desc => "Place a hold on an ebook title for a patron",
603 desc => 'Authentication token',
607 name => 'session_id',
608 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
613 desc => 'The identifier of the title',
618 desc => 'The barcode of the patron for whom the title is being held',
623 desc => 'Success: { queue_position => 1, queue_size => 1, expire_date => "2017-01-01" } / Failure: { error_msg => "Could not place hold." }',
628 __PACKAGE__->register_method(
630 api_name => 'open-ils.ebook_api.cancel_hold',
634 desc => "Cancel a hold on an ebook title for a patron",
638 desc => 'Authentication token',
642 name => 'session_id',
643 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
648 desc => 'The identifier of the title',
653 desc => 'The barcode of the patron',
658 desc => 'Success: { } / Failure: { error_msg => "Could not cancel hold." }',
664 sub _get_patron_xacts {
665 my ($xact_type, $auth, $session_id, $barcode) = @_;
667 $logger->info("EbookAPI: getting $xact_type for patron $barcode");
669 # verify that user is authenticated in EG
670 my $e = new_editor(authtoken => $auth);
671 if (!$e->checkauth) {
672 $logger->error("EbookAPI: authentication failed: " . $e->die_event);
676 my $handler = new_handler($session_id);
677 my $user_token = $handler->do_patron_auth($barcode);
680 if ($xact_type eq 'checkouts') {
681 $xacts = $handler->get_patron_checkouts($user_token);
682 } elsif ($xact_type eq 'holds') {
683 $xacts = $handler->get_patron_holds($user_token);
685 $logger->error("EbookAPI: invalid transaction type '$xact_type'");
689 # cache and return transaction details
690 $handler->{$xact_type} = $xacts;
691 # Overlay transactions onto existing cached handler.
692 if (update_cache($handler, 1)) {
693 return $handler->{$xact_type};
695 $logger->error("EbookAPI: error caching transaction details ($xact_type)");
700 sub get_patron_xacts {
701 my ($self, $conn, $auth, $session_id, $barcode) = @_;
703 if ($self->api_name =~ /checkouts/) {
704 $xact_type = 'checkouts';
705 } elsif ($self->api_name =~ /holds/) {
706 $xact_type = 'holds';
708 return _get_patron_xacts($xact_type, $auth, $session_id, $barcode);
710 __PACKAGE__->register_method(
711 method => 'get_patron_xacts',
712 api_name => 'open-ils.ebook_api.patron.get_checkouts',
716 desc => "Get information about a patron's ebook checkouts",
720 desc => 'Authentication token',
724 name => 'session_id',
725 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
730 desc => 'The barcode of the patron',
735 desc => 'Returns an array of transaction details, or undef if no details available',
740 __PACKAGE__->register_method(
741 method => 'get_patron_xacts',
742 api_name => 'open-ils.ebook_api.patron.get_holds',
746 desc => "Get information about a patron's ebook holds",
750 desc => 'Authentication token',
754 name => 'session_id',
755 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
760 desc => 'The barcode of the patron',
765 desc => 'Returns an array of transaction details, or undef if no details available',
771 sub get_all_patron_xacts {
772 my ($self, $conn, $auth, $session_id, $barcode) = @_;
773 my $checkouts = _get_patron_xacts('checkouts', $auth, $session_id, $barcode);
774 my $holds = _get_patron_xacts('holds', $auth, $session_id, $barcode);
776 checkouts => $checkouts,
780 __PACKAGE__->register_method(
781 method => 'get_all_patron_xacts',
782 api_name => 'open-ils.ebook_api.patron.get_transactions',
786 desc => "Get information about a patron's ebook checkouts and 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 a hashref of transactions: { checkouts => [], holds => [], failed => [] }',