</app_settings>
</open-ils.hold-targeter>
-
+ <open-ils.ebook_api>
+ <keepalive>5</keepalive>
+ <stateless>1</stateless>
+ <language>perl</language>
+ <implementation>OpenILS::Application::EbookAPI</implementation>
+ <max_requests>100</max_requests>
+ <unix_config>
+ <unix_sock>ebook_api_unix.sock</unix_sock>
+ <unix_pid>ebook_api_unix.pid</unix_pid>
+ <unix_log>ebook_api_unix.log</unix_log>
+ <max_requests>100</max_requests>
+ <min_children>1</min_children>
+ <max_children>15</max_children>
+ <min_spare_children>1</min_spare_children>
+ <max_spare_children>5</max_spare_children>
+ </unix_config>
+ <app_settings>
+ <cache_timeout>300</cache_timeout>
+ <request_timeout>60</request_timeout>
+ </app_settings>
+ </open-ils.ebook_api>
</apps>
</default>
<appname>open-ils.vandelay</appname>
<appname>open-ils.serial</appname>
<appname>open-ils.hold-targeter</appname>
+ <appname>open-ils.ebook_api</appname>
</activeapps>
</localhost>
</hosts>
<service>open-ils.url_verify</service>
<service>open-ils.vandelay</service>
<service>open-ils.serial</service>
+ <service>open-ils.ebook_api</service>
</services>
</router>
lib/OpenILS/Application/Circ/Survey.pm
lib/OpenILS/Application/Circ/Transit.pm
lib/OpenILS/Application/Collections.pm
+lib/OpenILS/Application/EbookAPI.pm
+lib/OpenILS/Application/EbookAPI/Test.pm
lib/OpenILS/Application/Fielder.pm
lib/OpenILS/Application/PermaCrud.pm
lib/OpenILS/Application/Proxy.pm
--- /dev/null
+#!/usr/bin/perl
+
+# Copyright (C) 2015 BC Libraries Cooperative
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+# ======================================================================
+# We define a handler class for each vendor API (OneClickdigital, OverDrive, etc.).
+# See EbookAPI/Test.pm for a reference implementation with required methods,
+# arguments, and return values.
+# ======================================================================
+
+package OpenILS::Application::EbookAPI;
+
+use strict;
+use warnings;
+
+use Time::HiRes qw/gettimeofday/;
+use Digest::MD5 qw/md5_hex/;
+
+use OpenILS::Application;
+use base qw/OpenILS::Application/;
+use OpenSRF::AppSession;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::JSON;
+use OpenILS::Utils::HTTPClient;
+
+my $handler;
+my $cache;
+my $cache_timeout;
+my $default_request_timeout;
+
+# map EbookAPI vendor codes to corresponding packages
+our %vendor_handlers = (
+ 'ebook_test' => 'OpenILS::Application::EbookAPI::Test',
+ 'oneclickdigital' => 'OpenILS::Application::EbookAPI::OneClickdigital',
+ 'overdrive' => 'OpenILS::Application::EbookAPI::OverDrive'
+);
+
+sub initialize {
+ $cache = OpenSRF::Utils::Cache->new;
+
+ my $sclient = OpenSRF::Utils::SettingsClient->new();
+ $cache_timeout = $sclient->config_value("apps", "open-ils.ebook_api", "app_settings", "cache_timeout" ) || 300;
+ $default_request_timeout = $sclient->config_value("apps", "open-ils.ebook_api", "app_settings", "request_timeout" ) || 60;
+}
+
+# returns the cached object (if successful)
+sub update_cache {
+ my $cache_obj = shift;
+ my $overlay = shift || 0;
+ my $cache_key;
+ if ($cache_obj->{session_id}) {
+ $cache_key = $cache_obj->{session_id};
+ } else {
+ $logger->error("EbookAPI: cannot update cache with unknown cache object");
+ return;
+ }
+
+ # Optionally, keep old cached field values unless a new value for that
+ # field is explicitly provided. This makes it easier for asynchronous
+ # requests (e.g. for circs and holds) to cache their results.
+ if ($overlay) {
+ if (my $orig_cache = $cache->get_cache($cache_key)) {
+ $logger->info("EbookAPI: overlaying new values on existing cache object");
+ foreach my $k (%$cache_obj) {
+ # Add/overwrite existing cached value if a new value is defined.
+ $orig_cache->{$k} = $cache_obj->{$k} if (defined $cache_obj->{$k});
+ }
+ # The cache object we want to save is the (updated) original one.
+ $cache_obj = $orig_cache;
+ }
+ }
+
+ try { # fail silently if there's no pre-existing cache to delete
+ $cache->delete_cache($cache_key);
+ } catch Error with {};
+ if (my $success_key = $cache->put_cache($cache_key, $cache_obj, $cache_timeout)) {
+ return $cache->get_cache($success_key);
+ } else {
+ $logger->error("EbookAPI: error when updating cache with object");
+ return;
+ }
+}
+
+sub retrieve_session {
+ my $session_id = shift;
+ unless ($session_id) {
+ $logger->info("EbookAPI: no session ID provided");
+ return;
+ }
+ my $cached_session = $cache->get_cache($session_id) || undef;
+ if ($cached_session) {
+ return $cached_session;
+ } else {
+ $logger->info("EbookAPI: could not find cached session with id $session_id");
+ return;
+ }
+}
+
+# prepare new handler from session
+# (will retrieve cached session unless a session object is provided)
+sub new_handler {
+ my $session_id = shift;
+ my $ses = shift || retrieve_session($session_id);
+ if (!$ses) {
+ $logger->error("EbookAPI: could not start handler - no cached session with ID $session_id");
+ return;
+ }
+ my $module = ref($ses);
+ $logger->info("EbookAPI: starting new $module handler from cached session $session_id...");
+ $module->use;
+ my $handler = $module->new($ses);
+ return $handler;
+}
+
+
+sub check_session {
+ my $self = shift;
+ my $conn = shift;
+ my $session_id = shift;
+ my $vendor = shift;
+ my $ou = shift;
+
+ return start_session($self, $conn, $vendor, $ou) unless $session_id;
+
+ my $cached_session = retrieve_session($session_id);
+ if ($cached_session) {
+ # re-authorize cached session, if applicable
+ my $handler = new_handler($session_id, $cached_session);
+ $handler->do_client_auth();
+ if (update_cache($handler)) {
+ return $session_id;
+ } else {
+ $logger->error("EbookAPI: error updating session cache");
+ return;
+ }
+ } else {
+ return start_session($self, $conn, $vendor, $ou);
+ }
+}
+__PACKAGE__->register_method(
+ method => 'check_session',
+ api_name => 'open-ils.ebook_api.check_session',
+ api_level => 1,
+ argc => 2,
+ signature => {
+ desc => "Validate an existing EbookAPI session, or initiate a new one",
+ params => [
+ {
+ name => 'session_id',
+ desc => 'The EbookAPI session ID being checked',
+ type => 'string'
+ },
+ {
+ name => 'vendor',
+ desc => 'The ebook vendor (e.g. "oneclickdigital")',
+ type => 'string'
+ },
+ {
+ name => 'ou',
+ desc => 'The context org unit ID',
+ type => 'number'
+ }
+ ],
+ return => {
+ desc => 'Returns an EbookAPI session ID',
+ type => 'string'
+ }
+ }
+);
+
+sub _start_session {
+ my $vendor = shift;
+ my $ou = shift;
+ $ou = $ou || 1; # default to top-level org unit
+
+ my $module;
+
+ # determine EbookAPI handler from vendor name
+ # TODO handle API versions?
+ if ($vendor_handlers{$vendor}) {
+ $module = $vendor_handlers{$vendor};
+ } else {
+ $logger->error("EbookAPI: No handler module found for $vendor!");
+ return;
+ }
+
+ # TODO cache session? reuse an existing one if available?
+
+ # generate session ID
+ my ($sec, $usec) = gettimeofday();
+ my $r = rand();
+ my $session_id = "ebook_api.ses." . md5_hex("$sec-$usec-$r");
+
+ my $args = {
+ vendor => $vendor,
+ ou => $ou,
+ session_id => $session_id
+ };
+
+ $module->use;
+ $handler = $module->new($args); # create new handler object
+ $handler->initialize(); # set handler attributes
+ $handler->do_client_auth(); # authorize client session against API, if applicable
+
+ # our "session" is actually just our handler object, serialized and cached
+ my $ckey = $handler->{session_id};
+ $cache->put_cache($ckey, $handler, $cache_timeout);
+
+ return $handler->{session_id};
+}
+
+sub start_session {
+ my $self = shift;
+ my $conn = shift;
+ my $vendor = shift;
+ my $ou = shift;
+ return _start_session($vendor, $ou);
+}
+__PACKAGE__->register_method(
+ method => 'start_session',
+ api_name => 'open-ils.ebook_api.start_session',
+ api_level => 1,
+ argc => 1,
+ signature => {
+ desc => "Initiate an EbookAPI session",
+ params => [
+ {
+ name => 'vendor',
+ desc => 'The ebook vendor (e.g. "oneclickdigital")',
+ type => 'string'
+ },
+ {
+ name => 'ou',
+ desc => 'The context org unit ID',
+ type => 'number'
+ }
+ ],
+ return => {
+ desc => 'Returns an EbookAPI session ID',
+ type => 'string'
+ }
+ }
+);
+
+sub cache_patron_password {
+ my $self = shift;
+ my $conn = shift;
+ my $session_id = shift;
+ my $password = shift;
+
+ # We don't need the handler module for this.
+ # Let's just update the cache directly.
+ if (my $ses = $cache->get_cache($session_id)) {
+ $ses->{patron_password} = $password;
+ if (update_cache($ses)) {
+ return $session_id;
+ } else {
+ $logger->error("EbookAPI: there was an error caching patron password");
+ return;
+ }
+ }
+}
+__PACKAGE__->register_method(
+ method => 'cache_patron_password',
+ api_name => 'open-ils.ebook_api.patron.cache_password',
+ api_level => 1,
+ argc => 2,
+ signature => {
+ desc => "Cache patron password on login for use during EbookAPI patron authentication",
+ params => [
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'patron_password',
+ desc => 'The patron password',
+ type => 'string'
+ }
+ ],
+ return => { desc => 'A session key, or undef' }
+ }
+);
+
+# Submit an HTTP request to a specified API endpoint.
+#
+# Params:
+#
+# $req - hashref containing the following:
+# method: HTTP request method (defaults to GET)
+# uri: API endpoint URI (required)
+# header: arrayref of HTTP headers (optional, but see below)
+# content: content of HTTP request (optional)
+# request_timeout (defaults to value in opensrf.xml)
+# $session_id - id of cached EbookAPI session
+#
+# A "Content-Type: application/json" header is automatically added to each
+# request. If no Authorization header is provided via the $req param, the
+# following header will also be automatically added:
+#
+# Authorization: basic $basic_token
+#
+# ... where $basic_token is derived from the cached session identified by the
+# $session_id param. If this does not meet the needs of your API, include the
+# correct Authorization header in $req->{header}.
+sub request {
+ my $self = shift;
+ my $req = shift;
+ my $session_id = shift;
+
+ my $uri;
+ if (!defined ($req->{uri})) {
+ $logger->error('EbookAPI: attempted an HTTP request but no URI was provided');
+ return;
+ } else {
+ $uri = $req->{uri};
+ }
+
+ my $method = defined $req->{method} ? $req->{method} : 'GET';
+ my $headers = defined $req->{headers} ? $req->{headers} : {};
+ my $content = defined $req->{content} ? $req->{content} : undef;
+ my $request_timeout = defined $req->{request_timeout} ? $req->{request_timeout} : $default_request_timeout;
+
+ # JSON as default content type
+ if ( !defined ($headers->{'Content-Type'}) ) {
+ $headers->{'Content-Type'} = 'application/json';
+ }
+
+ # all requests also require an Authorization header;
+ # let's default to using our basic token, if available
+ if ( !defined ($headers->{'Authorization'}) ) {
+ if (!$session_id) {
+ $logger->error("EbookAPI: HTTP request requires session info but no session ID was provided");
+ return;
+ }
+ my $ses = retrieve_session($session_id);
+ if ($ses) {
+ my $basic_token = $ses->{basic_token};
+ $headers->{'Authorization'} = "basic $basic_token";
+ }
+ }
+
+ my $client = OpenILS::Utils::HTTPClient->new();
+ my $res = $client->request(
+ $method,
+ $uri,
+ $headers,
+ $content,
+ $request_timeout
+ );
+ if (!defined ($res)) {
+ $logger->error('EbookAPI: no HTTP response received');
+ return;
+ } else {
+ $logger->info("EbookAPI: response received from server: " . $res->status_line);
+ return {
+ is_success => $res->is_success,
+ status => $res->status_line,
+ content => OpenSRF::Utils::JSON->JSON2perl($res->decoded_content)
+ };
+ }
+}
+
+sub get_availability {
+ my ($self, $conn, $session_id, $title_id) = @_;
+ my $handler = new_handler($session_id);
+ return $handler->do_availability_lookup($title_id);
+}
+__PACKAGE__->register_method(
+ method => 'get_availability',
+ api_name => 'open-ils.ebook_api.title.availability',
+ api_level => 1,
+ argc => 2,
+ signature => {
+ desc => "Get availability info for an ebook title",
+ params => [
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'title_id',
+ desc => 'The title ID (ISBN, unique identifier, etc.)',
+ type => 'string'
+ }
+ ],
+ return => {
+ desc => 'Returns 1 if title is available, 0 if not available, or undef if availability info could not be retrieved',
+ type => 'number'
+ }
+ }
+);
+
+sub get_holdings {
+ my ($self, $conn, $session_id, $title_id) = @_;
+ my $handler = new_handler($session_id);
+ return $handler->do_holdings_lookup($title_id);
+}
+__PACKAGE__->register_method(
+ method => 'get_holdings',
+ api_name => 'open-ils.ebook_api.title.holdings',
+ api_level => 1,
+ argc => 2,
+ signature => {
+ desc => "Get detailed holdings info (copy counts and formats) for an ebook title, or basic availability if holdings info is unavailable",
+ params => [
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'title_id',
+ desc => 'The title ID (ISBN, unique identifier, etc.)',
+ type => 'string'
+ }
+ ],
+ return => {
+ 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)',
+ type => 'hashref'
+ }
+ }
+);
+
+# Wrapper function for performing transactions that require an authenticated
+# patron and a title identifier (checkout, checkin, renewal, etc).
+#
+# Params:
+# - title_id: ISBN (OneClickdigital), title identifier (OverDrive)
+# - barcode: patron barcode
+#
+sub do_xact {
+ my ($self, $conn, $auth, $session_id, $title_id, $barcode) = @_;
+
+ my $action;
+ if ($self->api_name =~ /checkout/) {
+ $action = 'checkout';
+ } elsif ($self->api_name =~ /checkin/) {
+ $action = 'checkin';
+ } elsif ($self->api_name =~ /renew/) {
+ $action = 'renew';
+ } elsif ($self->api_name =~ /place_hold/) {
+ $action = 'place_hold';
+ } elsif ($self->api_name =~ /cancel_hold/) {
+ $action = 'cancel_hold';
+ }
+ $logger->info("EbookAPI: doing $action for title $title_id...");
+
+ # verify that user is authenticated in EG
+ my $e = new_editor(authtoken => $auth);
+ if (!$e->checkauth) {
+ $logger->error("EbookAPI: authentication failed: " . $e->die_event);
+ return;
+ }
+
+ my $handler = new_handler($session_id);
+ my $user_token = $handler->do_patron_auth($barcode);
+
+ # handler method constructs and submits request (and handles any external authentication)
+ my $res = $handler->$action($title_id, $user_token);
+ if (defined ($res)) {
+ return $res;
+ } else {
+ $logger->error("EbookAPI: could not do $action for title $title_id and patron $barcode");
+ return;
+ }
+}
+__PACKAGE__->register_method(
+ method => 'do_xact',
+ api_name => 'open-ils.ebook_api.checkout',
+ api_level => 1,
+ argc => 4,
+ signature => {
+ desc => "Checkout an ebook title to a patron",
+ params => [
+ {
+ name => 'authtoken',
+ desc => 'Authentication token',
+ type => 'string'
+ },
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'title_id',
+ desc => 'The identifier of the title',
+ type => 'string'
+ },
+ {
+ name => 'barcode',
+ desc => 'The barcode of the patron to whom the title will be checked out',
+ type => 'string'
+ },
+ ],
+ return => {
+ desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Checkout limit reached." }',
+ type => 'hashref'
+ }
+ }
+);
+__PACKAGE__->register_method(
+ method => 'do_xact',
+ api_name => 'open-ils.ebook_api.renew',
+ api_level => 1,
+ argc => 4,
+ signature => {
+ desc => "Renew an ebook title for a patron",
+ params => [
+ {
+ name => 'authtoken',
+ desc => 'Authentication token',
+ type => 'string'
+ },
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'title_id',
+ desc => 'The identifier of the title to be renewed',
+ type => 'string'
+ },
+ {
+ name => 'barcode',
+ desc => 'The barcode of the patron to whom the title is checked out',
+ type => 'string'
+ },
+ ],
+ return => {
+ desc => 'Success: { due_date => "2017-01-01" } / Failure: { error_msg => "Renewal limit reached." }',
+ type => 'hashref'
+ }
+ }
+);
+__PACKAGE__->register_method(
+ method => 'do_xact',
+ api_name => 'open-ils.ebook_api.checkin',
+ api_level => 1,
+ argc => 4,
+ signature => {
+ desc => "Check in an ebook title for a patron",
+ params => [
+ {
+ name => 'authtoken',
+ desc => 'Authentication token',
+ type => 'string'
+ },
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'title_id',
+ desc => 'The identifier of the title to be checked in',
+ type => 'string'
+ },
+ {
+ name => 'barcode',
+ desc => 'The barcode of the patron to whom the title is checked out',
+ type => 'string'
+ },
+ ],
+ return => {
+ desc => 'Success: { } / Failure: { error_msg => "Checkin failed." }',
+ type => 'hashref'
+ }
+ }
+);
+__PACKAGE__->register_method(
+ method => 'do_xact',
+ api_name => 'open-ils.ebook_api.place_hold',
+ api_level => 1,
+ argc => 4,
+ signature => {
+ desc => "Place a hold on an ebook title for a patron",
+ params => [
+ {
+ name => 'authtoken',
+ desc => 'Authentication token',
+ type => 'string'
+ },
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'title_id',
+ desc => 'The identifier of the title',
+ type => 'string'
+ },
+ {
+ name => 'barcode',
+ desc => 'The barcode of the patron for whom the title is being held',
+ type => 'string'
+ },
+ ],
+ return => {
+ desc => 'Success: { queue_position => 1, queue_size => 1, expire_date => "2017-01-01" } / Failure: { error_msg => "Could not place hold." }',
+ type => 'hashref'
+ }
+ }
+);
+__PACKAGE__->register_method(
+ method => 'do_xact',
+ api_name => 'open-ils.ebook_api.cancel_hold',
+ api_level => 1,
+ argc => 4,
+ signature => {
+ desc => "Cancel a hold on an ebook title for a patron",
+ params => [
+ {
+ name => 'authtoken',
+ desc => 'Authentication token',
+ type => 'string'
+ },
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'title_id',
+ desc => 'The identifier of the title',
+ type => 'string'
+ },
+ {
+ name => 'barcode',
+ desc => 'The barcode of the patron',
+ type => 'string'
+ },
+ ],
+ return => {
+ desc => 'Success: { } / Failure: { error_msg => "Could not cancel hold." }',
+ type => 'hashref'
+ }
+ }
+);
+
+sub _get_patron_xacts {
+ my ($xact_type, $auth, $session_id, $barcode) = @_;
+
+ $logger->info("EbookAPI: getting $xact_type for patron $barcode");
+
+ # verify that user is authenticated in EG
+ my $e = new_editor(authtoken => $auth);
+ if (!$e->checkauth) {
+ $logger->error("EbookAPI: authentication failed: " . $e->die_event);
+ return;
+ }
+
+ my $handler = new_handler($session_id);
+ my $user_token = $handler->do_patron_auth($barcode);
+
+ my $xacts;
+ if ($xact_type eq 'checkouts') {
+ $xacts = $handler->get_patron_checkouts($user_token);
+ } elsif ($xact_type eq 'holds') {
+ $xacts = $handler->get_patron_holds($user_token);
+ } else {
+ $logger->error("EbookAPI: invalid transaction type '$xact_type'");
+ return;
+ }
+
+ # cache and return transaction details
+ $handler->{$xact_type} = $xacts;
+ # Overlay transactions onto existing cached handler.
+ if (update_cache($handler, 1)) {
+ return $handler->{$xact_type};
+ } else {
+ $logger->error("EbookAPI: error caching transaction details ($xact_type)");
+ return;
+ }
+}
+
+sub get_patron_xacts {
+ my ($self, $conn, $auth, $session_id, $barcode) = @_;
+ my $xact_type;
+ if ($self->api_name =~ /checkouts/) {
+ $xact_type = 'checkouts';
+ } elsif ($self->api_name =~ /holds/) {
+ $xact_type = 'holds';
+ }
+ return _get_patron_xacts($xact_type, $auth, $session_id, $barcode);
+}
+__PACKAGE__->register_method(
+ method => 'get_patron_xacts',
+ api_name => 'open-ils.ebook_api.patron.get_checkouts',
+ api_level => 1,
+ argc => 3,
+ signature => {
+ desc => "Get information about a patron's ebook checkouts",
+ params => [
+ {
+ name => 'authtoken',
+ desc => 'Authentication token',
+ type => 'string'
+ },
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'barcode',
+ desc => 'The barcode of the patron',
+ type => 'string'
+ }
+ ],
+ return => {
+ desc => 'Returns an array of transaction details, or undef if no details available',
+ type => 'array'
+ }
+ }
+);
+__PACKAGE__->register_method(
+ method => 'get_patron_xacts',
+ api_name => 'open-ils.ebook_api.patron.get_holds',
+ api_level => 1,
+ argc => 3,
+ signature => {
+ desc => "Get information about a patron's ebook holds",
+ params => [
+ {
+ name => 'authtoken',
+ desc => 'Authentication token',
+ type => 'string'
+ },
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'barcode',
+ desc => 'The barcode of the patron',
+ type => 'string'
+ }
+ ],
+ return => {
+ desc => 'Returns an array of transaction details, or undef if no details available',
+ type => 'array'
+ }
+ }
+);
+
+sub get_all_patron_xacts {
+ my ($self, $conn, $auth, $session_id, $barcode) = @_;
+ my $checkouts = _get_patron_xacts('checkouts', $auth, $session_id, $barcode);
+ my $holds = _get_patron_xacts('holds', $auth, $session_id, $barcode);
+ return {
+ checkouts => $checkouts,
+ holds => $holds
+ };
+}
+__PACKAGE__->register_method(
+ method => 'get_all_patron_xacts',
+ api_name => 'open-ils.ebook_api.patron.get_transactions',
+ api_level => 1,
+ argc => 3,
+ signature => {
+ desc => "Get information about a patron's ebook checkouts and holds",
+ params => [
+ {
+ name => 'authtoken',
+ desc => 'Authentication token',
+ type => 'string'
+ },
+ {
+ name => 'session_id',
+ desc => 'The session ID (provided by open-ils.ebook_api.start_session)',
+ type => 'string'
+ },
+ {
+ name => 'barcode',
+ desc => 'The barcode of the patron',
+ type => 'string'
+ }
+ ],
+ return => {
+ desc => 'Returns a hashref of transactions: { checkouts => [], holds => [], failed => [] }',
+ type => 'hashref'
+ }
+ }
+);
+
+1;
--- /dev/null
+#!/usr/bin/perl
+
+# Copyright (C) 2015 BC Libraries Cooperative
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+# ======================================================================
+# OpenSRF requests are handled by the main OpenILS::Application::EbookAPI module,
+# which determines which "handler" submodule to use based on the params of the
+# OpenSRF request. Each vendor API (OneClickdigital, OverDrive, etc.) has its
+# own separate handler class, since they all work a little differently.
+#
+# An instance of the handler class represents an EbookAPI session -- that is, we
+# instantiate a new handler object when we start a new session with the external API.
+# Thus everything we need to talk to the API, like client keys or auth tokens, is
+# an attribute of the handler object.
+#
+# API endpoints are defined in the handler class. The handler constructs HTTP
+# requests, then passes them to the the request() method of the parent class
+# (OpenILS::Application::EbookAPI), which sets some default headers and manages
+# the actual mechanics of sending the request and receiving the response. It's
+# up to the handler class to do something with the response.
+#
+# At a minimum, each handler must have the following methods, since the parent
+# class presumes they exist; it may be a no-op if the API doesn't support that
+# bit of functionality:
+#
+# - initialize: assign values for basic attributes (e.g. library_id,
+# basic_token) based on library settings
+# - do_client_auth: authenticate client with external API (e.g. get client
+# token if needed)
+# - do_patron_auth: get a patron-specific bearer token, or just the patron ID
+# - do_holdings_lookup: how many total/available "copies" are there for this
+# title? (n/a for OneClickdigital)
+# - do_availability_lookup: does this title have available "copies"? y/n
+# - checkout
+# - renew
+# - checkin
+# - place_hold
+# - suspend_hold (n/a for OneClickdigital)
+# - cancel_hold
+# - get_patron_checkouts: returns an array of hashrefs representing checkouts;
+# each checkout hashref has the following keys:
+# - xact_id
+# - title_id
+# - due_date
+# - download_url
+# - title
+# - author
+# - get_patron_holds
+# ======================================================================
+
+package OpenILS::Application::EbookAPI::Test;
+
+use strict;
+use warnings;
+
+use OpenILS::Application;
+use OpenILS::Application::EbookAPI;
+use base qw/OpenILS::Application::EbookAPI/;
+use OpenSRF::AppSession;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::Cache;
+use OpenILS::Application::AppUtils;
+use DateTime;
+use DateTime::Format::ISO8601;
+
+my $U = 'OpenILS::Application::AppUtils';
+
+# create new handler object
+sub new {
+ my( $class, $args ) = @_;
+
+ # A new handler object represents a new API session, so we instantiate it
+ # by passing it a hashref containing the following basic attributes
+ # available to us when we start the session:
+ # - vendor: a string indicating the vendor whose API we're talking to
+ # - ou: org unit ID for current session
+ # - session_id: unique ID for the session represented by this object
+
+ $class = ref $class || $class;
+ return bless $args, $class;
+}
+
+# set API-specific handler attributes based on library settings
+sub initialize {
+ my $self = shift;
+
+ # At a minimum, you are likely to need some kind of basic API key or token
+ # to allow the client (Evergreen) to use the API.
+ # Other attributes will vary depending on the API. Consult your API
+ # documentation for details.
+
+ return $self;
+}
+
+# authorize client session against API
+sub do_client_auth {
+ my $self = shift;
+
+ # Some APIs require client authorization, and may return an auth token
+ # which must be included in subsequent requests. This is where you do
+ # that. If you get an auth token, you'll want to add it as an attribute to
+ # the handler object so that it's available to use in subsequent requests.
+ # If your API doesn't require this step, you don't need to return anything
+ # here.
+
+ return;
+}
+
+# authenticate patron against API
+sub do_patron_auth {
+ my $self = shift;
+
+ # We authenticate the patron using the barcode of their active card.
+ # We may capture this on OPAC login (along with password, if required),
+ # in which case it should already be an attribute of the handler object;
+ # otherwise, it should be passed to this method as a parameter.
+ my $barcode = shift;
+ if ($barcode) {
+ if (!$self->{patron_barcode}) {
+ $self->{patron_barcode} = $barcode;
+ } elsif ($barcode ne $self->{patron_barcode}) {
+ $logger->error("EbookAPI: patron barcode in auth request does not match patron barcode for this session");
+ return;
+ }
+ } else {
+ if (!$self->{patron_barcode}) {
+ $logger->error("EbookAPI: Cannot authenticate patron with unknown barcode");
+ } else {
+ $barcode = $self->{patron_barcode};
+ }
+ }
+
+ # We really don't want to be handling the patron's unencrypted password.
+ # But if we need to, it should be added to our handler object on login
+ # via the open-ils.ebook_api.patron.cache_password OpenSRF API call
+ # before we attempt to authenticate the patron against the external API.
+ my $password;
+ if ($self->{patron_password}) {
+ $password = $self->{patron_password};
+ }
+
+ # return external patron ID or patron auth token
+
+ # For testing, only barcode 99999359616 is valid.
+ return 'USER001' if ($barcode eq '99999359616');
+
+ # All other values return undef.
+ return undef;
+}
+
+# get detailed holdings information (copy counts and formats), OR basic
+# availability if detailed info is not provided by the API
+sub do_holdings_lookup {
+ my $self = shift;
+
+ # External ID for title. Depending on the API, this could be an ISBN
+ # or an identifier unique to that vendor.
+ my $title_id = shift;
+
+ # Prepare data structure to be used as return value.
+ # NOTE: If the external API does not provide detailed holdings info,
+ # return simple availability information: { available => 1 }
+ my $holdings = {
+ copies_owned => 0,
+ copies_available => 0,
+ formats => []
+ };
+
+ # 001 and 002 are unavailable.
+ if ($title_id eq '001' || $title_id eq '002') {
+ $holdings->{copies_owned} = 1;
+ $holdings->{copies_available} = 0;
+ push @{$holdings->{formats}}, 'ebook';
+ }
+
+ # 003 is available.
+ if ($title_id eq '003') {
+ $holdings->{copies_owned} = 1;
+ $holdings->{copies_available} = 1;
+ push @{$holdings->{formats}}, 'ebook';
+ }
+
+ # All other title IDs are unknown.
+
+ return $holdings;
+}
+
+# look up whether a title is currently available for checkout; returns a boolean value
+sub do_availability_lookup {
+ my $self = shift;
+
+ # External ID for title. Depending on the API, this could be an ISBN
+ # or an identifier unique to that vendor.
+ my $title_id = shift;
+
+ # At this point, you would lookup title availability via an API request.
+ # In our case, since this is a test module, we just return availability info
+ # based on hard-coded values:
+
+ # 001 and 002 are unavailable.
+ return 0 if ($title_id eq '001');
+ return 0 if ($title_id eq '002');
+
+ # 003 is available.
+ return 1 if ($title_id eq '003');
+
+ # All other title IDs are unknown.
+ return undef;
+}
+
+# check out a title to a patron
+sub checkout {
+ my $self = shift;
+
+ # External ID of title to be checked out.
+ my $title_id = shift;
+
+ # Patron ID or patron auth token, as returned by do_patron_auth().
+ my $user_token = shift;
+
+ # If checkout succeeds, the response is a hashref with the following fields:
+ # - due_date
+ # - xact_id (optional)
+ #
+ # If checkout fails, the response is a hashref with the following fields:
+ # - error_msg: a string containing an error message or description of why
+ # the checkout failed (e.g. "Checkout limit of (4) reached").
+ #
+ # If no valid response is received from the API, return undef.
+
+ # For testing purposes, user ID USER001 is our only valid user,
+ # and title 003 is the only available title.
+ if ($title_id && $user_token) {
+ if ($user_token eq 'USER001' && $title_id eq '003') {
+ return { due_date => DateTime->today()->add( days => 14 )->iso8601() };
+ } else {
+ return { msg => 'Checkout failed.' };
+ }
+ } else {
+ return undef;
+ }
+
+}
+
+sub renew {
+ my $self = shift;
+
+ # External ID of title to be renewed.
+ my $title_id = shift;
+
+ # Patron ID or patron auth token, as returned by do_patron_auth().
+ my $user_token = shift;
+
+ # If renewal succeeds, the response is a hashref with the following fields:
+ # - due_date
+ # - xact_id (optional)
+ #
+ # If renewal fails, the response is a hashref with the following fields:
+ # - error_msg: a string containing an error message or description of why
+ # the renewal failed (e.g. "Renewal limit reached").
+ #
+ # If no valid response is received from the API, return undef.
+
+ # For testing purposes, user ID USER001 is our only valid user,
+ # and title 001 is the only renewable title.
+ if ($title_id && $user_token) {
+ if ($user_token eq 'USER001' && $title_id eq '001') {
+ return { due_date => DateTime->today()->add( days => 14 )->iso8601() };
+ } else {
+ return { error_msg => 'Renewal failed.' };
+ }
+ } else {
+ return undef;
+ }
+}
+
+sub checkin {
+ my $self = shift;
+
+ # External ID of title to be checked in.
+ my $title_id = shift;
+
+ # Patron ID or patron auth token, as returned by do_patron_auth().
+ my $user_token = shift;
+
+ # If checkin succeeds, return an empty hashref (actually it doesn't
+ # need to be empty, it just must NOT contain "error_msg" as a key).
+ #
+ # If checkin fails, return a hashref with the following fields:
+ # - error_msg: a string containing an error message or description of why
+ # the checkin failed (e.g. "Checkin failed").
+ #
+ # If no valid response is received from the API, return undef.
+
+ # For testing purposes, user ID USER001 is our only valid user,
+ # and title 003 is the only title that can be checked in.
+ if ($title_id && $user_token) {
+ if ($user_token eq 'USER001' && $title_id eq '003') {
+ return {};
+ } else {
+ return { error_msg => 'Checkin failed' };
+ }
+ } else {
+ return undef;
+ }
+}
+
+sub place_hold {
+ my $self = shift;
+
+ # External ID of title to be held.
+ my $title_id = shift;
+
+ # Patron ID or patron auth token, as returned by do_patron_auth().
+ my $user_token = shift;
+
+ # If hold is successfully placed, return a hashref with the following
+ # fields:
+ # - queue_position: this user's position in hold queue for this title
+ # - queue_size: total number of holds on this title
+ # - expire_date: when the hold expires
+ #
+ # If hold fails, return a hashref with the following fields:
+ # - error_msg: a string containing an error message or description of why
+ # the hold failed (e.g. "Hold limit (4) reached").
+ #
+ # If no valid response is received from the API, return undef.
+
+ # For testing purposes, we always and only allow placing a hold on title
+ # 002 by user ID USER001.
+ if ($title_id && $user_token) {
+ if ($user_token eq 'USER001' && $title_id eq '002') {
+ return {
+ queue_position => 1,
+ queue_size => 1,
+ expire_date => DateTime->today()->add( days => 70 )->iso8601()
+ };
+ } else {
+ return { error_msg => 'Unable to place hold' };
+ }
+ } else {
+ return undef;
+ }
+}
+
+sub cancel_hold {
+ my $self = shift;
+
+ # External ID of title.
+ my $title_id = shift;
+
+ # Patron ID or patron auth token, as returned by do_patron_auth().
+ my $user_token = shift;
+
+ # If hold is successfully canceled, return an empty hashref (actually it
+ # doesn't need to be empty, it just must NOT contain "error_msg" as a key).
+ #
+ # If hold is NOT canceled, return a hashref with the following fields:
+ # - error_msg: a string containing an error message or description of why
+ # the hold was not canceled (e.g. "Hold could not be canceled").
+ #
+ # If no valid response is received from the API, return undef.
+
+ # For testing purposes, we always and only allow canceling a hold on title
+ # 002 by user ID USER001.
+ if ($title_id && $user_token) {
+ if ($user_token eq 'USER001' && $title_id eq '002') {
+ return {};
+ } else {
+ return { error_msg => 'Unable to cancel hold' };
+ }
+ } else {
+ return undef;
+ }
+}
+
+sub suspend_hold {
+}
+
+sub get_patron_checkouts {
+ my $self = shift;
+
+ # Patron ID or patron auth token.
+ my $user_token = shift;
+
+ # Return an array of hashrefs representing checkouts;
+ # each hashref should have the following keys:
+ # - xact_id: unique ID for this transaction (if used by API)
+ # - title_id: unique ID for this title
+ # - due_date
+ # - download_url
+ # - title: title of item, formatted for display
+ # - author: author of item, formatted for display
+
+ my $checkouts = [];
+ # USER001 is our only valid user, so we only return checkouts for them.
+ if ($user_token eq 'USER001') {
+ push @$checkouts, {
+ xact_id => '1',
+ title_id => '001',
+ due_date => DateTime->today()->add( days => 7 )->iso8601(),
+ download_url => 'http://example.com/ebookapi/t/001/download',
+ title => 'The Fellowship of the Ring',
+ author => 'J. R. R. Tolkien'
+ };
+ }
+ $self->{checkouts} = $checkouts;
+ return $self->{checkouts};
+}
+
+sub get_patron_holds {
+ my $self = shift;
+
+ # Patron ID or patron auth token.
+ my $user_token = shift;
+
+ # Return an array of hashrefs representing holds;
+ # each hashref should have the following keys:
+ # - title_id: unique ID for this title
+ # - queue_position: this user's position in hold queue for this title
+ # - queue_size: total number of holds on this title
+ # - is_ready: whether hold is currently available for checkout
+ # - is_frozen: whether hold is suspended
+ # - thaw_date: when hold suspension expires (if suspended)
+ # - create_date: when the hold was placed
+ # - expire_date: when the hold expires
+ # - title: title of item, formatted for display
+ # - author: author of item, formatted for display
+
+ my $holds = [];
+ # USER001 is our only valid user, so we only return checkouts for them.
+ if ($user_token eq 'USER001') {
+ push @$holds, {
+ title_id => '002',
+ queue_position => 1,
+ queue_size => 1,
+ is_ready => 0,
+ is_frozen => 0,
+ create_date => DateTime->today()->subtract( days => 10 )->iso8601(),
+ expire_date => DateTime->today()->add( days => 60 )->iso8601(),
+ title => 'The Two Towers',
+ author => 'J. R. R. Tolkien'
+ };
+ }
+ $self->{holds} = $holds;
+ return $self->{holds};
+}
+
--- /dev/null
+#!perl
+use strict; use warnings;
+use Test::More tests => 21; # XXX
+use OpenILS::Utils::TestUtils;
+
+diag("Tests Ebook API");
+
+# ------------------------------------------------------------
+# 1. Set up test environment.
+# ------------------------------------------------------------
+
+use constant EBOOK_API_VENDOR => 'ebook_test';
+use constant EBOOK_API_OU => 1;
+
+# Title IDs:
+# 001 - checked out to test user
+# 002 - not available (checked out to another user)
+# 003 - available
+# 004 - not found (invalid/does not exist in external system)
+
+# Patrons.
+use constant EBOOK_API_PATRON_USERNAME => '99999359616';
+use constant EBOOK_API_PATRON_PASSWORD => 'andreac1234';
+use constant EBOOK_API_PATRON_NOT_FOUND => 'patron-not-found';
+
+my $script = OpenILS::Utils::TestUtils->new();
+$script->bootstrap;
+
+my $ebook_api = $script->session('open-ils.ebook_api');
+
+# ------------------------------------------------------------
+# 2. Sessions.
+# ------------------------------------------------------------
+
+# Initiate a new EbookAPI session and get a session ID.
+# Returns undef unless a new session was created.
+my $session_id_req = $ebook_api->request(
+ 'open-ils.ebook_api.start_session', EBOOK_API_VENDOR, EBOOK_API_OU);
+my $session_id = $session_id_req->recv->content;
+ok($session_id, 'Initiated an EbookAPI session');
+
+# Check that an EbookAPI session exists matching our session ID.
+my $ck_session_id_req = $ebook_api->request(
+ 'open-ils.ebook_api.check_session', $session_id, EBOOK_API_VENDOR, EBOOK_API_OU);
+my $ck_session_id = $ck_session_id_req->recv->content;
+ok($ck_session_id eq $session_id, 'Validated existing EbookAPI session');
+
+# Given an invalid or expired session ID, fallback to initiating
+# a new EbookAPI session, which gives us a new session ID.
+# Returns undef unless a new session was created.
+my $new_session_id_req = $ebook_api->request(
+ 'open-ils.ebook_api.check_session', '', EBOOK_API_VENDOR, EBOOK_API_OU);
+my $new_session_id = $new_session_id_req->recv->content;
+ok($new_session_id, 'Initiated new EbookAPI session when valid session ID not provided');
+
+# ------------------------------------------------------------
+# 3. Title availability and holdings.
+# ------------------------------------------------------------
+
+# Title is not available.
+my $title_001_avail_req = $ebook_api->request(
+ 'open-ils.ebook_api.title.availability', $session_id, '001');
+my $title_001_avail = $title_001_avail_req->recv->content;
+is($title_001_avail, 0, 'Availability check 1/3 (not available)');
+
+# Title is available.
+my $title_003_avail_req = $ebook_api->request(
+ 'open-ils.ebook_api.title.availability', $session_id, '003');
+my $title_003_avail = $title_003_avail_req->recv->content;
+is($title_003_avail, 1, 'Availability check 2/3 (available)');
+
+# Title is not found (availability lookup returns undef).
+my $title_004_avail_req = $ebook_api->request(
+ 'open-ils.ebook_api.title.availability', $session_id, '004');
+my $title_004_avail = (defined $title_004_avail_req && defined $title_004_avail_req->recv) ? $title_004_avail_req->recv->content : undef;
+is($title_004_avail, undef, 'Availability check 3/3 (not found)');
+
+# Title has holdings, none available.
+my $title_001_holdings_req = $ebook_api->request(
+ 'open-ils.ebook_api.title.holdings', $session_id, '001');
+my $title_001_holdings = $title_001_holdings_req->recv->content;
+ok(ref($title_001_holdings) && $title_001_holdings->{copies_owned} == 1 && $title_001_holdings->{copies_available} == 0 && $title_001_holdings->{formats}->[0] eq 'ebook', 'Holdings check 1/3 (1 owned, 0 available)');
+
+# Title has holdings, one copy available.
+my $title_003_holdings_req = $ebook_api->request(
+ 'open-ils.ebook_api.title.holdings', $session_id, '003');
+my $title_003_holdings = $title_003_holdings_req->recv->content;
+ok(ref($title_003_holdings) && $title_003_holdings->{copies_owned} == 1 && $title_003_holdings->{copies_available} == 1 && $title_003_holdings->{formats}->[0] eq 'ebook', 'Holdings check 2/3 (1 owned, 1 available)');
+
+# Title not found, no holdings.
+my $title_004_holdings_req = $ebook_api->request(
+ 'open-ils.ebook_api.title.holdings', $session_id, '004');
+my $title_004_holdings = $title_004_holdings_req->recv->content;
+ok(ref($title_004_holdings) && $title_004_holdings->{copies_owned} == 0 && $title_004_holdings->{copies_available} == 0 && scalar(@{$title_004_holdings->{formats}}) == 0, 'Holdings check 3/3 (0 owned, 0 available)');
+
+# ------------------------------------------------------------
+# 4. Patron authentication and caching.
+# ------------------------------------------------------------
+
+# Authenticate our test patron.
+$script->authenticate({
+ username => EBOOK_API_PATRON_USERNAME,
+ password => EBOOK_API_PATRON_PASSWORD,
+ type => 'opac'
+ });
+ok($script->authtoken, 'Have an authtoken');
+my $authtoken = $script->authtoken;
+
+# open-ils.ebook_api.patron.cache_password
+my $updated_cache_id_req = $ebook_api->request(
+ 'open-ils.ebook_api.patron.cache_password', $session_id, EBOOK_API_PATRON_PASSWORD);
+my $updated_cache_id = $updated_cache_id_req->recv->content;
+ok($updated_cache_id eq $session_id, 'Session cache was updated with patron password');
+
+# ------------------------------------------------------------
+# 5. Patron transactions.
+# ------------------------------------------------------------
+
+# open-ils.ebook_api.patron.get_checkouts
+my $checkouts_req = $ebook_api->request(
+ 'open-ils.ebook_api.patron.get_checkouts', $authtoken, $session_id, EBOOK_API_PATRON_USERNAME);
+my $checkouts = $checkouts_req->recv->content;
+ok(ref($checkouts) && defined $checkouts->[0]->{title_id}, 'Retrieved ebook checkouts for patron');
+
+# open-ils.ebook_api.patron.get_holds
+my $holds_req = $ebook_api->request(
+ 'open-ils.ebook_api.patron.get_holds', $authtoken, $session_id, EBOOK_API_PATRON_USERNAME);
+my $holds = $holds_req->recv->content;
+ok(ref($holds) && defined $holds->[0]->{title_id}, 'Retrieved ebook holds for patron');
+
+# open-ils.ebook_api.patron.get_transactions
+my $xacts_req = $ebook_api->request(
+ 'open-ils.ebook_api.patron.get_transactions', $authtoken, $session_id, EBOOK_API_PATRON_USERNAME);
+my $xacts = $xacts_req->recv->content;
+ok(ref($xacts) && exists $xacts->{checkouts} && exists $xacts->{holds}, 'Retrieved transactions for patron');
+ok(defined $xacts->{checkouts}->[0]->{title_id}, 'Retrieved transactions include checkouts');
+ok(defined $xacts->{holds}->[0]->{title_id}, 'Retrieved transactions include holds');
+
+# open-ils.ebook_api.checkout
+my $checkout_req = $ebook_api->request(
+ 'open-ils.ebook_api.checkout', $authtoken, $session_id, '003', EBOOK_API_PATRON_USERNAME);
+my $checkout = $checkout_req->recv->content;
+ok(exists $checkout->{due_date}, 'Ebook checked out');
+
+# open-ils.ebook_api.renew
+my $renew_req = $ebook_api->request(
+ 'open-ils.ebook_api.renew', $authtoken, $session_id, '001', EBOOK_API_PATRON_USERNAME);
+my $renew = $renew_req->recv->content;
+ok(exists $renew->{due_date}, 'Ebook renewed');
+
+# open-ils.ebook_api.checkin
+my $checkin_req = $ebook_api->request(
+ 'open-ils.ebook_api.checkin', $authtoken, $session_id, '003', EBOOK_API_PATRON_USERNAME);
+my $checkin = $checkin_req->recv->content;
+ok(ref($checkin) && !exists $checkin->{error_msg}, 'Ebook checked in');
+
+# open-ils.ebook_api.cancel_hold
+my $cancel_hold_req = $ebook_api->request(
+ 'open-ils.ebook_api.cancel_hold', $authtoken, $session_id, '002', EBOOK_API_PATRON_USERNAME);
+my $cancel_hold = $cancel_hold_req->recv->content;
+ok(ref($cancel_hold) && !exists $checkin->{error_msg}, 'Ebook hold canceled');
+
+# open-ils.ebook_api.place_hold
+my $place_hold_req = $ebook_api->request(
+ 'open-ils.ebook_api.place_hold', $authtoken, $session_id, '002', EBOOK_API_PATRON_USERNAME);
+my $place_hold = $place_hold_req->recv->content;
+ok(exists $place_hold->{expire_date}, 'Ebook hold placed');
+
+# TODO: suspend hold
+
--- /dev/null
+#!perl -T
+
+use Test::More tests => 2;
+
+BEGIN {
+ use_ok( 'OpenILS::Application::EbookAPI' );
+ use_ok( 'OpenILS::Application::EbookAPI::Test' );
+}
+