[open-ils-commits] [GIT] Evergreen ILS branch master updated. 6d721a809b50814fa88b5601efec9319a5c29e8e

Evergreen Git git at git.evergreen-ils.org
Mon Feb 20 19:06:07 EST 2017


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  6d721a809b50814fa88b5601efec9319a5c29e8e (commit)
       via  d3ada6d437b6deea0329c30b7153c114686fbf8e (commit)
       via  1d599f441b82eb4ba4c8f6a0062eba79c21184bb (commit)
       via  45b0f2a317f258c41d938e1d0318a460824aef71 (commit)
       via  2729bbb9b03771d408123359062e1158f8c017a4 (commit)
       via  addf3ab0c80685394c3d9f476c8af25a26c51a0e (commit)
       via  556d1ceb81ff25ce9aa6c0938de1b6ff84a36871 (commit)
       via  ff070c185d341a2e554c4df982388fcbe5fd9c03 (commit)
       via  0dde1ead0ac55bbd7576665cde29a8c40d62f411 (commit)
       via  6efd80aee60974fe3045d6cab030fdc1db6accd8 (commit)
       via  77f8b367decae2bbe1f3061a6945e4c2c98d94cf (commit)
       via  ef5fb9f2ecc3a9c6b9b595f79a6c9fe246c3589d (commit)
      from  8e1e36da44a3a73c70055786c044bfe45782dbae (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 6d721a809b50814fa88b5601efec9319a5c29e8e
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Mon Feb 20 14:34:05 2017 -0800

    LP#1541559: Use HTTPS for OverDrive requests
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
index 6bb5be5..07df43e 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
@@ -103,9 +103,9 @@ sub initialize {
     my $ou = $self->{ou};
 
     my $discovery_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.discovery_base_uri');
-    $self->{discovery_base_uri} = $discovery_base_uri || 'http://api.overdrive.com/v1';
+    $self->{discovery_base_uri} = $discovery_base_uri || 'https://api.overdrive.com/v1';
     my $circulation_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.circulation_base_uri');
-    $self->{circulation_base_uri} = $circulation_base_uri || 'http://patron.api.overdrive.com/v1';
+    $self->{circulation_base_uri} = $circulation_base_uri || 'https://patron.api.overdrive.com/v1';
 
     my $account_id = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.account_id');
     if ($account_id) {
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index bdd70d7..a1f036d 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -16681,7 +16681,7 @@ VALUES (
     ),
     oils_i18n_gettext(
         'ebook_api.overdrive.discovery_base_uri',
-        'Base URI for OverDrive Discovery API (defaults to http://api.overdrive.com/v1)',
+        'Base URI for OverDrive Discovery API (defaults to https://api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
         'coust',
         'description'
     ),
@@ -16697,7 +16697,7 @@ VALUES (
     ),
     oils_i18n_gettext(
         'ebook_api.overdrive.circulation_base_uri',
-        'Base URI for OverDrive Circulation API (defaults to http://patron.api.overdrive.com/v1)',
+        'Base URI for OverDrive Circulation API (defaults to https://patron.api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
         'coust',
         'description'
     ),
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-overdrive.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-overdrive.sql
index 7e10262..bbcf92c 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-overdrive.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-overdrive.sql
@@ -17,7 +17,7 @@ VALUES (
     ),
     oils_i18n_gettext(
         'ebook_api.overdrive.discovery_base_uri',
-        'Base URI for OverDrive Discovery API (defaults to http://api.overdrive.com/v1)',
+        'Base URI for OverDrive Discovery API (defaults to https://api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
         'coust',
         'description'
     ),
@@ -33,7 +33,7 @@ VALUES (
     ),
     oils_i18n_gettext(
         'ebook_api.overdrive.circulation_base_uri',
-        'Base URI for OverDrive Circulation API (defaults to http://patron.api.overdrive.com/v1)',
+        'Base URI for OverDrive Circulation API (defaults to https://patron.api.overdrive.com/v1). Using HTTPS here is strongly encouraged.',
         'coust',
         'description'
     ),

commit d3ada6d437b6deea0329c30b7153c114686fbf8e
Author: Kathy Lussier <klussier at masslnc.org>
Date:   Mon Feb 20 16:46:02 2017 -0500

    LP#1541559: Minor tweaks to e-books circ in My Account
    
    A couple of tweaks to the CSS for E-items currently checked
    out and a correction to the link for Cureent Items Checked
    Out.
    
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/templates/opac/css/style.css.tt2 b/Open-ILS/src/templates/opac/css/style.css.tt2
index 4c49569..6d5ef52 100644
--- a/Open-ILS/src/templates/opac/css/style.css.tt2
+++ b/Open-ILS/src/templates/opac/css/style.css.tt2
@@ -956,12 +956,12 @@ div.result_table_utils_cont {
 
 .hold_note_title { font-weight: bold; }
 
-#acct_checked_main_header td, #acct_holds_main_header td, #acct_checked_hist_header td, #acct_holds_hist_header td, #acct_list_header td, #acct_list_header_anon td, #temp_list_holds td, #acct_messages_main_header, #ebook_circs_main_table, #ebook_holds_main_table td {
+#acct_checked_main_header td, #acct_holds_main_header td, #acct_checked_hist_header td, #acct_holds_hist_header td, #acct_list_header td, #acct_list_header_anon td, #temp_list_holds td, #acct_messages_main_header, #ebook_circs_main_table td, #ebook_holds_main_table td {
     background: [% css_colors.accent_lighter2 %];
     padding: 10px;
 }
 
-#acct_checked_main_header th, #acct_holds_main_header th, #acct_checked_hist_header th, acct_holds_hist_header th, #acct_list_header th, #acct_list_header_anon th, #temp_list_holds th, #acct_messages_main_header, #ebook_circs_main_table, #ebook_holds_main_table th {
+#acct_checked_main_header th, #acct_holds_main_header th, #acct_checked_hist_header th, acct_holds_hist_header th, #acct_list_header th, #acct_list_header_anon th, #temp_list_holds th, #acct_messages_main_header, #ebook_circs_main_table th, #ebook_holds_main_table th {
     text-align: left;
     padding: 0px 10px 0px 10px;
 }
diff --git a/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
index c073223..5a53976 100644
--- a/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
+++ b/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
@@ -8,7 +8,7 @@
 
     <div id="acct_checked_tabs">
         <div class="align">
-            <a href="[% mkurl('circ_history',{},1) %]">[% l("Current Items Checked Out") %]</a>
+            <a href="[% mkurl('circs',{},1) %]">[% l("Current Items Checked Out") %]</a>
         </div>
         <div class="align selected">
             <a href="#">[% l("E-Items Currently Checked Out") %]</a>

commit 1d599f441b82eb4ba4c8f6a0062eba79c21184bb
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Fri Feb 17 13:24:47 2017 -0800

    LP#1541559: remove non-functional sort on ebook API transaction details in My Account
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
index 4a75f23..c073223 100644
--- a/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
+++ b/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
@@ -29,8 +29,8 @@
             class="table_no_border_space table_no_cell_pad item_list_padding">
             <thead>
             <tr>
-                <th>[% sort_head("sort_title", l("Title")) %]</th>
-                <th>[% sort_head("author", l("Author")) %]</th>
+                <th>[% l("Title") %]</th>
+                <th>[% l("Author") %]</th>
                 <th>[% l("Due Date") %]</th>
                 <th>[% l("Actions") %]</th>
             </tr>
diff --git a/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2
index f578a9f..76bde68 100644
--- a/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2
+++ b/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2
@@ -37,8 +37,8 @@
             class="table_no_border_space table_no_cell_pad item_list_padding">
             <thead>
             <tr>
-                <th>[% sort_head("sort_title", l("Title")) %]</th>
-                <th>[% sort_head("author", l("Author")) %]</th>
+                <th>[% l("Title") %]</th>
+                <th>[% l("Author") %]</th>
                 <th>[% l("Expire Date") %]</th>
                 <th>[% l("Status") %]</th>
                 <th>[% l("Actions") %]</th>
diff --git a/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2
index b93bc95..006c986 100644
--- a/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2
+++ b/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2
@@ -37,8 +37,8 @@
             class="table_no_border_space table_no_cell_pad item_list_padding">
             <thead>
             <tr>
-                <th>[% sort_head("sort_title", l("Title")) %]</th>
-                <th>[% sort_head("author", l("Author")) %]</th>
+                <th>[% l("Title") %]</th>
+                <th>[% l("Author") %]</th>
                 <th>[% l("Expire Date") %]</th>
                 <th>[% l("Actions") %]</th>
             </tr>

commit 45b0f2a317f258c41d938e1d0318a460824aef71
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Fri Feb 17 13:21:38 2017 -0800

    LP#1541559: improve display of ebook API transaction details in My Account
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/templates/opac/css/style.css.tt2 b/Open-ILS/src/templates/opac/css/style.css.tt2
index 98d8749..4c49569 100644
--- a/Open-ILS/src/templates/opac/css/style.css.tt2
+++ b/Open-ILS/src/templates/opac/css/style.css.tt2
@@ -945,7 +945,7 @@ div.result_table_utils_cont {
     /*padding-left:10px;*/
 }
 
-#acct_checked_main_header, #acct_holds_main_header, #acct_checked_hist_header, #acct_holds_hist_header, #acct_list_header, #acct_list_header_anon, #temp_list_holds, #acct_messages_main_header {
+#acct_checked_main_header, #acct_holds_main_header, #acct_checked_hist_header, #acct_holds_hist_header, #acct_list_header, #acct_list_header_anon, #temp_list_holds, #acct_messages_main_header, #ebook_circs_main_table, #ebook_holds_main_table {
     border-collapse: collapse;
 }
 
@@ -956,12 +956,12 @@ div.result_table_utils_cont {
 
 .hold_note_title { font-weight: bold; }
 
-#acct_checked_main_header td, #acct_holds_main_header td, #acct_checked_hist_header td, #acct_holds_hist_header td, #acct_list_header td, #acct_list_header_anon td, #temp_list_holds td, #acct_messages_main_header td {
+#acct_checked_main_header td, #acct_holds_main_header td, #acct_checked_hist_header td, #acct_holds_hist_header td, #acct_list_header td, #acct_list_header_anon td, #temp_list_holds td, #acct_messages_main_header, #ebook_circs_main_table, #ebook_holds_main_table td {
     background: [% css_colors.accent_lighter2 %];
     padding: 10px;
 }
 
-#acct_checked_main_header th, #acct_holds_main_header th, #acct_checked_hist_header th, acct_holds_hist_header th, #acct_list_header th, #acct_list_header_anon th, #temp_list_holds th, #acct_messages_main_header th {
+#acct_checked_main_header th, #acct_holds_main_header th, #acct_checked_hist_header th, acct_holds_hist_header th, #acct_list_header th, #acct_list_header_anon th, #temp_list_holds th, #acct_messages_main_header, #ebook_circs_main_table, #ebook_holds_main_table th {
     text-align: left;
     padding: 0px 10px 0px 10px;
 }
@@ -2089,10 +2089,10 @@ a.preflib_change {
         border-bottom: none;
     }
         /* Force table to not be like tables anymore */
-        table#acct_checked_main_header thead tr th, table#acct_holds_main_header thead tr th, table#acct_checked_hist_header thead tr th, #acct_holds_hist_header thead tr th {
+        table#acct_checked_main_header thead tr th, table#acct_holds_main_header thead tr th, table#acct_checked_hist_header thead tr th, #acct_holds_hist_header thead tr th, #ebook_circs_main_table thead tr th, #ebook_holds_main_table thead tr th {
                 display: block;
         }
-        table#acct_checked_main_header tbody tr td, table#acct_holds_main_header tbody tr td, table#acct_checked_hist_header tbody tr td, #acct_holds_hist_header tbody tr td {
+        table#acct_checked_main_header tbody tr td, table#acct_holds_main_header tbody tr td, table#acct_checked_hist_header tbody tr td, #acct_holds_hist_header tbody tr td, #ebook_circs_main_table thead tr td, #ebook_holds_main_table thead tr td {
                 display: block;
         }
 

commit 2729bbb9b03771d408123359062e1158f8c017a4
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Fri Feb 17 12:51:28 2017 -0800

    LP#1541559: eliminate duplicate entries in ebook API transaction details
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js b/Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js
index 725d2e6..0ba1827 100644
--- a/Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js
+++ b/Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js
@@ -102,6 +102,7 @@ function updateCheckoutView() {
         dojo.removeClass('no_ebook_circs', "hidden");
     } else {
         dojo.forEach(xacts.checkouts, function(x) {
+            dojo.empty('ebook_circs_main_table_body');
             var dl_link = '<a href="' + x.download_url + '">' + l_strings.download + '</a>';
             var tr = dojo.create("tr", null, dojo.byId('ebook_circs_main_table_body'));
             dojo.create("td", { innerHTML: x.title }, tr);
@@ -134,6 +135,7 @@ function updateHoldView() {
             } else {
                 hold_status = h.queue_position + ' / ' + h.queue_size;
             }
+            dojo.empty('ebook_holds_main_table_body');
             var tr = dojo.create("tr", null, dojo.byId('ebook_holds_main_table_body'));
             dojo.create("td", { innerHTML: h.title }, tr);
             dojo.create("td", { innerHTML: h.author }, tr);
@@ -152,6 +154,7 @@ function updateHoldReadyView() {
         dojo.removeClass('no_ebook_holds', "hidden");
     } else {
         dojo.forEach(holds, function(h) {
+            dojo.empty('ebook_holds_main_table_body');
             var tr = dojo.create("tr", null, dojo.byId('ebook_holds_main_table_body'));
             dojo.create("td", { innerHTML: h.title }, tr);
             dojo.create("td", { innerHTML: h.author }, tr);

commit addf3ab0c80685394c3d9f476c8af25a26c51a0e
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Tue Feb 7 15:29:39 2017 -0800

    LP#1541559: ebook API integration for TPAC
    
    When this feature is enabled, Evergreen will use the open-ils.ebook_api
    service to look up title and patron information from specified vendor
    APIs and display that information in the TPAC.  (The service should be
    configured using org settings before being enabled in config.tt2.)
    
    This frontend is essentially a JS layer over top of the OPAC, with some
    light use of Dojo since we're already using it, plus a few additions to
    TT2 templates.  The JS layer uses OpenSRF JS bindings to talk to the
    backend service, which in turn makes the appropriate calls to the
    third-party API.  Session IDs and (if logged in) patron information are
    stored in cookies, which are cleared when the patron logs out.
    
    The user will see the following changes:
    
    - On search results and record summary, for any records from a known
      e-book vendor, Evergreen will automatically look up holdings info from
      the vendor API.  If detailed information on formats and available
      "copies" is provided by the API (e.g. for OverDrive), that information
      is displayed in a table within the record; if only basic availability
      info is available (e.g. for OneClickdigital), a line is added to each
      record indicating whether the title is available.  (Eventually, "Place
      Hold" or "Check Out" links will be added to allow patrons to
      checkout/hold titles without leaving the TPAC.)
    
    - When the user is logged in, the dashboard will show a count of e-book
      checkouts and holds for all enabled e-book vendors, as will the
      account summary.  This is separate from the "main" checkouts/holds
      display, since checkouts/holds on titles from third-party vendors are
      unrelated to checkouts/holds in Evergreen.
    
    - When the user is logged in, additional tabs will be available in My
      Account for displaying detailed information on the patron's ebook
      checkouts and holds.  (Eventually, functionality will be added to My
      Account allowing the user to download or renew titles, suspend or
      cancel holds, etc.)
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/templates/opac/css/style.css.tt2 b/Open-ILS/src/templates/opac/css/style.css.tt2
index 864304b..98d8749 100644
--- a/Open-ILS/src/templates/opac/css/style.css.tt2
+++ b/Open-ILS/src/templates/opac/css/style.css.tt2
@@ -114,12 +114,12 @@ div.select-box-wrapper {
     display:inline-block;
 }
 
-#dashboard {
+#dashboard, #dashboard_e {
     margin-top: 1em;
     height: 3em;
 }
 
-#dashboard span.dash-align a {
+#dashboard span.dash-align a, #dashboard_e span.dash-align a {
     font-weight: bold;
     text-decoration: none;
 }
@@ -130,9 +130,9 @@ div.select-box-wrapper {
 
 #logout_link { left: 1px; }
 
-#dash_checked { color: [% css_colors.text_attention %]; }
-#dash_holds { color: [% css_colors.text_attention %]; }
-#dash_pickup { color: [% css_colors.text_goodnews %]; }
+#dash_checked, #dash_e_checked { color: [% css_colors.text_attention %]; }
+#dash_holds, #dash_e_holds { color: [% css_colors.text_attention %]; }
+#dash_pickup, #dash_e_pickup { color: [% css_colors.text_goodnews %]; }
 
 /*  
 #dash_fines { color: [% css_colors.text_badnews %]; }
diff --git a/Open-ILS/src/templates/opac/myopac/circ_history.tt2 b/Open-ILS/src/templates/opac/myopac/circ_history.tt2
index 7e6edc4..8e989c4 100644
--- a/Open-ILS/src/templates/opac/myopac/circ_history.tt2
+++ b/Open-ILS/src/templates/opac/myopac/circ_history.tt2
@@ -14,6 +14,11 @@
         <div class="align">
             <a href='[% mkurl('circs',{},1) %]'>[% l("Current Items Checked Out") %]</a>
         </div>
+        [%- IF ebook_api.enabled %]
+        <div class="align">
+            <a href="[% mkurl('ebook_circs',{},1) %]">[% l("E-Items Currently Checked Out") %]</a>
+        </div>
+        [%- END %]
         <div class="align selected">
             <a href="#">[% l("Check Out History") %]</a>
         </div>
diff --git a/Open-ILS/src/templates/opac/myopac/circs.tt2 b/Open-ILS/src/templates/opac/myopac/circs.tt2
index 4715925..bd93d7b 100644
--- a/Open-ILS/src/templates/opac/myopac/circs.tt2
+++ b/Open-ILS/src/templates/opac/myopac/circs.tt2
@@ -10,6 +10,11 @@
         <div class="align selected">
             <a href="#">[% l("Current Items Checked Out") %]</a>
         </div>
+        [%- IF ebook_api.enabled %]
+        <div class="align">
+            <a href="[% mkurl('ebook_circs',{},1) %]">[% l("E-Items Currently Checked Out") %]</a>
+        </div>
+        [%- END %]
         <div class="align">
             <a href="[% mkurl('circ_history',{},1) %]">[% l("Check Out History") %]</a>
         </div>
diff --git a/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
new file mode 100644
index 0000000..4a75f23
--- /dev/null
+++ b/Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
@@ -0,0 +1,42 @@
+[%  PROCESS "opac/parts/header.tt2";
+    PROCESS "opac/parts/misc_util.tt2";
+    PROCESS "opac/parts/myopac/column_sort_support.tt2";
+    WRAPPER "opac/parts/myopac/base.tt2";
+    myopac_page = "ebook_circs"  %]
+<h3 class="sr-only">[% l('E-Items Currently Checked Out') %]</h3>
+<div id='myopac_checked_div'>
+
+    <div id="acct_checked_tabs">
+        <div class="align">
+            <a href="[% mkurl('circ_history',{},1) %]">[% l("Current Items Checked Out") %]</a>
+        </div>
+        <div class="align selected">
+            <a href="#">[% l("E-Items Currently Checked Out") %]</a>
+        </div>
+        <div class="align">
+            <a href="[% mkurl('circ_history',{},1) %]">[% l("Check Out History") %]</a>
+        </div>
+    </div>
+
+    <div class="header_middle">
+        <span class="float-left">[% l('E-Items Currently Checked Out') %]</span>
+    </div>
+    <div class="clear-both"></div>
+    <div id="no_ebook_circs" class="warning_box hidden">[% l('You have no e-items checked out.') %]</div>
+    <div id='ebook_circs_main' class="hidden">
+        <table id="ebook_circs_main_table"
+            title="[% l('E-Items Currently Checked Out') %]"
+            class="table_no_border_space table_no_cell_pad item_list_padding">
+            <thead>
+            <tr>
+                <th>[% sort_head("sort_title", l("Title")) %]</th>
+                <th>[% sort_head("author", l("Author")) %]</th>
+                <th>[% l("Due Date") %]</th>
+                <th>[% l("Actions") %]</th>
+            </tr>
+            </thead>
+            <tbody id="ebook_circs_main_table_body"></tbody>
+        </table>
+    </div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2
new file mode 100644
index 0000000..f578a9f
--- /dev/null
+++ b/Open-ILS/src/templates/opac/myopac/ebook_holds.tt2
@@ -0,0 +1,51 @@
+[%  PROCESS "opac/parts/header.tt2";
+    PROCESS "opac/parts/misc_util.tt2";
+    PROCESS "opac/parts/hold_status.tt2";
+    PROCESS "opac/parts/myopac/column_sort_support.tt2";
+    WRAPPER "opac/parts/myopac/base.tt2";
+    myopac_page = "ebook_holds";
+    limit = (ctx.holds_limit.defined) ? ctx.holds_limit : 0;
+    offset = (ctx.holds_offset.defined) ? ctx.holds_offset : 0;
+    count = (ctx.holds_ids.size.defined) ? ctx.holds_ids.size : 0;
+%]
+<h3 class="sr-only">[% l('My E-Item Holds') %]</h3>
+<div id='myopac_holds_div'>
+
+    <div id="acct_holds_tabs">
+        <div class="align">
+            <a href='[% mkurl('holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Items on Hold") %]</a>
+        </div>
+        <div class="align selected">
+            <a href='#'>[% l("E-Items on Hold") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Holds History") %]</a>
+        </div>
+    </div>
+
+    <div class="header_middle">
+        <span class="float-left">[% l('E-Items on Hold') %]</span>
+    </div>
+    <div class="clear-both"></div>
+    <div id="no_ebook_holds" class="warning_box hidden">[% l('You have no e-item holds.') %]</div>
+    <div id='ebook_holds_main' class="hidden">
+        <table id="ebook_holds_main_table"
+            title="[% l('E-Items on Hold') %]"
+            class="table_no_border_space table_no_cell_pad item_list_padding">
+            <thead>
+            <tr>
+                <th>[% sort_head("sort_title", l("Title")) %]</th>
+                <th>[% sort_head("author", l("Author")) %]</th>
+                <th>[% l("Expire Date") %]</th>
+                <th>[% l("Status") %]</th>
+                <th>[% l("Actions") %]</th>
+            </tr>
+            </thead>
+            <tbody id="ebook_holds_main_table_body"></tbody>
+        </table>
+    </div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2 b/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2
new file mode 100644
index 0000000..b93bc95
--- /dev/null
+++ b/Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2
@@ -0,0 +1,50 @@
+[%  PROCESS "opac/parts/header.tt2";
+    PROCESS "opac/parts/misc_util.tt2";
+    PROCESS "opac/parts/hold_status.tt2";
+    PROCESS "opac/parts/myopac/column_sort_support.tt2";
+    WRAPPER "opac/parts/myopac/base.tt2";
+    myopac_page = "ebook_holds_ready";
+    limit = (ctx.holds_limit.defined) ? ctx.holds_limit : 0;
+    offset = (ctx.holds_offset.defined) ? ctx.holds_offset : 0;
+    count = (ctx.holds_ids.size.defined) ? ctx.holds_ids.size : 0;
+%]
+<h3 class="sr-only">[% l('E-Items Ready for Checkout') %]</h3>
+<div id='myopac_holds_div'>
+
+    <div id="acct_holds_tabs">
+        <div class="align">
+            <a href='[% mkurl('holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Items on Hold") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('ebook_holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items on Hold") %]</a>
+        </div>
+        <div class="align selected">
+            <a href='#'>[% l("E-Items Ready for Checkout") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Holds History") %]</a>
+        </div>
+    </div>
+
+    <div class="header_middle">
+        <span class="float-left">[% l('E-Items Ready for Checkout') %]</span>
+    </div>
+    <div class="clear-both"></div>
+    <div id="no_ebook_holds" class="warning_box hidden">[% l('You have no e-item holds ready to be checked out.') %]</div>
+    <div id='ebook_holds_main' class="hidden">
+        <table id="ebook_holds_main_table"
+            title="[% l('E-Items Ready for Checkout') %]"
+            class="table_no_border_space table_no_cell_pad item_list_padding">
+            <thead>
+            <tr>
+                <th>[% sort_head("sort_title", l("Title")) %]</th>
+                <th>[% sort_head("author", l("Author")) %]</th>
+                <th>[% l("Expire Date") %]</th>
+                <th>[% l("Actions") %]</th>
+            </tr>
+            </thead>
+            <tbody id="ebook_holds_main_table_body"></tbody>
+        </table>
+    </div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/opac/myopac/hold_history.tt2 b/Open-ILS/src/templates/opac/myopac/hold_history.tt2
index 9905e6c..16b4c02 100644
--- a/Open-ILS/src/templates/opac/myopac/hold_history.tt2
+++ b/Open-ILS/src/templates/opac/myopac/hold_history.tt2
@@ -15,6 +15,14 @@
         <div class="align">
             <a href='[% mkurl('holds',{},['limit','offset']) %]'>[% l("Items on Hold") %]</a>
         </div>
+        [% IF ebook_api.enabled %]
+        <div class="align">
+            <a href='[% mkurl('ebook_holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items on Hold") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
+        </div>
+        [% END %]
         <div class="align selected">
             <a href="#">[% l("Holds History") %]</a>
         </div>
diff --git a/Open-ILS/src/templates/opac/myopac/holds.tt2 b/Open-ILS/src/templates/opac/myopac/holds.tt2
index 7fc808c..1ba5c9c 100644
--- a/Open-ILS/src/templates/opac/myopac/holds.tt2
+++ b/Open-ILS/src/templates/opac/myopac/holds.tt2
@@ -15,6 +15,14 @@
         <div class="align selected">
             <a href='#'>[% l("Items on Hold") %]</a>
         </div>
+        [% IF ebook_api.enabled %]
+        <div class="align">
+            <a href='[% mkurl('ebook_holds', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items on Hold") %]</a>
+        </div>
+        <div class="align">
+            <a href='[% mkurl('ebook_holds_ready', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("E-Items Ready for Checkout") %]</a>
+        </div>
+        [% END %]
         <div class="align">
             <a href='[% mkurl('hold_history', {}, ['limit','offset','available','sort','sort_type']) %]'>[% l("Holds History") %]</a>
         </div>
diff --git a/Open-ILS/src/templates/opac/parts/config.tt2 b/Open-ILS/src/templates/opac/parts/config.tt2
index 4e0e3b9..3af7bb7 100644
--- a/Open-ILS/src/templates/opac/parts/config.tt2
+++ b/Open-ILS/src/templates/opac/parts/config.tt2
@@ -51,6 +51,17 @@ google_analytics.enabled = 'false';
 google_analytics.code = 'UA-9999999-99';
 
 ##############################################################################
+# Ebook API integration
+##############################################################################
+ebook_api.enabled = 'false';
+ebook_api.ebook_test.enabled = 'false';
+ebook_api.ebook_test.base_uris = [ 'http://example.com/ebookapi/t/' ];
+ebook_api.oneclickdigital.enabled = 'false';
+ebook_api.oneclickdigital.base_uris = [ 'http://example.oneclickdigital.com/Products/ProductDetail.aspx' ];
+ebook_api.overdrive.enabled = 'false';
+ebook_api.overdrive.base_uris = [ 'http://elm.lib.overdrive.com/' ];
+
+##############################################################################
 # Enable "Forgot your password?" prompt at login
 ##############################################################################
 reset_password = 'true';
diff --git a/Open-ILS/src/templates/opac/parts/ebook_api/avail.tt2 b/Open-ILS/src/templates/opac/parts/ebook_api/avail.tt2
new file mode 100644
index 0000000..f75a951
--- /dev/null
+++ b/Open-ILS/src/templates/opac/parts/ebook_api/avail.tt2
@@ -0,0 +1,49 @@
+[%-
+# Display holdings/availability info from ebook API
+#
+# We require the following info:
+# - rec_id: internal ID for this record (rec.id in search results, ctx.bre_id in record summary)
+# - ebook_id: external ID for title (ISBN for OneClickdigital, unique identifier for OverDrive)
+# - vendor (oneclickdigital, overdrive)
+
+IF args.ebook_test_id;
+    ebook.ebook_id = args.ebook_test_id;
+    ebook.vendor = 'ebook_test';
+ELSIF args.oneclickdigital_id;
+    ebook.ebook_id = args.oneclickdigital_id;
+    ebook.vendor = 'oneclickdigital';
+ELSIF args.overdrive_id;
+    ebook.ebook_id = args.overdrive_id;
+    ebook.vendor = 'overdrive';
+END;
+
+IF ebook.ebook_id;
+
+    IF ctx.page == 'rresult';
+        ebook.rec_id = rec.id;
+    ELSE;
+        ebook.rec_id = ctx.bre_id;
+    END;
+
+# This div is hidden by default. The JS layer will unhide it, use the ebook_id
+# to retrieve holdings/availability info via the appropriate vendor API, and
+# overwrite the div's contents with that information.
+-%]
+<div id="[% ebook.rec_id %]" class="ebook_avail hidden">
+    <div id="[% ebook.ebook_id %]" class="[% ebook.vendor %]_avail">
+        <table id="[% ebook.rec_id %]_ebook_holdings" class="result_holdings_table hidden">
+            <thead>
+                <tr>
+                    <th>[% l('Available Formats') %]</th>
+                    <th>[% l('Status') %]</th>
+                </tr>
+            <tbody>
+                <tr>
+                    <td id="[% ebook.rec_id %]_formats"></td>
+                    <td id="[% ebook.rec_id %]_status"></td>
+                </tr>
+            </tbody>
+        </table>
+    </div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/opac/parts/ebook_api/avail_js.tt2 b/Open-ILS/src/templates/opac/parts/ebook_api/avail_js.tt2
new file mode 100644
index 0000000..f70f115
--- /dev/null
+++ b/Open-ILS/src/templates/opac/parts/ebook_api/avail_js.tt2
@@ -0,0 +1,49 @@
+<script type="text/javascript">
+dojo.addOnLoad(function() {
+
+    // detect ebooks on current page for each vendor
+    dojo.forEach(vendor_list, function(v) {
+        var vendor = new Vendor(v);
+        var ebook_nodes = dojo.query("." + v + "_avail");
+        console.log('found ' + ebook_nodes.length + ' ebooks on this page');
+
+        // we have ebooks for this vendor, so let's get availability info etc.
+        if (ebook_nodes.length > 0) {
+            checkSession(v, function(v,ses) {
+                ebook_nodes.forEach(function(node) {
+                    var ebook = new Ebook(v, node.getAttribute("id"));
+                    ebook.rec_id = node.parentNode.getAttribute("id");
+                    vendor.ebooks.push(ebook);
+
+                    ebook.getHoldings( function(holdings) {
+                        if (typeof holdings.available !== 'undefined') {
+                            var avail = holdings.available;
+                            if (avail == 1) {
+                                node.innerHTML = 'This title is available online.';
+                            } else if (avail == 0) {
+                                node.innerHTML = 'This title is not currently available.';
+                            } else {
+                                console.log(ebook.id + ' has bad availability: ' + avail);
+                            }
+                        } else {
+                            if (holdings.formats.length > 0) {
+                                var formats_ul = dojo.create("ul", null, ebook.rec_id + '_formats');
+                                dojo.forEach(holdings.formats, function(f) {
+                                    dojo.create("li", { innerHTML: f }, formats_ul);
+                                });
+                                var status_node = dojo.byId(ebook.rec_id + '_status');
+                                var status_str = holdings.copies_available + ' of ' + holdings.copies_owned + ' available';
+                                status_node.innerHTML = status_str;
+                                dojo.removeClass(ebook.rec_id + '_ebook_holdings', "hidden");
+                            }
+                        }
+                        // unhide holdings/availability info now that it's populated
+                        removeClass(node.parentNode, "hidden");
+                    });
+                });
+            });
+        }
+    });
+
+});
+</script>
diff --git a/Open-ILS/src/templates/opac/parts/ebook_api/base_js.tt2 b/Open-ILS/src/templates/opac/parts/ebook_api/base_js.tt2
new file mode 100644
index 0000000..632e534
--- /dev/null
+++ b/Open-ILS/src/templates/opac/parts/ebook_api/base_js.tt2
@@ -0,0 +1,71 @@
+[%
+# HTML display chunks
+progress_icon = '<img id="ebook_avail_spinner" src="/opac/images/progressbar_green.gif" alt="' _ l("Checking availability for this item...") _ '"/>'
+%]
+
+<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/opac/ebook_api/session.js"></script>
+<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/opac/ebook_api/ebook.js"></script>
+<script type="text/javascript">
+
+// translatable strings as JS variables
+var l_strings = {};
+l_strings.download = '[% l('Download') %]';
+l_strings.ready_for_checkout = '[% l('Ready for Checkout') %]';
+l_strings.suspended = '[% l('Suspended') %]';
+
+// give us cookies!
+dojo.require("dojo.cookie");
+
+// context org unit
+[% IF !ctx.page OR ctx.page != 'rresult';
+    PROCESS get_library;
+END %]
+var ou = [% loc_value %];
+
+// list of enabled vendors
+var vendor_list = [];
+[% IF ebook_api.ebook_test.enabled == 'true' %]
+vendor_list.push('ebook_test');
+[% END %]
+[% IF ebook_api.oneclickdigital.enabled == 'true' %]
+vendor_list.push('oneclickdigital');
+[% END %]
+[% IF ebook_api.overdrive.enabled == 'true' %]
+vendor_list.push('overdrive');
+[% END %]
+
+var cookie_registry = [ 'ebook_xact_cache' ];
+dojo.forEach(vendor_list, function(v) {
+    cookie_registry.push(v);
+});
+
+[% IF ctx.user %]
+// user- or login-specific vars
+var authtoken = '[% ctx.authtoken %]';
+var patron_id = '[% ctx.active_card %]'; // using barcode of active card as patron ID
+
+var myopac_page;
+[% IF myopac_page %]
+myopac_page = "[% myopac_page %]";
+[% END %]
+
+[% END %]
+
+// enforce removal of ebook API cookies on logout
+dojo.addOnLoad(function() {
+    var logout_handle = dojo.connect(dojo.byId('#logout_link'), 'onclick', function() {
+        dojo.forEach(cookie_registry, function(cookie) {
+            dojo.cookie(cookie, '', {path: '/', expires: '-1h'});
+        });
+        // When we switch to jQuery, use .one()
+        // instead of dojo's .connect() and .disconnect()
+        dojo.disconnect(logout_handle);
+    });
+});
+</script>
+
+[%- IF ctx.user %]
+<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/opac/ebook_api/relation.js"></script>
+<script type="text/javascript" src="[% ctx.media_prefix %]/js/ui/default/opac/ebook_api/loggedin.js"></script>
+[%- END %]
+
diff --git a/Open-ILS/src/templates/opac/parts/ebook_api/login_js.tt2 b/Open-ILS/src/templates/opac/parts/ebook_api/login_js.tt2
new file mode 100644
index 0000000..23ed256
--- /dev/null
+++ b/Open-ILS/src/templates/opac/parts/ebook_api/login_js.tt2
@@ -0,0 +1,41 @@
+<script type="text/javascript">
+var vendors_requiring_password = [];
+
+[% IF !loc_value; PROCESS get_library; END; %]
+[% IF ebook_api.overdrive.enabled == 'true'
+    AND loc_value
+    AND ctx.get_org_setting(loc_value, 'ebook_api.overdrive.password_required') %]
+vendors_requiring_password.push('overdrive');
+[% END %]
+
+dojo.addOnLoad(function() {
+    var handle = dojo.connect(dojo.byId('#login-form-box'), 'onclick', function(evt) {
+        // disconnect this event since it's one-time-only
+        // (when we switch to jQuery, we can use .one() here)
+        dojo.disconnect(handle);
+
+        // we cache the username (and password) for now, but will
+        // replace that with the patron's active barcode later
+        vendors_requiring_password.forEach(function(v) {
+            if (vendor_list.includes(v)) {
+                checkSession(v, function(v,ses) {
+                    var username = dojo.byId('#username_field').value;
+                    var password = dojo.byId('#password_field').value;
+                    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+                        method: 'open-ils.ebook_api.patron.cache_password',
+                        params: [ ses, password ],
+                        async: true,
+                        oncomplete: function(r) {
+                            var resp = r.recv();
+                            if (resp) {
+                                console.log('patron password has been cached');
+                                return;
+                            }
+                        }
+                    }).send();
+                });
+            }
+        });
+    });
+});
+</script>
diff --git a/Open-ILS/src/templates/opac/parts/header.tt2 b/Open-ILS/src/templates/opac/parts/header.tt2
index 8b60ba9..5b34c16 100644
--- a/Open-ILS/src/templates/opac/parts/header.tt2
+++ b/Open-ILS/src/templates/opac/parts/header.tt2
@@ -124,6 +124,10 @@
         want_dojo = 1;
     END;
 
+    IF ebook_api.enabled == 'true';
+        want_dojo = 1;
+    END;
+
     # Especially useful for image 'alt' tags and link title tags,
     # where the content may need to be unique (making it longer)
     # but should not exceed 75 chars for ideal screen reader support.
diff --git a/Open-ILS/src/templates/opac/parts/js.tt2 b/Open-ILS/src/templates/opac/parts/js.tt2
index 5cfdebe..245af43 100644
--- a/Open-ILS/src/templates/opac/parts/js.tt2
+++ b/Open-ILS/src/templates/opac/parts/js.tt2
@@ -107,6 +107,12 @@
     src="[% ctx.media_prefix %]/js/ui/default/opac/copyloc.js"></script>
 [% END %]
 
+[% IF ebook_api.enabled == 'true' %]
+    [% INCLUDE "opac/parts/ebook_api/base_js.tt2" %]
+    [% INCLUDE "opac/parts/ebook_api/avail_js.tt2" IF (ctx.page == 'rresult' OR ctx.page == 'record') %]
+    [% INCLUDE "opac/parts/ebook_api/login_js.tt2" IF (ctx.page == 'login') %]
+[% END %]
+
 <!-- provide a JS friendly org unit hash -->
 <script type="text/javascript">
 var aou_hash = {
diff --git a/Open-ILS/src/templates/opac/parts/misc_util.tt2 b/Open-ILS/src/templates/opac/parts/misc_util.tt2
index d7ab819..69ca1b4 100644
--- a/Open-ILS/src/templates/opac/parts/misc_util.tt2
+++ b/Open-ILS/src/templates/opac/parts/misc_util.tt2
@@ -407,6 +407,17 @@
             END;
         END;
 
+        IF ebook_api.overdrive.enabled == 'true';
+            FOR marc037 IN xml.findnodes('//*[@tag="037"]');
+                marc037_id = marc037.findnodes('./*[@code="a"]').textContent;
+                marc037_source = marc037.findnodes('./*[@code="b"]').textContent;
+                IF marc037_source.match('OverDrive') AND marc037_id;
+                    args.overdrive_id = marc037_id;
+                    LAST;
+                END;
+            END;
+        END;
+
         # Extract the 856 URLs that are not otherwise represented by asset.uri's
         args.online_res = [];
         FOR node IN xml.findnodes('//*[@tag="856" and @ind1="4" and (@ind2="0" or @ind2="1")]');
@@ -503,7 +514,46 @@
                         res.note = '';
                     END;
                     args.uris.push(res);
+
+                    IF ebook_api.ebook_test.enabled == 'true';
+                        IF !args.ebook_test_id;
+                            FOR base_uri IN ebook_api.ebook_test.base_uris;
+                                IF res.href.match(base_uri);
+                                    args.ebook_test_id = res.href.remove(base_uri);
+                                    LAST;
+                                END;
+                            END;
+                        END;
+                    END;
+
+                    IF ebook_api.oneclickdigital.enabled == 'true';
+                        # A record might conceivably have multiple OneClickdigital URIs,
+                        # but we use (the same) ISBN as the ebook ID in each case.
+                        IF !args.oneclickdigital_id;
+                            FOR base_uri IN ebook_api.oneclickdigital.base_uris;
+                                IF res.href.match(base_uri);
+                                    # found a OneClickdigital URI, let's grab our ID and move on
+                                    args.oneclickdigital_id = clean_isbn;
+                                    LAST;
+                                END;
+                            END;
+                        END;
+                    END;
+
+                    IF ebook_api.overdrive.enabled == 'true';
+                        # Ideally we already have an OverDrive record ID from MARC 037 (see above).
+                        # But for older records, it will be embedded in the URI in MARC 856.
+                        IF !args.overdrive_id;
+                            FOR base_uri IN ebook_api.overdrive.base_uris;
+                                IF res.href.match(base_uri);
+                                    args.overdrive_id = res.href.remove('^.*/ContentDetails.htm\?ID=');
+                                    LAST;
+                                END;
+                            END;
+                        END;
+                    END;
                 END;
+
                 NEXT;
             ELSE;
                 copies = volume.findnodes('./*[local-name()="copies"]/*[local-name()="copy"]');
diff --git a/Open-ILS/src/templates/opac/parts/myopac/main_base.tt2 b/Open-ILS/src/templates/opac/parts/myopac/main_base.tt2
index 16ad59b..705ba02 100644
--- a/Open-ILS/src/templates/opac/parts/myopac/main_base.tt2
+++ b/Open-ILS/src/templates/opac/parts/myopac/main_base.tt2
@@ -49,7 +49,13 @@
                     <td class='td-right'>
                         <a href="[% mkurl(ctx.opac_root _ '/myopac/circs') %]"
                             title="[% l('View My Checked Out Items') %]">
-                            [% l("View All") %]
+                            [% l("Items Currently Checked out ([_1])", ctx.user_stats.checkouts.total_out) %]
+                        </a>
+                    </td>
+                    <td class="td-right hidden" id="acct_sum_ebook_circs">
+                        <a href="[% mkurl(ctx.opac_root _ '/myopac/circs?e_items') %]"
+                            title="[% l('View My Checked Out E-Items') %]">
+                            [% l("E-Items Currently Checked out") %] (<span id="acct_sum_ebook_circ_total">-</span>)
                         </a>
                     </td>
                 </tr>
@@ -58,7 +64,13 @@
                     <td class='td-right'>
                         <a href="[% mkurl(ctx.opac_root _ '/myopac/holds') %]"
                             title="[% l('View My Holds') %]">
-                            [% l('View All') %]
+                            [% l('Items Currently on Hold ([_1])', ctx.user_stats.holds.total) %]
+                        </a>
+                    </td>
+                    <td class="td-right hidden" id="acct_sum_ebook_holds">
+                        <a href="[% mkurl(ctx.opac_root _ '/myopac/holds?e_items') %]"
+                            title="[% l('View My E-Items On Hold') %]">
+                            [% l("E-Items Currently on Hold") %] (<span id="acct_sum_ebook_hold_total">-</span>)
                         </a>
                     </td>
                 </tr>
@@ -67,7 +79,13 @@
                     <td class='td-right'>
                         <a href="[% mkurl(ctx.opac_root _ '/myopac/holds', {available => 1}) %]"
                             title="[% l('View My Holds Ready for Pickup') %]">
-                            [% l('View All') %]
+                            [% l('Items ready for pickup ([_1])', ctx.user_stats.holds.ready) %]
+                        </a>
+                    </td>
+                    <td class="td-right hidden" id="acct_sum_ebook_holds_ready">
+                        <a href="[% mkurl(ctx.opac_root _ '/myopac/holds?e_items&available=1') %]"
+                            title="[% l('View My E-Items Ready for Pickup') %]">
+                            [% l("E-Items ready for pickup") %] (<span id="acct_sum_ebook_hold_ready_total">-</span>)
                         </a>
                     </td>
                 </tr>
diff --git a/Open-ILS/src/templates/opac/parts/record/summary.tt2 b/Open-ILS/src/templates/opac/parts/record/summary.tt2
index ff822d6..b125b03 100644
--- a/Open-ILS/src/templates/opac/parts/record/summary.tt2
+++ b/Open-ILS/src/templates/opac/parts/record/summary.tt2
@@ -186,6 +186,11 @@ IF num_uris > 0;
     [%- END %]
     [%- IF num_uris > 1 %]</ul>[% END %]
 </div>
+[%
+IF ebook_api.enabled == 'true';
+    INCLUDE "opac/parts/ebook_api/avail.tt2";
+END;
+%]
 [%- END %]
 <div id="copy_hold_counts">
 [%-
diff --git a/Open-ILS/src/templates/opac/parts/result/table.tt2 b/Open-ILS/src/templates/opac/parts/result/table.tt2
index 5f3cbc3..ddfeacb 100644
--- a/Open-ILS/src/templates/opac/parts/result/table.tt2
+++ b/Open-ILS/src/templates/opac/parts/result/table.tt2
@@ -381,6 +381,11 @@ END;
                                                              %]
                                                         [% END %] <!-- END detail_record_view -->
                                                     </table>
+                                                    [% 
+                                                        IF ebook_api.enabled == 'true';
+                                                            INCLUDE "opac/parts/ebook_api/avail.tt2";
+                                                        END;
+                                                    %]
                                                     [% PROCESS "opac/parts/result/copy_counts.tt2" %]
                                                     [% IF rec.user_circulated %]
                                                     <div class="result_item_circulated">
diff --git a/Open-ILS/src/templates/opac/parts/topnav.tt2 b/Open-ILS/src/templates/opac/parts/topnav.tt2
index 719ffa1..fc12a35 100644
--- a/Open-ILS/src/templates/opac/parts/topnav.tt2
+++ b/Open-ILS/src/templates/opac/parts/topnav.tt2
@@ -69,6 +69,22 @@
                         %]</span> [% l("Fines") %]</a>
                 </span>
             </div>
+            <div id="dashboard_e" class="hidden">
+                <span class="dash-align">
+                    <a class="dash-link" href="[% mkurl(ctx.opac_root _ '/myopac/ebook_circs')
+                        %]"><span id="dash_e_checked">-</span> [% l("E-Items Checked Out") %]</a>
+                </span>
+                <span class="dash_divider">|</span>
+                <span class="dash-align">
+                    <a class="dash-link" href="[% mkurl(ctx.opac_root _ '/myopac/ebook_holds')
+                        %]"><span id="dash_e_holds">-</span> [% l("E-Items on Hold") %]</a>
+                </span>
+                <span class="dash_divider">|</span>
+                <span class="dash-align">
+                    <a class="dash-link" href="[% mkurl(ctx.opac_root _ '/myopac/ebook_holds_ready')
+                        %]"><span id="dash_e_pickup">-</span> [% l("E-Items Ready for Checkout") %]</a>
+                </span>
+            </div>
         </div>
         [% END %]
     </div>
diff --git a/Open-ILS/web/js/ui/default/opac/ebook_api/ebook.js b/Open-ILS/web/js/ui/default/opac/ebook_api/ebook.js
new file mode 100644
index 0000000..f933b69
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/opac/ebook_api/ebook.js
@@ -0,0 +1,48 @@
+// define our classes
+function Vendor(name) {
+    this.name = name;
+    this.ebooks = [];
+}
+
+function Ebook(vendor, id) {
+    this.vendor = vendor;
+    this.id = id; // external ID for this title
+    this.rec_id;  // bre.id for this title's MARC record
+    this.avail;   // availability info for this title
+    this.holdings = {}; // holdings info
+}
+
+Ebook.prototype.getAvailability = function(callback) {
+    var ses = dojo.cookie(this.vendor);
+    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+        method: 'open-ils.ebook_api.title.availability',
+        params: [ ses, this.id ],
+        async: true,
+        oncomplete: function(r) {
+            var resp = r.recv();
+            if (resp) {
+                console.log('availability response: ' + resp.content());
+                this.avail = resp.content();
+                return callback(resp.content());
+            }
+        }
+    }).send();
+}
+
+Ebook.prototype.getHoldings = function(callback) {
+    var ses = dojo.cookie(this.vendor);
+    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+        method: 'open-ils.ebook_api.title.holdings',
+        params: [ ses, this.id ],
+        async: true,
+        oncomplete: function(r) {
+            var resp = r.recv();
+            if (resp) {
+                console.log('holdings response: ' + resp.content());
+                this.holdings = resp.content();
+                return callback(resp.content());
+            }
+        }
+    }).send();
+}
+
diff --git a/Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js b/Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js
new file mode 100644
index 0000000..725d2e6
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js
@@ -0,0 +1,221 @@
+/*
+ * variables defined in base_js.tt2:
+ *
+ * ou
+ * vendor_list = [ 'ebook_test' ]
+ * authtoken
+ * patron_id (barcode)
+ * myopac_page
+ * progress_icon (probably not done right)
+ *
+ * base_js.tt2 also "imports" dojo.cookie and a bunch of ebook_api JS
+ */
+
+// Array of objects representing this patron's relationship with a specific vendor.
+var relations = [];
+
+// Transaction cache.
+var xacts = {
+    checkouts: [],
+    holds_pending: [],
+    holds_ready: []
+};
+
+dojo.addOnLoad(function() {
+
+    dojo.forEach(vendor_list, function(v) {
+        var rel = new Relation(v, patron_id);
+        relations.push(rel);
+    });
+
+    // Pull patron transaction info from cache (cookie), if available.
+    // Otherwise, do a live lookup against all enabled vendors.
+    if (dojo.cookie('ebook_xact_cache')) {
+        getCachedTransactions();
+        addTotalsToPage();
+        addTransactionsToPage();
+    } else {
+        console.log('retrieving patron transaction info for all vendors');
+        dojo.forEach(relations, function(rel) {
+            checkSession(rel.vendor, function(ses) {
+                rel.getTransactions( function(r) {
+                    addTransactionsToCache(r);
+                });
+            });
+        });
+    }
+
+});
+
+// Update current page with cross-vendor transaction totals.
+function addTotalsToPage() {
+    console.log('updating page with transaction totals');
+    updateDashboard();
+    updateMyAccountSummary();
+}
+
+// Update current page with detailed transaction info, where appropriate.
+function addTransactionsToPage() {
+    if (myopac_page) {
+        console.log('updating page with cached transaction details, if applicable');
+        if (myopac_page === 'ebook_circs')
+            updateCheckoutView();
+        if (myopac_page === 'ebook_holds')
+            updateHoldView();
+        if (myopac_page === 'ebook_holds_ready')
+            updateHoldReadyView();
+    }
+}
+        
+function updateDashboard() {
+    console.log('updating dashboard');
+    var total_checkouts = (typeof xacts.checkouts === 'undefined') ? '-' : xacts.checkouts.length;
+    var total_holds_pending = (typeof xacts.holds_pending === 'undefined') ? '-' : xacts.holds_pending.length;
+    var total_holds_ready = (typeof xacts.holds_ready === 'undefined') ? '-' : xacts.holds_ready.length;
+    // update totals
+    dojo.byId('dash_e_checked').innerHTML = total_checkouts;
+    dojo.byId('dash_e_holds').innerHTML = total_holds_pending;
+    dojo.byId('dash_e_pickup').innerHTML = total_holds_ready;
+    // unhide ebook dashboard
+    dojo.removeClass('dashboard_e', "hidden");
+}
+
+function updateMyAccountSummary() {
+    if (myopac_page === 'main') {
+        console.log('updating account summary');
+        var total_checkouts = (typeof xacts.checkouts === 'undefined') ? '-' : xacts.checkouts.length;
+        var total_holds_pending = (typeof xacts.holds_pending === 'undefined') ? '-' : xacts.holds_pending.length;
+        var total_holds_ready = (typeof xacts.holds_ready === 'undefined') ? '-' : xacts.holds_ready.length;
+        // update totals
+        dojo.byId('acct_sum_ebook_circ_total').innerHTML = total_checkouts;
+        dojo.byId('acct_sum_ebook_hold_total').innerHTML = total_holds_pending;
+        dojo.byId('acct_sum_ebook_hold_ready_total').innerHTML = total_holds_ready;
+        // unhide display elements
+        dojo.removeClass('acct_sum_ebook_circs', "hidden");
+        dojo.removeClass('acct_sum_ebook_holds', "hidden");
+        dojo.removeClass('acct_sum_ebook_holds_ready', "hidden");
+    }
+}
+
+function updateCheckoutView() {
+    if (xacts.checkouts.length < 1) {
+        dojo.removeClass('no_ebook_circs', "hidden");
+    } else {
+        dojo.forEach(xacts.checkouts, function(x) {
+            var dl_link = '<a href="' + x.download_url + '">' + l_strings.download + '</a>';
+            var tr = dojo.create("tr", null, dojo.byId('ebook_circs_main_table_body'));
+            dojo.create("td", { innerHTML: x.title }, tr);
+            dojo.create("td", { innerHTML: x.author }, tr);
+            dojo.create("td", { innerHTML: x.due_date }, tr);
+            dojo.create("td", { innerHTML: dl_link}, tr);
+            // TODO: more actions (renew, checkin)
+        });
+        dojo.addClass('no_ebook_circs', "hidden");
+        dojo.removeClass('ebook_circs_main', "hidden");
+    }
+}
+
+function updateHoldView() {
+    var holds_pending = xacts.holds_pending;
+    var holds_ready = xacts.holds_ready;
+
+    // combine all holds into a single list, ready-for-checkout holds first
+    var holds = holds_ready.concat(holds_pending);
+
+    if (holds.length < 1) {
+        dojo.removeClass('no_ebook_holds', "hidden");
+    } else {
+        dojo.forEach(holds, function(h) {
+            var hold_status;
+            if (h.is_ready) {
+                hold_status = l_strings.ready_for_checkout;
+            } else if (h.is_frozen) {
+                hold_status = l_strings.suspended;
+            } else {
+                hold_status = h.queue_position + ' / ' + h.queue_size;
+            }
+            var tr = dojo.create("tr", null, dojo.byId('ebook_holds_main_table_body'));
+            dojo.create("td", { innerHTML: h.title }, tr);
+            dojo.create("td", { innerHTML: h.author }, tr);
+            dojo.create("td", { innerHTML: h.expire_date }, tr);
+            dojo.create("td", { innerHTML: hold_status }, tr);
+            dojo.create("td", null, tr); // TODO actions
+        });
+        dojo.addClass('no_ebook_holds', "hidden");
+        dojo.removeClass('ebook_holds_main', "hidden");
+    }
+}
+
+function updateHoldReadyView() {
+    var holds = xacts.holds_ready;
+    if (holds.length < 1) {
+        dojo.removeClass('no_ebook_holds', "hidden");
+    } else {
+        dojo.forEach(holds, function(h) {
+            var tr = dojo.create("tr", null, dojo.byId('ebook_holds_main_table_body'));
+            dojo.create("td", { innerHTML: h.title }, tr);
+            dojo.create("td", { innerHTML: h.author }, tr);
+            dojo.create("td", { innerHTML: h.expire_date }, tr);
+            dojo.create("td", null, tr); // TODO actions
+        });
+        dojo.addClass('no_ebook_holds', "hidden");
+        dojo.removeClass('ebook_holds_main', "hidden");
+    }
+}
+
+// deserialize transactions from cache, returning them as a JS object
+function getCachedTransactions() {
+    console.log('retrieving cached transaction details');
+    var cache_obj;
+    var current_cache = dojo.cookie('ebook_xact_cache');
+    if (current_cache) {
+        cache_obj = JSON.parse(current_cache);
+        xacts.checkouts = cache_obj.checkouts;
+        xacts.holds_pending = cache_obj.holds_pending;
+        xacts.holds_ready = cache_obj.holds_ready;
+    }
+    return cache_obj;
+}
+
+// add a single vendor's transactions to transaction cache
+function addTransactionsToCache(rel) {
+    console.log('updating transaction cache');
+    var v = rel.vendor;
+    var updated_xacts = {
+        checkouts: [],
+        holds_pending: [],
+        holds_ready: []
+    };
+    // preserve any transactions with other vendors
+    dojo.forEach(xacts.checkouts, function(xact) {
+        if (xact.vendor !== v)
+            updated_xacts.checkouts.push(xact);
+    });
+    dojo.forEach(xacts.holds_pending, function(xact) {
+        if (xact.vendor !== v)
+            updated_xacts.holds_pending.push(xact);
+    });
+    dojo.forEach(xacts.holds_ready, function(xact) {
+        if (xact.vendor !== v)
+            updated_xacts.holds_ready.push(xact);
+    });
+    // add transactions from current vendor
+    dojo.forEach(rel.checkouts, function(xact) {
+        updated_xacts.checkouts.push(xact);
+    });
+    dojo.forEach(rel.holds_pending, function(xact) {
+        updated_xacts.holds_pending.push(xact);
+    });
+    dojo.forEach(rel.holds_ready, function(xact) {
+        updated_xacts.holds_ready.push(xact);
+    });
+    // TODO sort transactions by date
+    // save transactions to cache
+    xacts = updated_xacts;
+    var new_cache = JSON.stringify(xacts);
+    dojo.cookie('ebook_xact_cache', new_cache, {path: '/'});
+    // update current page
+    addTotalsToPage();
+    addTransactionsToPage();
+}
+
diff --git a/Open-ILS/web/js/ui/default/opac/ebook_api/relation.js b/Open-ILS/web/js/ui/default/opac/ebook_api/relation.js
new file mode 100644
index 0000000..d423f33
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/opac/ebook_api/relation.js
@@ -0,0 +1,80 @@
+function Relation(vendor, patron_id) {
+    this.vendor = vendor;
+    this.patron_id = patron_id;
+    this.checkouts = [];
+    this.holds_pending = [];
+    this.holds_ready = [];
+}
+
+Relation.prototype.getCheckouts = function(callback) {
+    var ses = dojo.cookie(this.vendor);
+    var rel = this;
+    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+        method: 'open-ils.ebook_api.patron.get_checkouts',
+        params: [ authtoken, ses, rel.patron_id ],
+        async: false,
+        oncomplete: function(r) {
+            var resp = r.recv();
+            if (resp) {
+                console.log('retrieved checkouts for patron');
+                rel.checkouts = [];
+                dojo.forEach(resp.content(), function(checkout) {
+                    rel.checkouts.push(checkout);
+                });
+                return callback(rel);
+            }
+        }
+    }).send();
+}
+
+Relation.prototype.getHolds = function(callback) {
+    var ses = dojo.cookie(this.vendor);
+    var rel = this;
+    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+        method: 'open-ils.ebook_api.patron.get_holds',
+        params: [ authtoken, ses, rel.patron_id ],
+        async: false,
+        oncomplete: function(r) {
+            var resp = r.recv();
+            if (resp) {
+                console.log('retrieved holds for patron');
+                dojo.forEach(resp.content(), function(hold) {
+                    if (hold.is_ready === 1) {
+                        rel.holds_ready.push(hold);
+                    } else {
+                        rel.holds_pending.push(hold);
+                    }
+                });
+                return callback(rel);
+            }
+        }
+    }).send();
+}
+
+Relation.prototype.getTransactions = function(callback) {
+    var ses = dojo.cookie(this.vendor);
+    var rel = this;
+    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+        method: 'open-ils.ebook_api.patron.get_transactions',
+        params: [ authtoken, ses, rel.patron_id ],
+        async: false,
+        oncomplete: function(r) {
+            var resp = r.recv();
+            if (resp) {
+                console.log('retrieved holds for patron');
+                var xacts = resp.content();
+                dojo.forEach(xacts.checkouts, function(checkout) {
+                    rel.checkouts.push(checkout);
+                });
+                dojo.forEach(xacts.holds, function(hold) {
+                    if (hold.is_ready === 1) {
+                        rel.holds_ready.push(hold);
+                    } else {
+                        rel.holds_pending.push(hold);
+                    }
+                });
+                return callback(rel);
+            }
+        }
+    }).send();
+}
diff --git a/Open-ILS/web/js/ui/default/opac/ebook_api/session.js b/Open-ILS/web/js/ui/default/opac/ebook_api/session.js
new file mode 100644
index 0000000..914abe6
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/opac/ebook_api/session.js
@@ -0,0 +1,40 @@
+// initialize an API session
+// XXX Are there any cases where checkSession does not suffice for this?
+function startSession(vendor, callback) {
+    console.log('starting ebook API session for ' + vendor);
+    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+        method: 'open-ils.ebook_api.start_session',
+        params: [ vendor, ou ],
+        async: false, // XXX
+        oncomplete: function(r) {
+            var resp = r.recv();
+            if (resp) {
+                var ses = resp.content();
+                dojo.cookie(vendor, ses, {path: '/'});
+                return callback(vendor,ses);
+            }
+        }
+    }).send();
+}
+
+// validate or initialize API session
+// (check_session method will fallback to start_session if no session ID is provided)
+function checkSession(vendor, callback) {
+    var ses = dojo.cookie(vendor) || null;
+    if (ses == null)
+        return startSession(vendor,callback);
+    console.log('checking ebook API session for ' + vendor);
+    new OpenSRF.ClientSession('open-ils.ebook_api').request({
+        method: 'open-ils.ebook_api.check_session',
+        params: [ ses, vendor, ou ],
+        async: false, // XXX
+        oncomplete: function(r) {
+            var resp = r.recv();
+            if (resp) {
+                var new_ses = resp.content();
+                dojo.cookie(vendor, new_ses, {path: '/'});
+                return callback(vendor,new_ses);
+            }
+        }
+    }).send();
+}

commit 556d1ceb81ff25ce9aa6c0938de1b6ff84a36871
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Tue Feb 7 15:27:48 2017 -0800

    LP#1541559: ebook API handler for OneClickdigital
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/MANIFEST b/Open-ILS/src/perlmods/MANIFEST
index d3f1deb..594e944 100644
--- a/Open-ILS/src/perlmods/MANIFEST
+++ b/Open-ILS/src/perlmods/MANIFEST
@@ -42,6 +42,7 @@ lib/OpenILS/Application/Collections.pm
 lib/OpenILS/Application/EbookAPI.pm
 lib/OpenILS/Application/EbookAPI/Test.pm
 lib/OpenILS/Application/EbookAPI/OverDrive.pm
+lib/OpenILS/Application/EbookAPI/OneClickdigital.pm
 lib/OpenILS/Application/Fielder.pm
 lib/OpenILS/Application/PermaCrud.pm
 lib/OpenILS/Application/Proxy.pm
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OneClickdigital.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OneClickdigital.pm
new file mode 100644
index 0000000..093b657
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OneClickdigital.pm
@@ -0,0 +1,305 @@
+#!/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.
+
+package OpenILS::Application::EbookAPI::OneClickdigital;
+
+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 Data::Dumper;
+
+sub new {
+    my( $class, $args ) = @_;
+    $class = ref $class || $class;
+    return bless $args, $class;
+}
+
+sub ou {
+    my $self = shift;
+    return $self->{ou};
+}
+
+sub vendor {
+    my $self = shift;
+    return $self->{vendor};
+}
+
+sub session_id {
+    my $self = shift;
+    return $self->{session_id};
+}
+
+sub base_uri {
+    my $self = shift;
+    return $self->{base_uri};
+}
+
+sub library_id {
+    my $self = shift;
+    return $self->{library_id};
+}
+
+sub basic_token {
+    my $self = shift;
+    return $self->{basic_token};
+}
+
+sub patron_id {
+    my $self = shift;
+    return $self->{patron_id};
+}
+
+sub initialize {
+    my $self = shift;
+    my $ou = $self->{ou};
+
+    $self->{base_uri} = 'https://api.oneclickdigital.us/v1'; # TODO pull from org setting?
+
+    my $library_id = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.oneclickdigital.library_id');
+    if ($library_id) {
+        $self->{library_id} = $library_id;
+    } else {
+        $logger->error("EbookAPI: no OneClickdigital library ID found for org unit $ou");
+        return;
+    }
+
+    my $basic_token = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.oneclickdigital.basic_token');
+    if ($basic_token) {
+        $self->{basic_token} = $basic_token;
+    } else {
+        $logger->error("EbookAPI: no OneClickdigital basic token found for org unit $ou");
+        return;
+    }
+
+    return $self;
+
+}
+
+# OneClickdigital API does not require separate client auth;
+# we just need to include our basic auth token in requests
+sub do_client_auth {
+    my $self = shift;
+    return;
+}
+
+# retrieve OneClickdigital patron ID (if any) based on patron barcode
+# GET http://api.oneclickdigital.us/v1/rpc/libraries/{libraryID}/patrons/{barcode}
+sub do_patron_auth {
+    my ($self, $barcode) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'GET',
+        uri    => "$base_uri/rpc/libraries/$library_id/patrons/$barcode"
+    };
+    my $res = $self->request($req, $session_id);
+    # TODO distinguish between unregistered patrons and patron auth failure
+    if (defined ($res) && $res->{content}->{patronId}) {
+        return $res->{content}->{patronId};
+    }
+    $logger->error("EbookAPI: no OneClickdigital patron ID found for barcode $barcode");
+    return;
+}
+
+# does this title have available "copies"? y/n
+# GET http://api.oneclickdigital.us/v1/libraries/{libraryID}/media/{isbn}/availability
+sub do_availability_lookup {
+    my ($self, $isbn) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'GET',
+        uri    => "$base_uri/libraries/$library_id/media/$isbn/availability"
+    };
+    my $res = $self->request($req, $session_id);
+    if (defined ($res)) {
+        $logger->info("EbookAPI: received availability response for ISBN $isbn: " . Dumper $res);
+        return $res->{content}->{availability};
+    } else {
+        $logger->error("EbookAPI: could not retrieve OneClickdigital availability for ISBN $isbn");
+        return;
+    }
+}
+
+# OneClickdigital API does not support detailed holdings lookup,
+# so we return basic availability information.
+sub do_holdings_lookup {
+    my ($self, $isbn) = @_;
+    my $avail = $self->do_availability_lookup($isbn);
+    return { available => $avail };
+}
+
+# checkout an item to a patron
+# item is identified by ISBN, patron ID is their barcode
+# POST //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/checkouts/{isbn}
+sub checkout {
+    my ($self, $isbn, $patron_id) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'POST',
+        uri    => "$base_uri/libraries/$library_id/patrons/$patron_id/checkouts/$isbn"
+    };
+    my $res = $self->request($req, $session_id);
+
+    # TODO: more sophisticated response handling
+    # HTTP 200 response indicates success, HTTP 409 indicates checkout limit reached
+    if (defined ($res)) {
+        if ($res->{is_success}) {
+            return {
+                xact_id => $res->{content}->{transactionId},
+                due_date => $res->{content}->{expiration}
+            };
+        } else {
+            $logger->error("EbookAPI: checkout failed for OneClickdigital title $isbn");
+            return { error_msg => $res->{content} };
+        }
+    } else {
+        $logger->error("EbookAPI: no response received from OneClickdigital server");
+        return;
+    }
+}
+
+# renew a checked-out item
+# item id = ISBN, patron id = barcode
+# PUT //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/checkouts/{isbn}
+sub renew {
+    my ($self, $isbn, $patron_id) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'PUT',
+        uri    => "$base_uri/libraries/$library_id/patrons/$patron_id/checkouts/$isbn"
+    };
+    my $res = $self->request($req, $session_id);
+
+    # TODO: more sophisticated response handling
+    # HTTP 200 response indicates success
+    if (defined ($res)) {
+        if ($res->{is_success}) {
+            return {
+                xact_id => $res->{content}->{transactionId},
+                due_date => $res->{content}->{expiration}
+            };
+        } else {
+            $logger->error("EbookAPI: renewal failed for OneClickdigital title $isbn");
+            return { error_msg => $res->{content} };
+        }
+    } else {
+        $logger->error("EbookAPI: no response received from OneClickdigital server");
+        return;
+    }
+}
+
+# checkin a checked-out item
+# item id = ISBN, patron id = barcode
+# XXX API docs indicate that a bearer token is required!
+# DELETE //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/checkouts/{isbn}
+sub checkin {
+}
+
+sub place_hold {
+}
+
+sub cancel_hold {
+}
+
+# GET //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/checkouts/all
+sub get_patron_checkouts {
+    my ($self, $patron_id) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'GET',
+        uri    => "$base_uri/libraries/$library_id/patrons/$patron_id/checkouts/all"
+    };
+    my $res = $self->request($req, $session_id);
+
+    my $checkouts = [];
+    if (defined ($res)) {
+        $logger->info("EbookAPI: received response for OneClickdigital checkouts: " . Dumper $res);
+        foreach my $checkout (@{$res->{content}}) {
+            push @$checkouts, {
+                xact_id => $checkout->{transactionID},
+                title_id => $checkout->{isbn},
+                due_date => $checkout->{expiration},
+                download_url => $checkout->{downloadURL},
+                title => $checkout->{title},
+                author => $checkout->{authors}
+            };
+        };
+        $logger->info("EbookAPI: retrieved " . scalar(@$checkouts) . " OneClickdigital checkouts for patron $patron_id");
+        $self->{checkouts} = $checkouts;
+        return $self->{checkouts};
+    } else {
+        $logger->error("EbookAPI: failed to retrieve OneClickdigital checkouts for patron $patron_id");
+        return;
+    }
+}
+
+# GET //api.{domain}/v1/libraries/{libraryId}/patrons/{patronId}/holds/all
+sub get_patron_holds {
+    my ($self, $patron_id) = @_;
+    my $base_uri = $self->{base_uri};
+    my $library_id = $self->{library_id};
+    my $session_id = $self->{session_id};
+    my $req = {
+        method => 'GET',
+        uri    => "$base_uri/libraries/$library_id/patrons/$patron_id/holds/all"
+    };
+    my $res = $self->request($req, $session_id);
+
+    my $holds = [];
+    if (defined ($res)) {
+        $logger->info("EbookAPI: received response for OneClickdigital holds: " . Dumper $res);
+        foreach my $hold (@{$res->{content}}) {
+            push @$holds, {
+                xact_id => $hold->{transactionID},
+                title_id => $hold->{isbn},
+                expire_date => $hold->{expiration},
+                title => $hold->{title},
+                author => $hold->{authors},
+                # XXX queue position/size and pending vs ready info not available via API
+                queue_position => '-',
+                queue_size => '-',
+                is_ready => 0
+            };
+        };
+        $logger->info("EbookAPI: retrieved " . scalar(@$holds) . " OneClickdigital holds for patron $patron_id");
+        $self->{holds} = $holds;
+        return $self->{holds};
+    } else {
+        $logger->error("EbookAPI: failed to retrieve OneClickdigital holds for patron $patron_id");
+        return;
+    }
+}
+
diff --git a/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t b/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
index 44a1d42..d5f5121 100644
--- a/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
+++ b/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
@@ -1,10 +1,11 @@
 #!perl -T
 
-use Test::More tests => 3;
+use Test::More tests => 4;
 
 BEGIN {
     use_ok( 'OpenILS::Application::EbookAPI' );
     use_ok( 'OpenILS::Application::EbookAPI::Test' );
     use_ok( 'OpenILS::Application::EbookAPI::OverDrive' );
+    use_ok( 'OpenILS::Application::EbookAPI::OneClickdigital' );
 }
 
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index 08f8877..bdd70d7 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -16800,3 +16800,39 @@ VALUES (
     'ebook_api',
     'bool'
 );
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.oneclickdigital.library_id',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'OneClickdigital Library ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'Identifier assigned to this library by OneClickdigital',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.oneclickdigital.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'OneClickdigital Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'Basic token for client authentication with OneClickdigital API (supplied by OneClickdigital)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+);
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-oneclickdigital.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-oneclickdigital.sql
new file mode 100644
index 0000000..754dac0
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-oneclickdigital.sql
@@ -0,0 +1,42 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.oneclickdigital.library_id',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'OneClickdigital Library ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.library_id',
+        'Identifier assigned to this library by OneClickdigital',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.oneclickdigital.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'OneClickdigital Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.oneclickdigital.basic_token',
+        'Basic token for client authentication with OneClickdigital API (supplied by OneClickdigital)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+);
+
+COMMIT;
+

commit ff070c185d341a2e554c4df982388fcbe5fd9c03
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Tue Feb 7 15:23:12 2017 -0800

    LP#1541559: ebook API handler for OverDrive
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/MANIFEST b/Open-ILS/src/perlmods/MANIFEST
index 8de8df8..d3f1deb 100644
--- a/Open-ILS/src/perlmods/MANIFEST
+++ b/Open-ILS/src/perlmods/MANIFEST
@@ -41,6 +41,7 @@ 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/EbookAPI/OverDrive.pm
 lib/OpenILS/Application/Fielder.pm
 lib/OpenILS/Application/PermaCrud.pm
 lib/OpenILS/Application/Proxy.pm
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
new file mode 100644
index 0000000..6bb5be5
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
@@ -0,0 +1,561 @@
+#!/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.
+
+package OpenILS::Application::EbookAPI::OverDrive;
+
+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 Data::Dumper;
+
+sub new {
+    my( $class, $args ) = @_;
+    $class = ref $class || $class;
+    return bless $args, $class;
+}
+
+sub ou {
+    my $self = shift;
+    return $self->{ou};
+}
+
+sub vendor {
+    my $self = shift;
+    return $self->{vendor};
+}
+
+sub session_id {
+    my $self = shift;
+    return $self->{session_id};
+}
+
+sub account_id {
+    my $self = shift;
+    return $self->{account_id};
+}
+
+sub websiteid {
+    my $self = shift;
+    return $self->{websiteid};
+}
+
+sub authorizationname {
+    my $self = shift;
+    return $self->{authorizationname};
+}
+
+sub basic_token {
+    my $self = shift;
+    return $self->{basic_token};
+}
+
+sub bearer_token {
+    my $self = shift;
+    return $self->{bearer_token};
+}
+
+sub collection_token {
+    my $self = shift;
+    return $self->{collection_token};
+}
+
+sub granted_auth_uri {
+    my $self = shift;
+    return $self->{granted_auth_uri};
+}
+
+sub password_required {
+    my $self = shift;
+    return $self->{password_required};
+}
+
+sub patron_token {
+    my $self = shift;
+    return $self->{patron_token};
+}
+
+sub initialize {
+    my $self = shift;
+    my $ou = $self->{ou};
+
+    my $discovery_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.discovery_base_uri');
+    $self->{discovery_base_uri} = $discovery_base_uri || 'http://api.overdrive.com/v1';
+    my $circulation_base_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.circulation_base_uri');
+    $self->{circulation_base_uri} = $circulation_base_uri || 'http://patron.api.overdrive.com/v1';
+
+    my $account_id = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.account_id');
+    if ($account_id) {
+        $self->{account_id} = $account_id;
+    } else {
+        $logger->error("EbookAPI: no OverDrive account ID found for org unit $ou");
+        return;
+    }
+
+    my $websiteid = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.websiteid');
+    if ($websiteid) {
+        $self->{websiteid} = $websiteid;
+    } else {
+        $logger->error("EbookAPI: no OverDrive website ID found for org unit $ou");
+        return;
+    }
+
+    my $authorizationname = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.authorizationname');
+    if ($authorizationname) {
+        $self->{authorizationname} = $authorizationname;
+    } else {
+        $logger->error("EbookAPI: no OverDrive authorization name found for org unit $ou");
+        return;
+    }
+
+    my $basic_token = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.basic_token');
+    if ($basic_token) {
+        $self->{basic_token} = $basic_token;
+    } else {
+        $logger->error("EbookAPI: no OverDrive basic token found for org unit $ou");
+        return;
+    }
+
+    my $granted_auth_uri = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.granted_auth_redirect_uri');
+    if ($granted_auth_uri) {
+        $self->{granted_auth_uri} = $granted_auth_uri;
+    }
+
+    my $password_required = OpenILS::Application::AppUtils->ou_ancestor_setting_value($ou, 'ebook_api.overdrive.password_required') || 0;
+    $self->{password_required} = $password_required;
+
+    return $self;
+
+}
+
+# Wrapper method for HTTP requests.
+sub handle_http_request {
+    my $self = shift;
+    my $req = shift;
+
+    # Prep our request using defaults.
+    $req->{method} = 'GET' if (!$req->{method});
+    $req = $self->set_http_headers($req);
+
+    # Send the request.
+    my $res = $self->request($req, $self->{session_id});
+
+    $logger->info("EbookAPI: raw OverDrive HTTP response: " . Dumper $res);
+
+    # A "401 Unauthorized" response means we need to re-auth our client or patron.
+    if (defined ($res) && $res->{status} =~ /^401/) {
+        $logger->info("EbookAPI: 401 response received from OverDrive, re-authorizing...");
+
+        # Always re-auth client to ensure we have an up-to-date client token.
+        $self->do_client_auth();
+
+        # If we're using a Circulation API, redo patron auth too.
+        my $circulation_base_uri = $self->{circulation_base_uri};
+        if ($req->{uri} =~ /^$circulation_base_uri/) {
+            $self->do_patron_auth();
+        }
+
+        # Now we can update our headers with our fresh client/patron tokens
+        # and re-send our request.
+        $req = $self->set_http_headers($req);
+        return $self->request($req, $self->{session_id});
+    }
+
+    # For any non-401 response (including no response at all),
+    # just return whatever response we got (if any).
+    return $res;
+}
+
+# Set the correct headers for our request.
+# Authorization headers are determined by which API we're using:
+# - Circulation APIs use a patron access token.
+# - Discovery APIs use a regular access token.
+# - For other APIs, fallback to our basic token.
+sub set_http_headers {
+    my $self = shift;
+    my $req = shift;
+    $req->{headers} = {} if (!$req->{headers});
+    if (!$req->{headers}->{Authorization}) {
+        my $auth_type;
+        my $token;
+        my $circulation_base_uri = $self->{circulation_base_uri};
+        my $discovery_base_uri = $self->{discovery_base_uri};
+        if ($req->{uri} =~ /^$circulation_base_uri/) {
+            $auth_type = 'Bearer';
+            $token = $self->{patron_token};
+        } elsif ($req->{uri} =~ /^$discovery_base_uri/) {
+            $auth_type = 'Bearer';
+            $token = $self->{bearer_token};
+        } else {
+            $auth_type = 'Basic';
+            $token = $self->{basic_token};
+        }
+        if (!$token) {
+            $logger->error("EbookAPI: unable to set HTTP Authorization header without token");
+            $logger->error("EbookAPI: failed request: " . Dumper $req);
+            return;
+        } else {
+            $req->{headers}->{Authorization} = "$auth_type $token";
+        }
+    }
+    return $req;
+}
+
+# POST /token HTTP/1.1
+# Host: oauth.overdrive.com
+# Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
+# 
+# grant_type=client_credentials
+sub do_client_auth {
+    my $self = shift;
+    my $req = {
+        method  => 'POST',
+        uri     => 'https://oauth.overdrive.com/token',
+        headers => {
+            'Authorization' => 'Basic ' . $self->{basic_token},
+            'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
+        },
+        content => 'grant_type=client_credentials'
+    };
+    my $res = $self->request($req, $self->{session_id});
+
+    if (defined ($res)) {
+        if ($res->{content}->{access_token}) {
+            # save our access token for future use
+            $self->{bearer_token} = $res->{content}->{access_token};
+            # use access token to grab other library info (e.g. collection token)
+            $self->get_library_info();
+            return $res;
+        } else {
+            $logger->error("EbookAPI: bearer token not received from OverDrive API");
+            $logger->error("EbookAPI: bad response: " . Dumper $res);
+        }
+    } else {
+        $logger->error("EbookAPI: no client authentication response from OverDrive API");
+    }
+    return;
+}
+
+sub do_patron_auth {
+    my $self = shift;
+    my @args = @_;
+    if ($self->{granted_auth_uri}) {
+        return $self->do_granted_patron_auth(@args);
+    } else {
+        return $self->do_basic_patron_auth(@args);
+    }
+}
+
+# TODO
+sub do_granted_patron_auth {
+}
+
+# POST /patrontoken HTTP/1.1
+# Host: oauth-patron.overdrive.com
+# Authorization: Basic {Base64-encoded string}
+# Content-Type: application/x-www-form-urlencoded;charset=UTF-8
+# 
+# grant_type=password&username=1234567890&password=1234&scope=websiteid:12345 authorizationname:default
+# OR:
+# grant_type=password&username=1234567890&password=[ignore]&password_required=false&scope=websiteid:12345 authorizationname:default
+sub do_basic_patron_auth {
+    my $self = shift;
+    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};
+        }
+    }
+
+    # TODO handle cached/expired tokens?
+    # Making a request using an expired token will give a 401 Unauthorized error.
+    # Handle this appropriately.
+
+    # request content is an ugly url-encoded string
+    my $pw = (defined $self->{patron_password}) ? $self->{patron_password} : '';
+    my $content = 'grant_type=password';
+    $content .= "&username=$barcode";
+    if ($self->{password_required}) {
+        $content .= "&password=$pw";
+    } else {
+        $content .= '&password=xxx&password_required=false'
+    }
+    $content .= '&scope=websiteid:' . $self->{websiteid} . ' authorizationname:' . $self->{authorizationname};
+
+    my $req = {
+        method  => 'POST',
+        uri     => 'https://oauth-patron.overdrive.com/patrontoken',
+        headers => {
+            'Authorization' => 'Basic ' . $self->{basic_token},
+            'Content-Type'  => 'application/x-www-form-urlencoded;charset=UTF-8'
+        },
+        content => $content
+    };
+    my $res = $self->request($req, $self->{session_id});
+
+    if (defined ($res)) {
+        if ($res->{content}->{access_token}) {
+            $self->{patron_token} = $res->{content}->{access_token};
+            return $self->{patron_token};
+        } else {
+            $logger->error("EbookAPI: patron access token not received from OverDrive API");
+        }
+    } else {
+        $logger->error("EbookAPI: no patron authentication response from OverDrive API");
+    }
+    return;
+}
+
+# GET http://api.overdrive.com/v1/libraries/1225
+# User-Agent: {Your application}
+# Authorization: Bearer {OAuth access token}
+# Host: api.overdrive.com
+sub get_library_info {
+    my $self = shift;
+    my $library_id = $self->{account_id};
+    my $req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/libraries/$library_id"
+    };
+    if (my $res = $self->handle_http_request($req, $self->{session_id})) {
+        $self->{collection_token} = $res->{content}->{collectionToken};
+        return $self->{collection_token};
+    } else {
+        $logger->error("EbookAPI: OverDrive Library Account API request failed");
+        return;
+    }
+}
+
+# GET http://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
+# User-Agent: {Your application}
+# Authorization: Bearer {OAuth access token}
+# Host: api.overdrive.com
+sub get_title_info {
+    my $self = shift;
+    my $title_id = shift;
+    $self->do_client_auth() if (!$self->{bearer_token});
+    $self->get_library_info() if (!$self->{collection_token});
+    my $collection_token = $self->{collection_token};
+    my $req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
+    };
+    if (my $res = $self->handle_http_request($req, $self->{session_id})) {
+        if ($res->{content}->{title}) {
+            return {
+                title  => $res->{content}->{title},
+                author => $res->{content}->{creators}[0]{name}
+            };
+        } else {
+            $logger->error("EbookAPI: OverDrive metadata lookup failed for $title_id");
+        }
+    } else {
+        $logger->error("EbookAPI: no metadata response from OverDrive API");
+    }
+    return;
+}
+
+# GET http://api.overdrive.com/v1/collections/L1BAAEAAA2i/products/76C1B7D0-17F4-4C05-8397-C66C17411584/availability
+# User-Agent: {Your application}
+# Authorization: Bearer {OAuth access token}
+# Host: api.overdrive.com
+sub do_availability_lookup {
+    my $self = shift;
+    my $title_id = shift;
+    $self->do_client_auth() if (!$self->{bearer_token});
+    $self->get_library_info() if (!$self->{collection_token});
+    my $req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/collections/" . $self->{collection_token} . "/products/$title_id/availability"
+    };
+    if (my $res = $self->handle_http_request($req, $self->{session_id})) {
+        return $res->{content}->{available};
+    } else {
+        $logger->error("EbookAPI: could not retrieve OverDrive availability for title $title_id");
+        return;
+    }
+}
+
+# Holdings lookup has two parts:
+#
+# 1. Copy availability: as above, but grab more details.
+#
+# 2. Formats:
+#     GET https://api.overdrive.com/v1/collections/v1L1BYwAAAA2Q/products/76c1b7d0-17f4-4c05-8397-c66c17411584/metadata
+#     User-Agent: {Your application}
+#     Authorization: Bearer {OAuth access token}
+#     Host: api.overdrive.com
+#
+sub do_holdings_lookup {
+    my ($self, $title_id) = @_;
+    $self->do_client_auth() if (!$self->{bearer_token});
+    $self->get_library_info() if (!$self->{collection_token});
+    my $collection_token = $self->{collection_token};
+
+    # prepare data structure to be used as return value
+    my $holdings = {
+        copies_owned => 0,
+        copies_available => 0,
+        formats => []
+    };
+
+    # request copy availability totals
+    my $avail_req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/availability"
+    };
+    if (my $avail_res = $self->handle_http_request($avail_req, $self->{session_id})) {
+        $holdings->{copies_owned} = $avail_res->{content}->{copiesOwned};
+        $holdings->{copies_available} = $avail_res->{content}->{copiesAvailable};
+    } else {
+        $logger->error("EbookAPI: failed to retrieve OverDrive holdings counts for title $title_id");
+    }
+
+    # request available formats
+    my $format_req = {
+        method  => 'GET',
+        uri     => $self->{discovery_base_uri} . "/collections/$collection_token/products/$title_id/metadata"
+    };
+    if (my $format_res = $self->handle_http_request($format_req, $self->{session_id})) {
+        if ($format_res->{content}->{formats}) {
+            foreach my $f (@{$format_res->{content}->{formats}}) {
+                push @{$holdings->{formats}}, $f->{name};
+            }
+        } else {
+            $logger->info("EbookAPI: OverDrive holdings format request for title $title_id contained no format information");
+        }
+    } else {
+        $logger->error("EbookAPI: failed to retrieve OverDrive holdings formats for title $title_id");
+    }
+
+    return $holdings;
+}
+
+# List of patron checkouts:
+# GET http://patron.api.overdrive.com/v1/patrons/me/checkouts
+# User-Agent: {Your application}
+# Authorization: Bearer {OAuth patron access token}
+# Host: patron.api.overdrive.com
+#
+# Response looks like this:
+# {
+#     "totalItems": 4,
+#     "totalCheckouts": 2,
+#     "checkouts": [
+#         {
+#             "reserveId": "A03EAC2C-C088-46C6-B9E9-59D6C11A3596",
+#             "expires": "2015-08-11T18:53:00Z",
+#             ...
+#         }
+#     ],
+#     ...
+# }
+#
+# To get title metadata (e.g. title/author), do get_title_info(reserveId).
+sub get_patron_checkouts {
+    my $self = shift;
+    my $patron_token = shift;
+    if (my $res = $self->do_get_patron_xacts('checkouts', $patron_token)) {
+        my $checkouts = [];
+        foreach my $checkout (@{$res->{content}->{checkouts}}) {
+            my $title_id = $checkout->{reserveId};
+            my $title_info = $self->get_title_info($title_id);
+            # TODO get download URL - need to "lock in" a format first, see OD Checkouts API docs
+            push @$checkouts, {
+                title_id => $title_id,
+                due_date => $checkout->{expires},
+                title => $title_info->{title},
+                author => $title_info->{author}
+            }
+        };
+        $self->{checkouts} = $checkouts;
+        return $self->{checkouts};
+    } else {
+        $logger->error("EbookAPI: unable to retrieve OverDrive checkouts for patron " . $self->{patron_barcode});
+        return;
+    }
+}
+
+sub get_patron_holds {
+    my $self = shift;
+    my $patron_token = shift;
+    if (my $res = $self->do_get_patron_xacts('holds', $patron_token)) {
+        my $holds = [];
+        foreach my $hold (@{$res->{content}->{holds}}) {
+            my $title_id = $hold->{reserveId};
+            my $title_info = $self->get_title_info($title_id);
+            my $this_hold = {
+                title_id => $title_id,
+                queue_position => $hold->{holdListPosition},
+                queue_size => $hold->{numberOfHolds},
+                # TODO: special handling for ready-to-checkout holds
+                is_ready => ( $hold->{actions}->{checkout} ) ? 1 : 0,
+                create_date => $hold->{holdPlacedDate},
+                expire_date => ( $hold->{holdExpires} ) ? $hold->{holdExpires} : '-',
+                title => $title_info->{title},
+                author => $title_info->{author}
+            };
+            # TODO: hold suspensions
+            push @$holds, $this_hold;
+        }
+        $self->{holds} = $holds;
+        return $self->{holds};
+    } else {
+        $logger->error("EbookAPI: unable to retrieve OverDrive holds for patron " . $self->{patron_barcode});
+        return;
+    }
+}
+
+# generic function for retrieving patron transactions
+sub do_get_patron_xacts {
+    my $self = shift;
+    my $xact_type = shift;
+    my $patron_token = shift;
+    if (!$patron_token) {
+        if ($self->{patron_barcode}) {
+            $self->do_client_auth() if (!$self->{bearer_token});
+            $self->do_patron_auth();
+        } else {
+            $logger->error("EbookAPI: Cannot retrieve OverDrive $xact_type with no patron information");
+        }
+    }
+    my $req = {
+        method  => 'GET',
+        uri     => $self->{circulation_base_uri} . "/patrons/me/$xact_type"
+    };
+    return $self->handle_http_request($req, $self->{session_id});
+}
+
diff --git a/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t b/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
index 338f043..44a1d42 100644
--- a/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
+++ b/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
@@ -1,9 +1,10 @@
 #!perl -T
 
-use Test::More tests => 2;
+use Test::More tests => 3;
 
 BEGIN {
     use_ok( 'OpenILS::Application::EbookAPI' );
     use_ok( 'OpenILS::Application::EbookAPI::Test' );
+    use_ok( 'OpenILS::Application::EbookAPI::OverDrive' );
 }
 
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index d294365..08f8877 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -16666,3 +16666,137 @@ INSERT INTO config.global_flag (name, label, value, enabled) VALUES (
     TRUE
 );
 
+INSERT INTO config.settings_group (name, label)
+    VALUES ('ebook_api', 'Ebook API Integration');
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.overdrive.discovery_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'OverDrive Discovery API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'Base URI for OverDrive Discovery API (defaults to http://api.overdrive.com/v1)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.circulation_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'OverDrive Circulation API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'Base URI for OverDrive Circulation API (defaults to http://patron.api.overdrive.com/v1)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.account_id',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'OverDrive Account ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'Account ID (a.k.a. Library ID) for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.websiteid',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'OverDrive Website ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'Website ID for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.authorizationname',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'OverDrive Authorization Name',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'Authorization name for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'OverDrive Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'Basic token for client authentication with OverDrive API (supplied by OverDrive)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.granted_auth_redirect_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'OverDrive Granted Authorization Redirect URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'URI provided to OverDrive for use with granted authorization',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.password_required',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'OverDrive Password Required',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'Does this library require a password when authenticating patrons with the OverDrive API?',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'bool'
+);
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-overdrive.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-overdrive.sql
new file mode 100644
index 0000000..7e10262
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-overdrive.sql
@@ -0,0 +1,141 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+INSERT INTO config.settings_group (name, label)
+    VALUES ('ebook_api', 'Ebook API Integration');
+
+INSERT INTO config.org_unit_setting_type
+    (name, label, description, grp, datatype) 
+VALUES (
+    'ebook_api.overdrive.discovery_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'OverDrive Discovery API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.discovery_base_uri',
+        'Base URI for OverDrive Discovery API (defaults to http://api.overdrive.com/v1)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.circulation_base_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'OverDrive Circulation API Base URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.circulation_base_uri',
+        'Base URI for OverDrive Circulation API (defaults to http://patron.api.overdrive.com/v1)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.account_id',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'OverDrive Account ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.account_id',
+        'Account ID (a.k.a. Library ID) for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.websiteid',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'OverDrive Website ID',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.websiteid',
+        'Website ID for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.authorizationname',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'OverDrive Authorization Name',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.authorizationname',
+        'Authorization name for this library, as assigned by OverDrive',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.basic_token',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'OverDrive Basic Token',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.basic_token',
+        'Basic token for client authentication with OverDrive API (supplied by OverDrive)',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.granted_auth_redirect_uri',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'OverDrive Granted Authorization Redirect URI',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.granted_auth_redirect_uri',
+        'URI provided to OverDrive for use with granted authorization',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'string'
+),(
+    'ebook_api.overdrive.password_required',
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'OverDrive Password Required',
+        'coust',
+        'label'
+    ),
+    oils_i18n_gettext(
+        'ebook_api.overdrive.password_required',
+        'Does this library require a password when authenticating patrons with the OverDrive API?',
+        'coust',
+        'description'
+    ),
+    'ebook_api',
+    'bool'
+);
+
+COMMIT;
+

commit 0dde1ead0ac55bbd7576665cde29a8c40d62f411
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Tue Feb 7 15:11:59 2017 -0800

    LP#1541559: ebook API sample MARC records for test module
    
    This commit adds a small number of MARC records to the existing sample
    data.  Each record contains a scoped URI in the 856 field.  The records
    are intended to be usable with the OpenILS::Application::EbookAPI::Test
    module.
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/tests/datasets/sql/bibs_ebook_api.sql b/Open-ILS/tests/datasets/sql/bibs_ebook_api.sql
new file mode 100644
index 0000000..1a47fe6
--- /dev/null
+++ b/Open-ILS/tests/datasets/sql/bibs_ebook_api.sql
@@ -0,0 +1,8 @@
+\set bib_tag '''IMPORT EBOOK_API'''
+
+INSERT INTO marcxml_import (tag, marc) VALUES
+(:bib_tag,'<record    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"    xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"    xmlns="http://www.loc.gov/MARC21/slim"><leader>00620nam a22      i 4500</leader><controlfield tag="005">20161210000052.0</controlfield><controlfield tag="008">070101s                o           eng d</controlfield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">SITKA</subfield><subfield code="b">eng</subfield><subfield code="e">rda</subfield><subfield code="c">SITKA</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973,</subfield><subfield code="e">author.</subfield></datafield><datafield tag="245" ind1="1" ind2=" "><subfield code="a">The fellowship of the ring /</subfield><subfield code="c">by J.R.R. Tolkien.</subfield></datafield><datafield ta
 g="250" ind1=" " ind2=" "><subfield code="a">2nd ed.</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">text</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">computer</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">online resource</subfield><subfield code="2">rdacarrier</subfield></datafield><datafield tag="490" ind1="1" ind2=" "><subfield code="a">The Lord of the rings / J.R.R. Tolkien ;</subfield><subfield code="v">pt. 1</subfield></datafield><datafield tag="800" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973.</subfield><subfield code="t">Lord of the rings</subfield></datafield><datafield tag="856" ind1="4" ind2="0"><subfield code="u">http://example.com/ebookapi/t/001</subfield><subfield code="y">Click to acces
 s online</subfield><subfield code="9">CONS</subfield></datafield></record>'),
+(:bib_tag,'<record    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"    xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"    xmlns="http://www.loc.gov/MARC21/slim"><leader>00620nam a22      i 4500</leader><controlfield tag="005">20161210000052.0</controlfield><controlfield tag="008">070101s                o           eng d</controlfield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">SITKA</subfield><subfield code="b">eng</subfield><subfield code="e">rda</subfield><subfield code="c">SITKA</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973,</subfield><subfield code="e">author.</subfield></datafield><datafield tag="245" ind1="1" ind2=" "><subfield code="a">The two towers /</subfield><subfield code="c">by J.R.R. Tolkien.</subfield></datafield><datafield tag="250" ind1
 =" " ind2=" "><subfield code="a">2nd ed.</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">text</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">computer</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">online resource</subfield><subfield code="2">rdacarrier</subfield></datafield><datafield tag="490" ind1="1" ind2=" "><subfield code="a">The Lord of the rings / J.R.R. Tolkien ;</subfield><subfield code="v">pt. 2</subfield></datafield><datafield tag="800" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973.</subfield><subfield code="t">Lord of the rings</subfield></datafield><datafield tag="856" ind1="4" ind2="0"><subfield code="u">http://example.com/ebookapi/t/002</subfield><subfield code="y">Click to access online</su
 bfield><subfield code="9">CONS</subfield></datafield></record>'),
+(:bib_tag,'<record    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"    xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"    xmlns="http://www.loc.gov/MARC21/slim"><leader>00620nam a22      i 4500</leader><controlfield tag="005">20161210000052.0</controlfield><controlfield tag="008">070101s                o           eng d</controlfield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">SITKA</subfield><subfield code="b">eng</subfield><subfield code="e">rda</subfield><subfield code="c">SITKA</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973,</subfield><subfield code="e">author.</subfield></datafield><datafield tag="245" ind1="1" ind2=" "><subfield code="a">The return of the king /</subfield><subfield code="c">by J.R.R. Tolkien.</subfield></datafield><datafield tag="2
 50" ind1=" " ind2=" "><subfield code="a">2nd ed.</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">text</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">computer</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">online resource</subfield><subfield code="2">rdacarrier</subfield></datafield><datafield tag="490" ind1="1" ind2=" "><subfield code="a">The Lord of the rings / J.R.R. Tolkien ;</subfield><subfield code="v">pt. 3</subfield></datafield><datafield tag="800" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973.</subfield><subfield code="t">Lord of the rings</subfield></datafield><datafield tag="856" ind1="4" ind2="0"><subfield code="u">http://example.com/ebookapi/t/003</subfield><subfield code="y">Click to access on
 line</subfield><subfield code="9">CONS</subfield></datafield></record>'),
+(:bib_tag,'<record    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"    xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"    xmlns="http://www.loc.gov/MARC21/slim"><leader>00620nam a22      i 4500</leader><controlfield tag="005">20161210000052.0</controlfield><controlfield tag="008">070101s                o           eng d</controlfield><datafield tag="040" ind1=" " ind2=" "><subfield code="a">SITKA</subfield><subfield code="b">eng</subfield><subfield code="e">rda</subfield><subfield code="c">SITKA</subfield></datafield><datafield tag="100" ind1="1" ind2=" "><subfield code="a">Tolkien, J. R. R.</subfield><subfield code="q">(John Ronald Reuel),</subfield><subfield code="d">1892-1973,</subfield><subfield code="e">author.</subfield></datafield><datafield tag="245" ind1="1" ind2=" "><subfield code="a">The hobbit, or, There and back again /</subfield><subfield code="c">by J.R.R. Tolkien.</subfield></datafield><da
 tafield tag="250" ind1=" " ind2=" "><subfield code="a">New ed.</subfield></datafield><datafield tag="336" ind1=" " ind2=" "><subfield code="a">text</subfield><subfield code="2">rdacontent</subfield></datafield><datafield tag="337" ind1=" " ind2=" "><subfield code="a">computer</subfield><subfield code="2">rdamedia</subfield></datafield><datafield tag="338" ind1=" " ind2=" "><subfield code="a">online resource</subfield><subfield code="2">rdacarrier</subfield></datafield><datafield tag="856" ind1="4" ind2="0"><subfield code="u">http://example.com/ebookapi/t/004</subfield><subfield code="y">Click to access online</subfield><subfield code="9">CONS</subfield></datafield></record>')
+;
diff --git a/Open-ILS/tests/datasets/sql/load_all.sql b/Open-ILS/tests/datasets/sql/load_all.sql
index 1d4914e..54d4e55 100644
--- a/Open-ILS/tests/datasets/sql/load_all.sql
+++ b/Open-ILS/tests/datasets/sql/load_all.sql
@@ -30,6 +30,9 @@ BEGIN;
 -- load RDA bibs
 \i bibs_rda.sql
 
+-- load EbookAPI bibs
+\i bibs_ebook_api.sql
+
 -- insert all loaded bibs into the biblio.record_entry in insert order
 INSERT INTO biblio.record_entry (marc, last_xact_id) 
     SELECT marc, tag FROM marcxml_import ORDER BY id;

commit 6efd80aee60974fe3045d6cab030fdc1db6accd8
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Tue Feb 7 15:08:31 2017 -0800

    LP#1541559: ebook API service and test module
    
    This commit introduces a new service, open-ils.ebook_api, for
    integration of third-party APIs from vendors like OverDrive and
    OneClickdigital.
    
    The design of the service is somewhat similar to Evergreen's added
    content module.  Common functionality and API calls are defined in the
    main Perl module, OpenILS::Application::EbookAPI, while vendor-specific
    details like endpoint URLs are broken out into separate "handler"
    submodules for each vendor API.  (The actual mechanics of HTTP
    requests/responses are handled by the new OpenILS::Utils::HTTPClient
    module.)
    
    An example handler module, OpenILS::Application::EbookAPI::Test, is
    included with this commit, along with some live tests which depend on
    the test module.  It can be considered a reference implementation for
    future vendor-specific handlers.
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example
index dd128bd..a111c5b 100644
--- a/Open-ILS/examples/opensrf.xml.example
+++ b/Open-ILS/examples/opensrf.xml.example
@@ -1247,7 +1247,27 @@ vim:et:ts=4:sw=4:
                 </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>
 
@@ -1293,6 +1313,7 @@ vim:et:ts=4:sw=4:
                 <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>
diff --git a/Open-ILS/examples/opensrf_core.xml.example b/Open-ILS/examples/opensrf_core.xml.example
index d2ec8eb..ba21693 100644
--- a/Open-ILS/examples/opensrf_core.xml.example
+++ b/Open-ILS/examples/opensrf_core.xml.example
@@ -37,6 +37,7 @@ Example OpenSRF bootstrap configuration file for Evergreen
           <service>open-ils.url_verify</service>
           <service>open-ils.vandelay</service>
           <service>open-ils.serial</service>
+          <service>open-ils.ebook_api</service>
         </services>
       </router>
 
diff --git a/Open-ILS/src/perlmods/MANIFEST b/Open-ILS/src/perlmods/MANIFEST
index 216c40d..8de8df8 100644
--- a/Open-ILS/src/perlmods/MANIFEST
+++ b/Open-ILS/src/perlmods/MANIFEST
@@ -39,6 +39,8 @@ lib/OpenILS/Application/Circ/StatCat.pm
 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
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm
new file mode 100644
index 0000000..1b3c8c6
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm
@@ -0,0 +1,811 @@
+#!/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;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm
new file mode 100644
index 0000000..a20846c
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm
@@ -0,0 +1,464 @@
+#!/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};
+}
+
diff --git a/Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t b/Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t
new file mode 100644
index 0000000..72054a5
--- /dev/null
+++ b/Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t
@@ -0,0 +1,170 @@
+#!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
+
diff --git a/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t b/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
new file mode 100644
index 0000000..338f043
--- /dev/null
+++ b/Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
@@ -0,0 +1,9 @@
+#!perl -T
+
+use Test::More tests => 2;
+
+BEGIN {
+    use_ok( 'OpenILS::Application::EbookAPI' );
+    use_ok( 'OpenILS::Application::EbookAPI::Test' );
+}
+

commit 77f8b367decae2bbe1f3061a6945e4c2c98d94cf
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Fri Nov 20 13:54:36 2015 -0800

    LP#1541559: HTTPClient: a utility for sending HTTP requests and handling responses
    
    The intent of this package is to provide basic tools for communicating
    with third-party APIs.  It is a dependency of the open-ils.ebook_api
    service.
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example
index 9205229..dd128bd 100644
--- a/Open-ILS/examples/opensrf.xml.example
+++ b/Open-ILS/examples/opensrf.xml.example
@@ -232,6 +232,39 @@ vim:et:ts=4:sw=4:
         instructions on mapping the old XML entries to database tables.
         -->
 
+        <http_client>
+            <!--
+            These settings are used by the OpenILS::Utils::HTTPClient module
+            when communicating with external services (e.g. third-party APIs)
+            over HTTP.  Values are passed along to LWP::UserAgent.
+            -->
+
+            <!-- custom useragent for HTTP requests
+            <useragent>Evergreen</useragent>
+            -->
+
+            <!-- default timeout value (in seconds) -->
+            <default_timeout>60</default_timeout>
+
+            <ssl_opts>
+                <!--
+                When using HTTPS, verify that the external server has a valid
+                SSL certificate matching the expected hostname.  (Set to 0 to
+                disable verification, 1 to enable it.)
+                -->
+                <verify_hostname>1</verify_hostname>
+
+                <!--
+                If verify_hostname is enabled, you may need to specify a path
+                for CA certificates installed on your system.  Use ONE of the
+                following settings.  See LWP::UserAgent docs for details.
+                <SSL_ca_path>/etc/ssl/certs</SSL_ca_path>
+                <SSL_ca_file>/etc/ssl/certs/ca-certificates.crt</SSL_ca_file>
+                -->
+            </ssl_opts>
+
+        </http_client>
+
         <added_content>
             <!-- load the OpenLibrary added content module -->
             <module>OpenILS::WWW::AddedContent::OpenLibrary</module>
diff --git a/Open-ILS/src/perlmods/MANIFEST b/Open-ILS/src/perlmods/MANIFEST
index f8a77a4..216c40d 100644
--- a/Open-ILS/src/perlmods/MANIFEST
+++ b/Open-ILS/src/perlmods/MANIFEST
@@ -132,6 +132,7 @@ lib/OpenILS/Utils/Cronscript.pm
 lib/OpenILS/Utils/Cronscript.pm.in
 lib/OpenILS/Utils/CStoreEditor.pm
 lib/OpenILS/Utils/Fieldmapper.pm
+lib/OpenILS/Utils/HTTPClient.pm
 lib/OpenILS/Utils/ISBN.pm
 lib/OpenILS/Utils/Lockfile.pm
 lib/OpenILS/Utils/MFHD.pm
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HTTPClient.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HTTPClient.pm
new file mode 100644
index 0000000..6474586
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HTTPClient.pm
@@ -0,0 +1,131 @@
+package OpenILS::Utils::HTTPClient;
+
+use strict;
+use warnings;
+
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use OpenSRF::Utils::JSON;
+use LWP::UserAgent;
+use HTTP::Request;
+
+sub new {
+    my $class = shift;
+
+    my $self = {};
+    bless $self, $class;
+
+    $self->_initialize();
+
+    return $self;
+}
+
+sub _initialize {
+    my $self = shift;
+
+    # pull settings from opensrf.xml config
+    my $conf = OpenSRF::Utils::SettingsClient->new();
+    my $settings = $conf->config_value('http_client');
+
+    if ($settings->{useragent}) {
+        $self->{useragent} = $settings->{useragent};
+    }
+    if ($settings->{default_timeout}) {
+        $self->{default_timeout} = $settings->{default_timeout};
+    }
+
+    # SSL handling options. When communicating over HTTPS, LWP::UserAgent
+    # falls back to the environment variables whose values are set here.
+    # See LWP::UserAgent docs for details.
+    foreach my $opt (keys %{$settings->{ssl_opts}}) {
+        # check for a valid SSL cert?
+        if ($opt eq 'verify_hostname') {
+            $ENV{PERL_LWP_SSL_VERIFY_HOSTNAME} = $settings->{ssl_opts}->{verify_hostname};
+        # path to directory for CA certificate files
+        } elsif ($opt eq 'SSL_ca_path') {
+            $ENV{PERL_LWP_SSL_CA_PATH} = $settings->{ssl_opts}->{SSL_ca_path};
+        # path to CA certificate file
+        } elsif ($opt eq 'SSL_ca_file') {
+            $ENV{PERL_LWP_SSL_CA_FILE} = $settings->{ssl_opts}->{SSL_ca_file};
+        }
+    }
+
+    return $self;
+}
+
+# request(): Send an HTTP request.
+#
+# Params:
+#   $method - HTTP method (GET, POST, PUT, DELETE)
+#   $uri - URI of resource to be requested
+#   $header - hashref containing HTTP headers
+#   $content - content of request
+#   $request_timeout - timeout value in seconds; defaults to 60s
+#   $useragent - user agent string; defaults to SameOrigin/1.0
+#
+# Returns an HTTP::Response object, or undef if the request failed/timed out.
+# Use $res->content to get response content.
+#
+sub request {
+    my ($self, $method, $uri, $headers, $content, $request_timeout, $useragent) = @_;
+    my $ua = new LWP::UserAgent;
+
+    $request_timeout = $request_timeout || $self->{default_timeout} || 60;
+    $ua->timeout($request_timeout);
+
+    $useragent = $useragent || $self->{useragent} || 'SameOrigin/1.0';
+    $ua->agent($useragent);
+
+    my $h = HTTP::Headers->new();
+    foreach my $k (keys %$headers) {
+        $h->header($k => $headers->{$k});
+    }
+
+    my $req = HTTP::Request->new(
+        $method,
+        $uri,
+        $h,
+        $content
+    );
+    my $res;
+
+    eval {
+        $logger->info("HTTPClient: sending HTTP $method request to $uri");
+        $res = $ua->request($req);
+    } or do {
+        $logger->info("HTTPClient: execution error");
+        return undef;
+    };
+
+    if ($res->status_line =~ /timeout/) {
+        $logger->info("HTTPClient: timeout error: " . $res->status_line);
+        return undef;
+    }
+
+    # TODO handle HTTP response status codes
+
+    return $res;
+}
+
+# Wrappers for request() using specific HTTP methods (GET, POST etc).
+sub get {
+    my $self = shift;
+    return $self->request('GET', @_);
+}
+
+sub post {
+    my $self = shift;
+    return $self->request('POST', @_);
+}
+
+sub put {
+    my $self = shift;
+    return $self->request('PUT', @_);
+}
+
+sub delete {
+    my $self = shift;
+    return $self->request('DELETE', @_);
+}
+
+1;
diff --git a/Open-ILS/src/perlmods/t/14-OpenILS-Utils.t b/Open-ILS/src/perlmods/t/14-OpenILS-Utils.t
index 180686f..548bea3 100644
--- a/Open-ILS/src/perlmods/t/14-OpenILS-Utils.t
+++ b/Open-ILS/src/perlmods/t/14-OpenILS-Utils.t
@@ -1,6 +1,6 @@
 #!perl -T
 
-use Test::More tests => 29;
+use Test::More tests => 30;
 use Test::Warn;
 use utf8;
 
@@ -20,6 +20,7 @@ use_ok( 'OpenILS::Utils::PermitHold' );
 use_ok( 'OpenILS::Utils::RemoteAccount' );
 use_ok( 'OpenILS::Utils::ZClient' );
 use_ok( 'OpenILS::Utils::EDIReader' );
+use_ok( 'OpenILS::Utils::HTTPClient' );
 
 # LP 800269 - Test MFHD holdings for records that only contain a caption field
 my $co_marc = MARC::Record->new();

commit ef5fb9f2ecc3a9c6b9b595f79a6c9fe246c3589d
Author: Jeff Davis <jdavis at sitka.bclibraries.ca>
Date:   Mon Oct 3 13:39:37 2016 -0700

    LP#1541559: make active barcode available in OPAC
    
    When logged in to the TPAC, the barcode of the patron's active card is
    made available in $ctx->{active_card}.
    
    Signed-off-by: Jeff Davis <jdavis at sitka.bclibraries.ca>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
index 3b1a661..f2f5d8f 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
@@ -302,6 +302,7 @@ sub load_common {
             $ctx->{authtoken} = $e->authtoken;
             $ctx->{authtime} = $e->authtime;
             $ctx->{user} = $e->requestor;
+            $ctx->{active_card} = $self->editor->retrieve_actor_card($ctx->{user}->card)->barcode;
             $ctx->{place_unfillable} = 1 if $e->requestor->wsid && $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
 
             # The browser client does not set an OILS-Wrapper header (above).

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/examples/opensrf.xml.example              |   56 ++-
 Open-ILS/examples/opensrf_core.xml.example         |    1 +
 Open-ILS/src/perlmods/MANIFEST                     |    5 +
 .../perlmods/lib/OpenILS/Application/EbookAPI.pm   |  811 ++++++++++++++++++++
 .../Application/EbookAPI/OneClickdigital.pm        |  305 ++++++++
 .../lib/OpenILS/Application/EbookAPI/OverDrive.pm  |  561 ++++++++++++++
 .../lib/OpenILS/Application/EbookAPI/Test.pm       |  464 +++++++++++
 .../src/perlmods/lib/OpenILS/Utils/HTTPClient.pm   |  131 ++++
 .../src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm    |    1 +
 .../src/perlmods/live_t/20-lp1541559-ebook-api.t   |  170 ++++
 Open-ILS/src/perlmods/t/14-OpenILS-Utils.t         |    3 +-
 .../perlmods/t/23-OpenILS-Application-EbookAPI.t   |   11 +
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |  170 ++++
 ....data.org-setting.ebook-api-oneclickdigital.sql |   42 +
 .../XXXX.data.org-setting.ebook-api-overdrive.sql  |  141 ++++
 Open-ILS/src/templates/opac/css/style.css.tt2      |   20 +-
 .../src/templates/opac/myopac/circ_history.tt2     |    5 +
 Open-ILS/src/templates/opac/myopac/circs.tt2       |    5 +
 Open-ILS/src/templates/opac/myopac/ebook_circs.tt2 |   42 +
 Open-ILS/src/templates/opac/myopac/ebook_holds.tt2 |   51 ++
 .../templates/opac/myopac/ebook_holds_ready.tt2    |   50 ++
 .../src/templates/opac/myopac/hold_history.tt2     |    8 +
 Open-ILS/src/templates/opac/myopac/holds.tt2       |    8 +
 Open-ILS/src/templates/opac/parts/config.tt2       |   11 +
 .../src/templates/opac/parts/ebook_api/avail.tt2   |   49 ++
 .../templates/opac/parts/ebook_api/avail_js.tt2    |   49 ++
 .../src/templates/opac/parts/ebook_api/base_js.tt2 |   71 ++
 .../templates/opac/parts/ebook_api/login_js.tt2    |   41 +
 Open-ILS/src/templates/opac/parts/header.tt2       |    4 +
 Open-ILS/src/templates/opac/parts/js.tt2           |    6 +
 Open-ILS/src/templates/opac/parts/misc_util.tt2    |   50 ++
 .../src/templates/opac/parts/myopac/main_base.tt2  |   24 +-
 .../src/templates/opac/parts/record/summary.tt2    |    5 +
 Open-ILS/src/templates/opac/parts/result/table.tt2 |    5 +
 Open-ILS/src/templates/opac/parts/topnav.tt2       |   16 +
 Open-ILS/tests/datasets/sql/bibs_ebook_api.sql     |    8 +
 Open-ILS/tests/datasets/sql/load_all.sql           |    3 +
 Open-ILS/web/js/ui/default/opac/ebook_api/ebook.js |   48 ++
 .../web/js/ui/default/opac/ebook_api/loggedin.js   |  224 ++++++
 .../web/js/ui/default/opac/ebook_api/relation.js   |   80 ++
 .../web/js/ui/default/opac/ebook_api/session.js    |   40 +
 41 files changed, 3780 insertions(+), 15 deletions(-)
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI.pm
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OneClickdigital.pm
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/OverDrive.pm
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/EbookAPI/Test.pm
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Utils/HTTPClient.pm
 create mode 100644 Open-ILS/src/perlmods/live_t/20-lp1541559-ebook-api.t
 create mode 100644 Open-ILS/src/perlmods/t/23-OpenILS-Application-EbookAPI.t
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-oneclickdigital.sql
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting.ebook-api-overdrive.sql
 create mode 100644 Open-ILS/src/templates/opac/myopac/ebook_circs.tt2
 create mode 100644 Open-ILS/src/templates/opac/myopac/ebook_holds.tt2
 create mode 100644 Open-ILS/src/templates/opac/myopac/ebook_holds_ready.tt2
 create mode 100644 Open-ILS/src/templates/opac/parts/ebook_api/avail.tt2
 create mode 100644 Open-ILS/src/templates/opac/parts/ebook_api/avail_js.tt2
 create mode 100644 Open-ILS/src/templates/opac/parts/ebook_api/base_js.tt2
 create mode 100644 Open-ILS/src/templates/opac/parts/ebook_api/login_js.tt2
 create mode 100644 Open-ILS/tests/datasets/sql/bibs_ebook_api.sql
 create mode 100644 Open-ILS/web/js/ui/default/opac/ebook_api/ebook.js
 create mode 100644 Open-ILS/web/js/ui/default/opac/ebook_api/loggedin.js
 create mode 100644 Open-ILS/web/js/ui/default/opac/ebook_api/relation.js
 create mode 100644 Open-ILS/web/js/ui/default/opac/ebook_api/session.js


hooks/post-receive
-- 
Evergreen ILS




More information about the open-ils-commits mailing list