]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm
LP#1673870: Handle OverDrive ebook checkout and download
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / EbookAPI / Test.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 # ====================================================================== 
20 # OpenSRF requests are handled by the main OpenILS::Application::EbookAPI module,
21 # which determines which "handler" submodule to use based on the params of the
22 # OpenSRF request.  Each vendor API (OneClickdigital, OverDrive, etc.) has its
23 # own separate handler class, since they all work a little differently.
24 #
25 # An instance of the handler class represents an EbookAPI session -- that is, we
26 # instantiate a new handler object when we start a new session with the external API.
27 # Thus everything we need to talk to the API, like client keys or auth tokens, is
28 # an attribute of the handler object.
29 #
30 # API endpoints are defined in the handler class.  The handler constructs HTTP
31 # requests, then passes them to the the request() method of the parent class
32 # (OpenILS::Application::EbookAPI), which sets some default headers and manages
33 # the actual mechanics of sending the request and receiving the response.  It's
34 # up to the handler class to do something with the response.
35 #
36 # At a minimum, each handler must have the following methods, since the parent
37 # class presumes they exist; it may be a no-op if the API doesn't support that
38 # bit of functionality:
39 #
40 #   - initialize: assign values for basic attributes (e.g. library_id,
41 #     basic_token) based on library settings
42 #   - do_client_auth: authenticate client with external API (e.g. get client
43 #     token if needed)
44 #   - do_patron_auth: get a patron-specific bearer token, or just the patron ID
45 #   - get_title_info: get basic title details (title, author, optional cover image)
46 #   - do_holdings_lookup: how many total/available "copies" are there for this
47 #     title? (n/a for OneClickdigital)
48 #   - do_availability_lookup: does this title have available "copies"? y/n
49 #   - checkout
50 #   - renew
51 #   - checkin
52 #   - place_hold
53 #   - suspend_hold (n/a for OneClickdigital)
54 #   - cancel_hold
55 #   - get_patron_checkouts: returns an array of hashrefs representing checkouts;
56 #     each checkout hashref has the following keys:
57 #       - xact_id
58 #       - title_id
59 #       - due_date
60 #       - download_url
61 #       - title
62 #       - author
63 #   - get_patron_holds
64 # ====================================================================== 
65
66 package OpenILS::Application::EbookAPI::Test;
67
68 use strict;
69 use warnings;
70
71 use OpenILS::Application;
72 use OpenILS::Application::EbookAPI;
73 use base qw/OpenILS::Application::EbookAPI/;
74 use OpenSRF::AppSession;
75 use OpenSRF::EX qw(:try);
76 use OpenSRF::Utils::SettingsClient;
77 use OpenSRF::Utils::Logger qw($logger);
78 use OpenSRF::Utils::Cache;
79 use OpenILS::Application::AppUtils;
80 use DateTime;
81 use DateTime::Format::ISO8601;
82
83 my $U = 'OpenILS::Application::AppUtils';
84
85 # create new handler object
86 sub new {
87     my( $class, $args ) = @_;
88
89     # A new handler object represents a new API session, so we instantiate it
90     # by passing it a hashref containing the following basic attributes
91     # available to us when we start the session:
92     #   - vendor: a string indicating the vendor whose API we're talking to
93     #   - ou: org unit ID for current session
94     #   - session_id: unique ID for the session represented by this object
95
96     $class = ref $class || $class;
97     return bless $args, $class;
98 }
99
100 # set API-specific handler attributes based on library settings
101 sub initialize {
102     my $self = shift;
103
104     # At a minimum, you are likely to need some kind of basic API key or token
105     # to allow the client (Evergreen) to use the API.
106     # Other attributes will vary depending on the API.  Consult your API
107     # documentation for details.
108
109     return $self;
110 }
111
112 # authorize client session against API
113 sub do_client_auth {
114     my $self = shift;
115
116     # Some APIs require client authorization, and may return an auth token
117     # which must be included in subsequent requests.  This is where you do
118     # that.  If you get an auth token, you'll want to add it as an attribute to
119     # the handler object so that it's available to use in subsequent requests.
120     # If your API doesn't require this step, you don't need to return anything
121     # here.
122
123     return;
124 }
125
126 # authenticate patron against API
127 sub do_patron_auth {
128     my $self = shift;
129
130     # We authenticate the patron using the barcode of their active card.
131     # We may capture this on OPAC login (along with password, if required),
132     # in which case it should already be an attribute of the handler object;
133     # otherwise, it should be passed to this method as a parameter.
134     my $barcode = shift;
135     if ($barcode) {
136         if (!$self->{patron_barcode}) {
137             $self->{patron_barcode} = $barcode;
138         } elsif ($barcode ne $self->{patron_barcode}) {
139             $logger->error("EbookAPI: patron barcode in auth request does not match patron barcode for this session");
140             return;
141         }
142     } else {
143         if (!$self->{patron_barcode}) {
144             $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
145         } else {
146             $barcode = $self->{patron_barcode};
147         }
148     }
149
150     # We really don't want to be handling the patron's unencrypted password.
151     # But if we need to, it should be added to our handler object on login
152     # via the open-ils.ebook_api.patron.cache_password OpenSRF API call
153     # before we attempt to authenticate the patron against the external API.
154     my $password;
155     if ($self->{patron_password}) {
156         $password = $self->{patron_password};
157     }
158
159     # return external patron ID or patron auth token
160
161     # For testing, only barcode 99999359616 is valid.
162     return 'USER001' if ($barcode eq '99999359616');
163
164     # All other values return undef.
165     return undef;
166 }
167
168 # get basic info (title, author, eventually a thumbnail URL) for a title
169 sub get_title_info {
170     my $self = shift;
171
172     # External ID for title.  Depending on the API, this could be an ISBN
173     # or an identifier unique to that vendor.
174     my $title_id = shift;
175
176     # Prepare data structure to be used as return value.
177     my $title_info = {
178         title  => '',
179         author => ''
180     };
181
182     # If title lookup fails or title is not found, our return value
183     # is somewhat different.
184     my $title_not_found = {
185         error => 'Title not found.'
186     };
187
188     # For testing purposes, we have only three valid titles (001, 002, 003).
189     # All other title IDs return an error message.
190     if ($title_id eq '001') {
191         $title_info->{title} = 'The Fellowship of the Ring';
192         $title_info->{author} = 'J.R.R. Tolkien';
193     } elsif ($title_id eq '002') {
194         $title_info->{title} = 'The Two Towers';
195         $title_info->{author} = 'J.R.R. Tolkien';
196     } elsif ($title_id eq '003') {
197         $title_info->{title} = 'The Return of the King';
198         $title_info->{author} = 'J.R.R. Tolkien';
199     } else {
200         return $title_not_found;
201     }
202     return $title_info;
203 }
204
205 # get detailed holdings information (copy counts and formats), OR basic
206 # availability if detailed info is not provided by the API
207 sub do_holdings_lookup {
208     my $self = shift;
209
210     # External ID for title.  Depending on the API, this could be an ISBN
211     # or an identifier unique to that vendor.
212     my $title_id = shift;
213
214     # Prepare data structure to be used as return value.
215     # NOTE: If the external API does not provide detailed holdings info,
216     # return simple availability information: { available => 1 }
217     my $holdings = {
218         copies_owned => 0,
219         copies_available => 0,
220         formats => []
221     };
222
223     # 001 and 002 are unavailable.
224     if ($title_id eq '001' || $title_id eq '002') {
225         $holdings->{copies_owned} = 1;
226         $holdings->{copies_available} = 0;
227         push @{$holdings->{formats}}, 'ebook';
228     }
229
230     # 003 is available.
231     if ($title_id eq '003') {
232         $holdings->{copies_owned} = 1;
233         $holdings->{copies_available} = 1;
234         push @{$holdings->{formats}}, 'ebook';
235     }
236
237     # All other title IDs are unknown.
238
239     return $holdings;
240 }
241
242 # look up whether a title is currently available for checkout; returns a boolean value
243 sub do_availability_lookup {
244     my $self = shift;
245
246     # External ID for title.  Depending on the API, this could be an ISBN
247     # or an identifier unique to that vendor.
248     my $title_id = shift;
249
250     # At this point, you would lookup title availability via an API request.
251     # In our case, since this is a test module, we just return availability info
252     # based on hard-coded values:
253
254     # 001 and 002 are unavailable.
255     return 0 if ($title_id eq '001');
256     return 0 if ($title_id eq '002');
257
258     # 003 is available.
259     return 1 if ($title_id eq '003');
260
261     # All other title IDs are unknown.
262     return undef;
263 }
264
265 # check out a title to a patron
266 sub checkout {
267     my $self = shift;
268
269     # External ID of title to be checked out.
270     my $title_id = shift;
271
272     # Patron ID or patron auth token, as returned by do_patron_auth().
273     my $user_token = shift;
274
275     # Ebook format to be checked out (optional, not used here).
276     my $format = shift;
277
278     # If checkout succeeds, the response is a hashref with the following fields:
279     # - due_date
280     # - xact_id (optional)
281     #
282     # If checkout fails, the response is a hashref with the following fields:
283     # - error_msg: a string containing an error message or description of why
284     #   the checkout failed (e.g. "Checkout limit of (4) reached").
285     #
286     # If no valid response is received from the API, return undef.
287
288     # For testing purposes, user ID USER001 is our only valid user, 
289     # and title 003 is the only available title.
290     if ($title_id && $user_token) {
291         if ($user_token eq 'USER001' && $title_id eq '003') {
292             return { due_date => DateTime->today()->add( days => 14 )->iso8601() };
293         } else {
294             return { msg => 'Checkout failed.' };
295         }
296     } else {
297         return undef;
298     }
299
300 }
301
302 sub renew {
303     my $self = shift;
304
305     # External ID of title to be renewed.
306     my $title_id = shift;
307
308     # Patron ID or patron auth token, as returned by do_patron_auth().
309     my $user_token = shift;
310
311     # If renewal succeeds, the response is a hashref with the following fields:
312     # - due_date
313     # - xact_id (optional)
314     #
315     # If renewal fails, the response is a hashref with the following fields:
316     # - error_msg: a string containing an error message or description of why
317     #   the renewal failed (e.g. "Renewal limit reached").
318     #
319     # If no valid response is received from the API, return undef.
320
321     # For testing purposes, user ID USER001 is our only valid user, 
322     # and title 001 is the only renewable title.
323     if ($title_id && $user_token) {
324         if ($user_token eq 'USER001' && $title_id eq '001') {
325             return { due_date => DateTime->today()->add( days => 14 )->iso8601() };
326         } else {
327             return { error_msg => 'Renewal failed.' };
328         }
329     } else {
330         return undef;
331     }
332 }
333
334 sub checkin {
335     my $self = shift;
336
337     # External ID of title to be checked in.
338     my $title_id = shift;
339
340     # Patron ID or patron auth token, as returned by do_patron_auth().
341     my $user_token = shift;
342
343     # If checkin succeeds, return an empty hashref (actually it doesn't
344     # need to be empty, it just must NOT contain "error_msg" as a key).
345     #
346     # If checkin fails, return a hashref with the following fields:
347     # - error_msg: a string containing an error message or description of why
348     #   the checkin failed (e.g. "Checkin failed").
349     #
350     # If no valid response is received from the API, return undef.
351
352     # For testing purposes, user ID USER001 is our only valid user, 
353     # and title 003 is the only title that can be checked in.
354     if ($title_id && $user_token) {
355         if ($user_token eq 'USER001' && $title_id eq '003') {
356             return {};
357         } else {
358             return { error_msg => 'Checkin failed' };
359         }
360     } else {
361         return undef;
362     }
363 }
364
365 sub place_hold {
366     my $self = shift;
367
368     # External ID of title to be held.
369     my $title_id = shift;
370
371     # Patron ID or patron auth token, as returned by do_patron_auth().
372     my $user_token = shift;
373
374     # Email address of patron (optional, not used here).
375     my $email = shift;
376
377     # If hold is successfully placed, return a hashref with the following
378     # fields:
379     # - queue_position: this user's position in hold queue for this title
380     # - queue_size: total number of holds on this title
381     # - expire_date: when the hold expires
382     #
383     # If hold fails, return a hashref with the following fields:
384     # - error_msg: a string containing an error message or description of why
385     #   the hold failed (e.g. "Hold limit (4) reached").
386     #
387     # If no valid response is received from the API, return undef.
388
389     # For testing purposes, we always and only allow placing a hold on title
390     # 002 by user ID USER001.
391     if ($title_id && $user_token) {
392         if ($user_token eq 'USER001' && $title_id eq '002') {
393             return {
394                 queue_position => 1,
395                 queue_size => 1,
396                 expire_date => DateTime->today()->add( days => 70 )->iso8601()
397             };
398         } else {
399             return { error_msg => 'Unable to place hold' };
400         }
401     } else {
402         return undef;
403     }
404 }
405
406 sub cancel_hold {
407     my $self = shift;
408
409     # External ID of title.
410     my $title_id = shift;
411
412     # Patron ID or patron auth token, as returned by do_patron_auth().
413     my $user_token = shift;
414
415     # If hold is successfully canceled, return an empty hashref (actually it
416     # doesn't need to be empty, it just must NOT contain "error_msg" as a key).
417     #
418     # If hold is NOT canceled, return a hashref with the following fields:
419     # - error_msg: a string containing an error message or description of why
420     #   the hold was not canceled (e.g. "Hold could not be canceled"). 
421     #
422     # If no valid response is received from the API, return undef.
423
424     # For testing purposes, we always and only allow canceling a hold on title
425     # 002 by user ID USER001.
426     if ($title_id && $user_token) {
427         if ($user_token eq 'USER001' && $title_id eq '002') {
428             return {};
429         } else {
430             return { error_msg => 'Unable to cancel hold' };
431         }
432     } else {
433         return undef;
434     }
435 }
436
437 sub suspend_hold {
438 }
439
440 sub get_patron_checkouts {
441     my $self = shift;
442
443     # Patron ID or patron auth token.
444     my $user_token = shift;
445
446     # Return an array of hashrefs representing checkouts;
447     # each hashref should have the following keys:
448     #   - xact_id: unique ID for this transaction (if used by API)
449     #   - title_id: unique ID for this title
450     #   - due_date
451     #   - download_url
452     #   - title: title of item, formatted for display
453     #   - author: author of item, formatted for display
454
455     my $checkouts = [];
456     # USER001 is our only valid user, so we only return checkouts for them.
457     if ($user_token eq 'USER001') {
458         push @$checkouts, {
459             xact_id => '1',
460             title_id => '001',
461             due_date => DateTime->today()->add( days => 7 )->iso8601(),
462             download_url => 'http://example.com/ebookapi/t/001/download',
463             title => 'The Fellowship of the Ring',
464             author => 'J. R. R. Tolkien'
465         };
466     }
467     $self->{checkouts} = $checkouts;
468     return $self->{checkouts};
469 }
470
471 sub get_patron_holds {
472     my $self = shift;
473
474     # Patron ID or patron auth token.
475     my $user_token = shift;
476
477     # Return an array of hashrefs representing holds;
478     # each hashref should have the following keys:
479     #   - title_id: unique ID for this title
480     #   - queue_position: this user's position in hold queue for this title
481     #   - queue_size: total number of holds on this title
482     #   - is_ready: whether hold is currently available for checkout
483     #   - is_frozen: whether hold is suspended
484     #   - thaw_date: when hold suspension expires (if suspended)
485     #   - create_date: when the hold was placed
486     #   - expire_date: when the hold expires
487     #   - title: title of item, formatted for display
488     #   - author: author of item, formatted for display
489
490     my $holds = [];
491     # USER001 is our only valid user, so we only return checkouts for them.
492     if ($user_token eq 'USER001') {
493         push @$holds, {
494             title_id => '002',
495             queue_position => 1,
496             queue_size => 1,
497             is_ready => 0,
498             is_frozen => 0,
499             create_date => DateTime->today()->subtract( days => 10 )->iso8601(),
500             expire_date => DateTime->today()->add( days => 60 )->iso8601(),
501             title => 'The Two Towers',
502             author => 'J. R. R. Tolkien'
503         };
504     }
505     $self->{holds} = $holds;
506     return $self->{holds};
507 }
508
509 sub do_get_download_link {
510     my $self = shift;
511     my $request_link = shift;
512
513     # For some vendors (e.g. OverDrive), the workflow is as follows:
514     #
515     # 1. Perform a checkout.
516     # 2. Checkout response contains a URL which we use to request a
517     #    format-specific download link for the checked-out title.
518     # 3. Submit a request to the request link.
519     # 4. Response contains a (temporary/dynamic) URL which the user
520     #    clicks on to download the ebook in the desired format.
521     #    
522     # For other vendors, the download link for a title is static and not
523     # format-dependent.  In that case, we just return the original request link
524     # (but ideally the UI will skip the download link request altogether, since
525     # it's superfluous in that case).
526
527     return $request_link;
528 }