]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm
LP#1673857: add open-ils.circ.copy_tags.retrieve[.staff]
[Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / EbookAPI.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 # 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 # ====================================================================== 
24
25 package OpenILS::Application::EbookAPI;
26
27 use strict;
28 use warnings;
29
30 use Time::HiRes qw/gettimeofday/;
31 use Digest::MD5 qw/md5_hex/;
32
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;
43
44 my $handler;
45 my $cache;
46 my $cache_timeout;
47 my $default_request_timeout;
48
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'
54 );
55
56 sub initialize {
57     $cache = OpenSRF::Utils::Cache->new;
58
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;
62 }
63
64 # returns the cached object (if successful)
65 sub update_cache {
66     my $cache_obj = shift;
67     my $overlay = shift || 0;
68     my $cache_key;
69     if ($cache_obj->{session_id}) {
70         $cache_key = $cache_obj->{session_id};
71     } else {
72         $logger->error("EbookAPI: cannot update cache with unknown cache object");
73         return;
74     }
75
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.
79     if ($overlay) {
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});
85             }
86             # The cache object we want to save is the (updated) original one.
87             $cache_obj = $orig_cache;
88         }
89     }
90
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);
96     } else {
97         $logger->error("EbookAPI: error when updating cache with object");
98         return;
99     }
100 }
101
102 sub retrieve_session {
103     my $session_id = shift;
104     unless ($session_id) {
105         $logger->info("EbookAPI: no session ID provided");
106         return;
107     }
108     my $cached_session = $cache->get_cache($session_id) || undef;
109     if ($cached_session) {
110         return $cached_session;
111     } else {
112         $logger->info("EbookAPI: could not find cached session with id $session_id");
113         return;
114     }
115 }
116
117 # prepare new handler from session
118 # (will retrieve cached session unless a session object is provided)
119 sub new_handler {
120     my $session_id = shift;
121     my $ses = shift || retrieve_session($session_id);
122     if (!$ses) {
123         $logger->error("EbookAPI: could not start handler - no cached session with ID $session_id");
124         return;
125     }
126     my $module = ref($ses);
127     $logger->info("EbookAPI: starting new $module handler from cached session $session_id...");
128     $module->use;
129     my $handler = $module->new($ses);
130     return $handler;
131 }
132
133
134 sub check_session {
135     my $self = shift;
136     my $conn = shift;
137     my $session_id = shift;
138     my $vendor = shift;
139     my $ou = shift;
140
141     return start_session($self, $conn, $vendor, $ou) unless $session_id;
142
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)) {
149             return $session_id;
150         } else {
151             $logger->error("EbookAPI: error updating session cache");
152             return;
153         }
154     } else {
155         return start_session($self, $conn, $vendor, $ou);
156     }
157 }
158 __PACKAGE__->register_method(
159     method => 'check_session',
160     api_name => 'open-ils.ebook_api.check_session',
161     api_level => 1,
162     argc => 2,
163     signature => {
164         desc => "Validate an existing EbookAPI session, or initiate a new one",
165         params => [
166             {
167                 name => 'session_id',
168                 desc => 'The EbookAPI session ID being checked',
169                 type => 'string'
170             },
171             {
172                 name => 'vendor',
173                 desc => 'The ebook vendor (e.g. "oneclickdigital")',
174                 type => 'string'
175             },
176             {
177                 name => 'ou',
178                 desc => 'The context org unit ID',
179                 type => 'number'
180             }
181         ],
182         return => {
183             desc => 'Returns an EbookAPI session ID',
184             type => 'string'
185         }
186     }
187 );
188
189 sub _start_session {
190     my $vendor = shift;
191     my $ou = shift;
192     $ou = $ou || 1; # default to top-level org unit
193
194     my $module;
195     
196     # determine EbookAPI handler from vendor name
197     # TODO handle API versions?
198     if ($vendor_handlers{$vendor}) {
199         $module = $vendor_handlers{$vendor};
200     } else {
201         $logger->error("EbookAPI: No handler module found for $vendor!");
202         return;
203     }
204
205     # TODO cache session? reuse an existing one if available?
206
207     # generate session ID
208     my ($sec, $usec) = gettimeofday();
209     my $r = rand();
210     my $session_id = "ebook_api.ses." . md5_hex("$sec-$usec-$r");
211     
212     my $args = {
213         vendor => $vendor,
214         ou => $ou,
215         session_id => $session_id
216     };
217
218     $module->use;
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
222
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);
226
227     return $handler->{session_id};
228 }
229
230 sub start_session {
231     my $self = shift;
232     my $conn = shift;
233     my $vendor = shift;
234     my $ou = shift;
235     return _start_session($vendor, $ou);
236 }
237 __PACKAGE__->register_method(
238     method => 'start_session',
239     api_name => 'open-ils.ebook_api.start_session',
240     api_level => 1,
241     argc => 1,
242     signature => {
243         desc => "Initiate an EbookAPI session",
244         params => [
245             {
246                 name => 'vendor',
247                 desc => 'The ebook vendor (e.g. "oneclickdigital")',
248                 type => 'string'
249             },
250             {
251                 name => 'ou',
252                 desc => 'The context org unit ID',
253                 type => 'number'
254             }
255         ],
256         return => {
257             desc => 'Returns an EbookAPI session ID',
258             type => 'string'
259         }
260     }
261 );
262
263 sub cache_patron_password {
264     my $self = shift;
265     my $conn = shift;
266     my $session_id = shift;
267     my $password = shift;
268
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)) {
274             return $session_id;
275         } else {
276             $logger->error("EbookAPI: there was an error caching patron password");
277             return;
278         }
279     }
280 }
281 __PACKAGE__->register_method(
282     method => 'cache_patron_password',
283     api_name => 'open-ils.ebook_api.patron.cache_password',
284     api_level => 1,
285     argc => 2,
286     signature => {
287         desc => "Cache patron password on login for use during EbookAPI patron authentication",
288         params => [
289             {
290                 name => 'session_id',
291                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
292                 type => 'string'
293             },
294             {
295                 name => 'patron_password',
296                 desc => 'The patron password',
297                 type => 'string'
298             }
299         ],
300         return => { desc => 'A session key, or undef' }
301     }
302 );
303
304 # Submit an HTTP request to a specified API endpoint.
305 #
306 # Params:
307 #
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
315 #
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:
319 #
320 #   Authorization: basic $basic_token
321 #
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}.
325 sub request {
326     my $self = shift;
327     my $req = shift;
328     my $session_id = shift;
329
330     my $uri;
331     if (!defined ($req->{uri})) {
332         $logger->error('EbookAPI: attempted an HTTP request but no URI was provided');
333         return;
334     } else {
335         $uri = $req->{uri};
336     }
337     
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;
342
343     # JSON as default content type
344     if ( !defined ($headers->{'Content-Type'}) ) {
345         $headers->{'Content-Type'} = 'application/json';
346     }
347
348     # all requests also require an Authorization header;
349     # let's default to using our basic token, if available
350     if ( !defined ($headers->{'Authorization'}) ) {
351         if (!$session_id) {
352             $logger->error("EbookAPI: HTTP request requires session info but no session ID was provided");
353             return;
354         }
355         my $ses = retrieve_session($session_id);
356         if ($ses) {
357             my $basic_token = $ses->{basic_token};
358             $headers->{'Authorization'} = "basic $basic_token";
359         }
360     }
361
362     my $client = OpenILS::Utils::HTTPClient->new();
363     my $res = $client->request(
364         $method,
365         $uri,
366         $headers,
367         $content,
368         $request_timeout
369     );
370     if (!defined ($res)) {
371         $logger->error('EbookAPI: no HTTP response received');
372         return;
373     } else {
374         $logger->info("EbookAPI: response received from server: " . $res->status_line);
375         return {
376             is_success => $res->is_success,
377             status     => $res->status_line,
378             content    => OpenSRF::Utils::JSON->JSON2perl($res->decoded_content)
379         };
380     }
381 }
382
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);
387 }
388 __PACKAGE__->register_method(
389     method => 'get_availability',
390     api_name => 'open-ils.ebook_api.title.availability',
391     api_level => 1,
392     argc => 2,
393     signature => {
394         desc => "Get availability info for an ebook title",
395         params => [
396             {
397                 name => 'session_id',
398                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
399                 type => 'string'
400             },
401             {
402                 name => 'title_id',
403                 desc => 'The title ID (ISBN, unique identifier, etc.)',
404                 type => 'string'
405             }
406         ],
407         return => {
408             desc => 'Returns 1 if title is available, 0 if not available, or undef if availability info could not be retrieved',
409             type => 'number'
410         }
411     }
412 );
413
414 sub get_holdings {
415     my ($self, $conn, $session_id, $title_id) = @_;
416     my $handler = new_handler($session_id);
417     return $handler->do_holdings_lookup($title_id);
418 }
419 __PACKAGE__->register_method(
420     method => 'get_holdings',
421     api_name => 'open-ils.ebook_api.title.holdings',
422     api_level => 1,
423     argc => 2,
424     signature => {
425         desc => "Get detailed holdings info (copy counts and formats) for an ebook title, or basic availability if holdings info is unavailable",
426         params => [
427             {
428                 name => 'session_id',
429                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
430                 type => 'string'
431             },
432             {
433                 name => 'title_id',
434                 desc => 'The title ID (ISBN, unique identifier, etc.)',
435                 type => 'string'
436             }
437         ],
438         return => {
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)',
440             type => 'hashref'
441         }
442     }
443 );
444
445 # Wrapper function for performing transactions that require an authenticated
446 # patron and a title identifier (checkout, checkin, renewal, etc).
447 #
448 # Params:
449 # - title_id: ISBN (OneClickdigital), title identifier (OverDrive)
450 # - barcode: patron barcode
451 #
452 sub do_xact {
453     my ($self, $conn, $auth, $session_id, $title_id, $barcode) = @_;
454
455     my $action;
456     if ($self->api_name =~ /checkout/) {
457         $action = 'checkout';
458     } elsif ($self->api_name =~ /checkin/) {
459         $action = 'checkin';
460     } elsif ($self->api_name =~ /renew/) {
461         $action = 'renew';
462     } elsif ($self->api_name =~ /place_hold/) {
463         $action = 'place_hold';
464     } elsif ($self->api_name =~ /cancel_hold/) {
465         $action = 'cancel_hold';
466     }
467     $logger->info("EbookAPI: doing $action for title $title_id...");
468
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);
473         return;
474     }
475
476     my $handler = new_handler($session_id);
477     my $user_token = $handler->do_patron_auth($barcode);
478
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)) {
482         return $res;
483     } else {
484         $logger->error("EbookAPI: could not do $action for title $title_id and patron $barcode");
485         return;
486     }
487 }
488 __PACKAGE__->register_method(
489     method => 'do_xact',
490     api_name => 'open-ils.ebook_api.checkout',
491     api_level => 1,
492     argc => 4,
493     signature => {
494         desc => "Checkout an ebook title to a patron",
495         params => [
496             {
497                 name => 'authtoken',
498                 desc => 'Authentication token',
499                 type => 'string'
500             },
501             {
502                 name => 'session_id',
503                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
504                 type => 'string'
505             },
506             {
507                 name => 'title_id',
508                 desc => 'The identifier of the title',
509                 type => 'string'
510             },
511             {
512                 name => 'barcode',
513                 desc => 'The barcode of the patron to whom the title will be checked out',
514                 type => 'string'
515             },
516         ],
517         return => {
518             desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Checkout limit reached." }',
519             type => 'hashref'
520         }
521     }
522 );
523 __PACKAGE__->register_method(
524     method => 'do_xact',
525     api_name => 'open-ils.ebook_api.renew',
526     api_level => 1,
527     argc => 4,
528     signature => {
529         desc => "Renew an ebook title for a patron",
530         params => [
531             {
532                 name => 'authtoken',
533                 desc => 'Authentication token',
534                 type => 'string'
535             },
536             {
537                 name => 'session_id',
538                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
539                 type => 'string'
540             },
541             {
542                 name => 'title_id',
543                 desc => 'The identifier of the title to be renewed',
544                 type => 'string'
545             },
546             {
547                 name => 'barcode',
548                 desc => 'The barcode of the patron to whom the title is checked out',
549                 type => 'string'
550             },
551         ],
552         return => {
553             desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Renewal limit reached." }',
554             type => 'hashref'
555         }
556     }
557 );
558 __PACKAGE__->register_method(
559     method => 'do_xact',
560     api_name => 'open-ils.ebook_api.checkin',
561     api_level => 1,
562     argc => 4,
563     signature => {
564         desc => "Check in an ebook title for a patron",
565         params => [
566             {
567                 name => 'authtoken',
568                 desc => 'Authentication token',
569                 type => 'string'
570             },
571             {
572                 name => 'session_id',
573                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
574                 type => 'string'
575             },
576             {
577                 name => 'title_id',
578                 desc => 'The identifier of the title to be checked in',
579                 type => 'string'
580             },
581             {
582                 name => 'barcode',
583                 desc => 'The barcode of the patron to whom the title is checked out',
584                 type => 'string'
585             },
586         ],
587         return => {
588             desc => 'Success: { } / Failure: { error_msg => "Checkin failed." }',
589             type => 'hashref'
590         }
591     }
592 );
593 __PACKAGE__->register_method(
594     method => 'do_xact',
595     api_name => 'open-ils.ebook_api.place_hold',
596     api_level => 1,
597     argc => 4,
598     signature => {
599         desc => "Place a hold on an ebook title for a patron",
600         params => [
601             {
602                 name => 'authtoken',
603                 desc => 'Authentication token',
604                 type => 'string'
605             },
606             {
607                 name => 'session_id',
608                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
609                 type => 'string'
610             },
611             {
612                 name => 'title_id',
613                 desc => 'The identifier of the title',
614                 type => 'string'
615             },
616             {
617                 name => 'barcode',
618                 desc => 'The barcode of the patron for whom the title is being held',
619                 type => 'string'
620             },
621         ],
622         return => {
623             desc => 'Success: { queue_position => 1, queue_size => 1, expire_date => "2017-01-01" } / Failure: { error_msg => "Could not place hold." }',
624             type => 'hashref'
625         }
626     }
627 );
628 __PACKAGE__->register_method(
629     method => 'do_xact',
630     api_name => 'open-ils.ebook_api.cancel_hold',
631     api_level => 1,
632     argc => 4,
633     signature => {
634         desc => "Cancel a hold on an ebook title for a patron",
635         params => [
636             {
637                 name => 'authtoken',
638                 desc => 'Authentication token',
639                 type => 'string'
640             },
641             {
642                 name => 'session_id',
643                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
644                 type => 'string'
645             },
646             {
647                 name => 'title_id',
648                 desc => 'The identifier of the title',
649                 type => 'string'
650             },
651             {
652                 name => 'barcode',
653                 desc => 'The barcode of the patron',
654                 type => 'string'
655             },
656         ],
657         return => {
658             desc => 'Success: { } / Failure: { error_msg => "Could not cancel hold." }',
659             type => 'hashref'
660         }
661     }
662 );
663
664 sub _get_patron_xacts {
665     my ($xact_type, $auth, $session_id, $barcode) = @_;
666
667     $logger->info("EbookAPI: getting $xact_type for patron $barcode");
668
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);
673         return;
674     }
675
676     my $handler = new_handler($session_id);
677     my $user_token = $handler->do_patron_auth($barcode);
678
679     my $xacts;
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);
684     } else {
685         $logger->error("EbookAPI: invalid transaction type '$xact_type'");
686         return;
687     }
688
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};
694     } else {
695         $logger->error("EbookAPI: error caching transaction details ($xact_type)");
696         return;
697     }
698 }
699
700 sub get_patron_xacts {
701     my ($self, $conn, $auth, $session_id, $barcode) = @_;
702     my $xact_type;
703     if ($self->api_name =~ /checkouts/) {
704         $xact_type = 'checkouts';
705     } elsif ($self->api_name =~ /holds/) {
706         $xact_type = 'holds';
707     }
708     return _get_patron_xacts($xact_type, $auth, $session_id, $barcode);
709 }
710 __PACKAGE__->register_method(
711     method => 'get_patron_xacts',
712     api_name => 'open-ils.ebook_api.patron.get_checkouts',
713     api_level => 1,
714     argc => 3,
715     signature => {
716         desc => "Get information about a patron's ebook checkouts",
717         params => [
718             {
719                 name => 'authtoken',
720                 desc => 'Authentication token',
721                 type => 'string'
722             },
723             {
724                 name => 'session_id',
725                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
726                 type => 'string'
727             },
728             {
729                 name => 'barcode',
730                 desc => 'The barcode of the patron',
731                 type => 'string'
732             }
733         ],
734         return => {
735             desc => 'Returns an array of transaction details, or undef if no details available',
736             type => 'array'
737         }
738     }
739 );
740 __PACKAGE__->register_method(
741     method => 'get_patron_xacts',
742     api_name => 'open-ils.ebook_api.patron.get_holds',
743     api_level => 1,
744     argc => 3,
745     signature => {
746         desc => "Get information about a patron's ebook holds",
747         params => [
748             {
749                 name => 'authtoken',
750                 desc => 'Authentication token',
751                 type => 'string'
752             },
753             {
754                 name => 'session_id',
755                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
756                 type => 'string'
757             },
758             {
759                 name => 'barcode',
760                 desc => 'The barcode of the patron',
761                 type => 'string'
762             }
763         ],
764         return => {
765             desc => 'Returns an array of transaction details, or undef if no details available',
766             type => 'array'
767         }
768     }
769 );
770
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);
775     return {
776         checkouts => $checkouts,
777         holds     => $holds
778     };
779 }
780 __PACKAGE__->register_method(
781     method => 'get_all_patron_xacts',
782     api_name => 'open-ils.ebook_api.patron.get_transactions',
783     api_level => 1,
784     argc => 3,
785     signature => {
786         desc => "Get information about a patron's ebook checkouts and holds",
787         params => [
788             {
789                 name => 'authtoken',
790                 desc => 'Authentication token',
791                 type => 'string'
792             },
793             {
794                 name => 'session_id',
795                 desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
796                 type => 'string'
797             },
798             {
799                 name => 'barcode',
800                 desc => 'The barcode of the patron',
801                 type => 'string'
802             }
803         ],
804         return => {
805             desc => 'Returns a hashref of transactions: { checkouts => [], holds => [], failed => [] }',
806             type => 'hashref'
807         }
808     }
809 );
810
811 1;