Initial commit
[sitka/overdrive-evergreen-opac.git] / src / od_api.coffee
CommitLineData
8ebc3fff
SC
1define [
2 'jquery'
3 'lodash'
4 'json'
5 'cookies'
6 'moment'
7 'od_config'
8], ($, _, json, C, M, config) ->
9
10 # Dump the given arguments or log them to console
11 log = ->
12 try
13 dump "#{x}\n" for x in arguments
14 return
15 catch
16 console.log arguments
17 return
18
19 $notify = $ {}
20
21 logError = (jqXHR, textStatus, errorThrown) ->
22 log "#{textStatus} #{jqXHR.status} #{errorThrown}"
23 $notify.trigger 'od.fail', arguments
24
25 # Define custom event names for this module. A custom event is triggered
26 # whenever result data becomes available after making an API request.
27 eventList = [
28 'od.clientaccess'
29 'od.libraryaccount'
30 'od.metadata'
31 'od.availability'
32
33 'od.patronaccess'
34 'od.patroninfo'
35 'od.holds'
36 'od.checkouts'
37 'od.interests'
38 'od.action'
39
40 'od.hold.update'
41 'od.hold.delete'
42 'od.checkout.update'
43 'od.checkout.delete'
44
45 'od.prefs'
46 'od.login'
47 'od.logout'
48 'od.error'
49 ]
50 eventObject = $({}).on eventList.join(' '), (e, x, y...) -> log e.namespace, x, y
51
52 # We require the service of a session object to store essentials bits of
53 # information during a login session and between page reloads. Here, we
54 # define the default version of the session, which will be used if no
55 # session is active.
56 default_session =
57
58 prefs: {}
59
60 credentials: {}
61
62 # An essential role of the session object is to store the response
63 # parameters that are provided as a result of authenticating the client
64 # or the patron. Two components, the token type and the token, are
65 # computed into an Authorization header so that they can be easily
66 # submitted to an ajax call.
67 token: {}
68 #parameters: ''
69 #headers: Authorization: ''
70
71 # Another role is to store the endpoints of the various APIs. These
72 # include the endpoints for authenticating the client or the patron and
73 # the endpoints for getting library or patron information. Upon
74 # authentication, other endpoints are dynamically accumulated within
75 # the session object.
76 links:
77 token: href: '//oauth.overdrive.com/token'
78 libraries: href: "//api.overdrive.com/v1/libraries/#{config.accountID}"
79 patrontoken: href: '//oauth-patron.overdrive.com/patrontoken'
80 patrons: href: '//patron.api.overdrive.com/v1/patrons/me'
81 holds: href: ''
82 checkouts: href: ''
83 products: ''
84 advantageAccounts: ''
85 search: ''
86 availability: ''
87
88 # Another role is to preserve the mapping between format id and format
89 # name that will be provided by the Library Account API.
90 labels: {}
91
92 # The session object uses a local storage mechanism based on window.name;
93 # see
94 # http://stackoverflow.com/questions/2035075/using-window-name-as-a-local-data-cache-in-web-browsers
95 # for pros and cons and alternatives.
96 #
97 # On page load, we unserialize the text string found in local storage into
98 # an object, or if there is no string yet, we create the default object.
99 session =
100 try
101 json.parse window.name
102 catch
103 default_session
104
105 # On window unload, we timestamp the current session object and serialize it
106 # into local storage so that it survives page reloads.
107 $(window).on 'unload', ->
108 session.now = M().toISOString()
109 window.name = json.stringify session
110
111 # Use a cheap, nasty way to enforce a sanity constraint: session link templates should have empty
112 # values unless the current session is logged in.
113 $.extend session.links, { search: '', availability: '' } unless Boolean C('eg_loggedin') or window.IAMXUL
114
115 # Return a new object from given an object that has a 'key' property and a
116 # 'value' property
117 to_object = (from, key, value) ->
118 to = {}
119 to[x[key]] = x[value] for x in from
120 return to
121
122 # Update library or patron account information for the session
123 update_session_cache = (x) ->
124
125 # Cache any links
126 $.extend session.links, x.links if x.links
127
128 # Cache any linkTemplates; these are sourced only via the Patron Information API
129 $.extend session.links, x.linkTemplates if x.linkTemplates
130
131 #$.extend session, x
132
133 # Cache any mapping between format names and format IDs
134 $.extend session.labels, to_object x.formats, 'id', 'name' if x.formats
135
136 return x
137
138 # Revert the session cache to its default version
139 expire_session_cache = ->
140 $.extend session, default_session
141
142 # Define a function to check if the session object contains a patron access
143 # token. It is enough to test if the parameters.scope text string mentions
144 # the word 'patron'.
145 #is_patron_access_token = -> /patron/i.test session.token.parameters?.scope
146 is_patron_access_token = -> /patron/i.test session.token?.scope
147
148 # Calculate a string encompassing the token type and access token of a
149 # given response object to an Overdrive login request
150 token_header = (x) -> Authorization: "#{x.token_type} #{x.access_token}"
151
152 # Customize the plain jQuery ajax to post a request for an access token
153 _api = (url, data) ->
154
155 $.ajax $.extend {},
156 # The Basic Authorization string is always added to the HTTP header.
157 headers: Authorization: "Basic #{config.credentials}"
158 # The URL endpoint is converted to its reverse proxy version,
159 # because we are using the Evergreen server as a reverse proxy to
160 # the Overdrive server.
161 url: proxy url
162 type: 'POST'
163 # We expect data to be always given; the ajax method will convert
164 # it to a query string.
165 data: data
166
167 # Replace the host domain of a given URL with a proxy domain. If the input
168 # URL specifies a protocol, it is stripped out so that the output will
169 # default to the client's protocol.
170 proxy = (x) ->
171 return unless x
172 y = x
173 y = y.replace 'https://', '//'
174 y = y.replace 'http://' , '//'
175 y = y.replace '//oauth-patron.overdrive.com', '/od/oauth-patron'
176 y = y.replace '//oauth.overdrive.com', '/od/oauth'
177 y = y.replace '//patron.api.overdrive.com', '/od/api-patron'
178 y = y.replace '//api.overdrive.com', '/od/api'
179 y = y.replace '//images.contentreserve.com', '/od/images'
180 y = y.replace '//fulfill.contentreserve.com', '/od/fulfill'
181 #log "proxy #{x} -> #{y}"
182 y
183
184 # Convert a serialized array into a serialized object
185 serializeObject = (a) ->
186 o = {}
187 $.each a, ->
188 v = @value or ''
189 if (n = o[@name]) isnt undefined
190 o[@name] = [n] unless n.push
191 o[@name].push v
192 else
193 o[@name] = v
194 return o
195
196 # TODO unused
197 $.fn.extend
198
199 # Convert this serialized array to a serialized object
200 _serializeObject: -> serializeObject @serializeArray()
201
202 # Serialize this to a json string, an object, an array, a query string, or just return itself
203 _serializeX: (X) ->
204 switch X
205 when 'j' then json.stringify @_serializeX 'o'
206 when 'k' then json.stringify @_serializeX 'a'
207 when 'p' then $.param @_serializeX 'a'
208 when 's' then @serialize()
209 when 'o' then serializeObject @_serializeX 'a'
210 when 'a' then @serializeArray()
211 else @
212
213 # Mutate an ISO 8601 date string into a Moment object. If the argument is
214 # just a date value, then it specifies an absolute date in ISO 8601 format.
215 # If the argument is a pair, then it specifies a date relative to now. For
216 # an ISO 8601 date, we correct for what seems to be an error in time zone,
217 # Zulu time is really East Coast time.
218 momentize = (date, unit) ->
219 switch arguments.length
220 when 1
221 if date then M(date.replace /Z$/, '-0400') else M()
222 when 2
223 if date then M().add date, unit else M()
224 else M()
225
226 # We define the public interface of the module
227 # TODO wrap od in jquery so that we can use it to trigger events and bind event handlers
228 od =
229
230 # Povides the anchor object for implementing a publish/subscribe
231 # mechanism for this module.
232 $: eventObject.on
233
234 # Notification that there are possible changes of values from
235 # preferences page that should be updated in the session cache
236 'od.prefs': (ev, x) -> $.extend session.prefs, x
237
238 # Expire patron access token if user is no longer logged into EG
239 'od.logout': (ev, x) ->
240 if x is 'eg'
241 expire_session_cache if is_patron_access_token()
242
243 log: log
244
245 # It's necessary to expose the session cache to other modules because
246 # they may need to access certain stored values, in particular, the
247 # place hold page needs to get access to the email address
248 session: session
249
250 proxy: proxy
251
252 # Map format id to format name using current session object
253 labels: (id) -> session.labels[id] or id
254
255 # Customize the plain jQuery ajax method to handle a GET or POST method
256 # for the Overdrive api.
257 api: (url, method, data) ->
258
259 # Do some pre-processing of data before it is sent to server
260 if method is 'post'
261
262 # Convert numberOfDays value from an ISO 8601 date string to
263 # number of days relative to now. There are two subtleties
264 # regarding rounding errors: First, we use only use now of
265 # resolution to days to avoid a local round-down from 1 to 0.
266 # Second, we need to add one to avoid a round-down at the OD
267 # server.
268 for v in data.fields when v.name is 'numberOfDays'
269 v.value = 1 + M(v.value).diff M().toArray()[0..2], 'days'
270
271 $.ajax $.extend {},
272 # The current Authorization string is always added to the HTTP header.
273 headers: session.token.headers
274 # The URL endpoint is converted to its reverse proxy version, because
275 # we are using the Evergreen server as a reverse proxy to the Overdrive
276 # server.
277 url: proxy url
278 # Will default to 'get' if no method string is supplied
279 type: method
280 # A given data object is expected to be in JSON format
281 contentType: 'application/json; charset=utf-8'
282 data: json.stringify data
283
284 .done ->
285
286 # For a post method, we get a data object in reply. We publish
287 # the object using an event named after the data type, eg,
288 # 'hold', 'checkout'. We can't easily recognize the data type
289 # by looking at the data, so we have to pattern match on the
290 # API URL.
291 if method is 'post'
292 if /\/holds|\/suspension/.test url
293 x = arguments[0]
294 x.holdPlacedDate = momentize x.holdPlacedDate
295 x.holdExpires = momentize x.holdExpires
296 if x.holdSuspension
297 x.holdSuspension.numberOfDays = momentize x.holdSuspension.numberOfDays, 'days'
298 od.$.triggerHandler 'od.hold.update', x
299 if /\/checkouts/.test url
300 x = arguments[0]
301 x.expires = momentize x.expires
302 od.$.triggerHandler 'od.checkout.update', x
303
304 # For a delete method, we do not get a data object in reply,
305 # thus we pattern match for the specific ID and trigger an
306 # event with the ID.
307 if method is 'delete'
308 if id = url.match /\/holds\/(.+)\/suspension$/
309 return # no relevant event
310 if id = url.match /\/holds\/(.+)$/
311 od.$.triggerHandler 'od.hold.delete', reserveId: id[1]
312 if id = url.match /\/checkouts\/(.+)$/
313 od.$.triggerHandler 'od.checkout.delete', reserveId: id[2]
314
315 .fail ->
316 od.$.triggerHandler 'od.error', [url, arguments[0]]
317 #$('<div>')._notify arguments[1].statusText, arguments[0].responseText
318
319 # Get a library access token so that we can use the Discovery API
320 apiDiscAccess: ->
321
322 ok = (x) ->
323 # Cache the server's response object as a general reference point
324 #session.token.parameters = x
325 session.token = x
326 # Cache the access token so that it can be used in future api calls
327 session.token.headers = token_header x
328 od.$.triggerHandler 'od.clientaccess', x
329
330 _api session.links.token.href, grant_type: 'client_credentials'
331
332 .then ok, logError
333
334 # Use the Library Account API to get library account information,
335 # primarily the product link and the available formats. Since we
336 # schedule this call on every page load, it will also tell us if
337 # our access token has expired or not.
338 #
339 # If a retry is needed, we have to decide whether to get a library
340 # access token or a patron access token. However, getting the latter
341 # will, in the general case, require user credentials, which means we
342 # need to store the password in the browser across sessions. An
343 # alternative is to force a logout, so that the user needs to manually
344 # relogin. In effect, we would only proceed with a retry to get a
345 # library access token, but if the user has logged in, we would not.
346 #
347 apiAccount: ->
348
349 get = -> od.api session.links.libraries.href
350
351 ok = (x) ->
352 update_session_cache x
353 od.$.triggerHandler 'od.libraryaccount', x
354 return
355
356 retry = (jqXHR) ->
357
358 # Retry if we got a 401 error code
359 if jqXHR.status is 401
360
361 if is_patron_access_token()
362 # Current OD patron access token may have expired
363 od.$.triggerHandler 'od.logout', 'od'
364
365 else
366 # Renew our access token and retry the get operation
367 od.apiDiscAccess()
368 .then get, logError
369 .then ok
370
371 get().then ok, retry
372
373 # We define a two-phase sequence to get a patron access token, for example,
374 # login(credentials); do_something_involving_page_reload(); login();
375 # where credentials is an object containing username and password
376 # properties from the login form.
377 #
378 # Logging into Evergreen can proceed using either barcode or user name,
379 # but logging into Overdrive is only a natural act using barcode. In
380 # order to ensure that logging into OD with a username can proceed, we
381 # presume that EG has been logged into and, as a prelude, we get the
382 # Preferences page of the user so that we can scrape out the barcode
383 # value for logging into OD.
384 #
385 # The login sequence is associated with a cache that remembers the
386 # login response ('parameters') between login sessions. The epilogue to
387 # the login sequence is to use the Patron Information API to get URL
388 # links and templates that will allow the user to make further use of
389 # the Circulation API.
390 login: (credentials) ->
391
392 # Temporarily store the username and password from the login form
393 # into the session cache, and invalidate the session cache so that
394 # the final part of login sequence can complete.
395 if credentials
396 $.extend session.credentials, credentials
397 #delete session.token.parameters
398 session.token = {}
399 od.$.triggerHandler 'od.login'
400 return
401
402 # Return a promise to a resolved deferredment if session cache is still valid
403 # TODO is true if in staff client but shouldn't be
404 #if session.token.parameters
405 if is_patron_access_token()
406 return $.Deferred().resolve().promise()
407
408 # Request OD service for a patron access token using credentials
409 # pulled from the patron's preferences page
410 login = (prefs) ->
411
412 # Define a function to cut the value corresponding to a label
413 # from prefs
414 x = (label) ->
415 r = new RegExp "#{label}<\\/td>\\s+<td.+>(.*?)<\\/td>", 'i'
416 prefs.match(r)?[1] or ''
417
418 # Retrieve values from preferences page and save them in the
419 # session cache for later reference
420 $.extend( session.prefs,
421 barcode: x 'barcode'
422 email_address: x 'email address'
423 home_library: x 'home library'
424 )
425
426 # Use barcode as username or the username that was stored in
427 # session cache (in the hope that is a barcode) or give up with
428 # a null string
429 un = session.prefs.barcode or session.credentials?.username or ''
430
431 # Use the password that was stored in session cache or a dummy value
432 pw = if config.password_required is 'false' then 'xxxx' else session.credentials?.password or 'xxxx'
433
434 # Remove the stored credentials from cache as soon as they are
435 # no longer needed
436 session.credentials = {}
437
438 # Determine the Open Auth scope by mapping the long name of EG
439 # home library to OD authorization name
440 scope = "websiteid:#{config.websiteID} authorizationname:#{config.authorizationname session.prefs.home_library}"
441
442 # Try to get a patron access token from OD server
443 _api session.links.patrontoken.href,
444 grant_type: 'password'
445 username: un
446 password: pw
447 password_required: config.password_required
448 scope: scope
449
450 # Complete login sequence if the session cache is invalid
451 ok = (x) ->
452 #session.token.parameters = x
453 session.token = x
454 session.token.headers = token_header x
455 od.$.triggerHandler 'od.patronaccess', x
456
457 # Get patron preferences page
458 $.get '/eg/opac/myopac/prefs'
459 # Get the access token using credentials from preferences
460 .then login
461 # Update the session cache with access token
462 .then ok
463 # Update the session cache with session links
464 .then od.apiPatronInfo
465 .fail log
466
467 # TODO not used; EG catalogue is used instead
468 apiSearch: (x) ->
469 return unless x
470 od.api session.links.products.href, get, x
471
472 # TODO we can probably get away with using one normalization routine
473 # instead of using one for each type of data object, because they don't
474 # share property names.
475
476 apiMetadata: (x) ->
477 return unless x.id
478 od.api "#{session.links.products.href}/#{x.id}/metadata"
479
480 .then (y) ->
481 # Convert ID to upper case to match same case found in EG catalogue
482 y.id = y.id.toUpperCase()
483 # Provide a simplified notion of author: first name in creators
484 # list having a role of author
485 y.author = (v.name for v in y.creators when v.role is 'Author')[0] or ''
486 # Publish the metadata object
487 od.$.triggerHandler 'od.metadata', y
488 y
489
490 .fail -> od.$.triggerHandler 'od.metadata', x
491
492 apiAvailability: (x) ->
493 return unless x.id
494
495 url =
496 if (alink = session.links.availability?.href)
497 alink.replace '{crId}', x.id # Use this link if logged in
498 else
499 "#{session.links.products.href}/#{x.id}/availability"
500
501 od.api url
502
503 # Post-process the result, eg, fill in empty properties
504 .then (y) ->
505 # Normalize the result by adding zero values
506 y.copiesOwned = 0 unless y.copiesOwned
507 y.copiesAvailable = 0 unless y.copiesAvailable
508 y.numberOfHolds = 0 unless y.numberOfHolds
509
510 if y.actions?.hold
511 # The reserve ID is empty in the actions.hold.fields; we have to fill it ourselves.
512 _.where(y.actions.hold.fields, name: 'reserveId')[0].value = y.id
513 # We jam the email address from the prefs page into the fields object from the server
514 # so that the new form will display it.
515 if email_address = od.session.prefs.email_address
516 _.where(y.actions.hold.fields, name: 'emailAddress')[0].value = email_address
517
518 od.$.triggerHandler 'od.availability', y
519 arguments
520
521 .fail -> od.$.triggerHandler 'od.availability', x
522
523 apiPatronInfo: ->
524 ok = (x) ->
525 update_session_cache x
526 od.$.triggerHandler 'od.patroninfo', x
527 return
528
529 od.api session.links.patrons.href
530 .then ok, logError
531
532 apiHoldsGet: (x) ->
533 return unless is_patron_access_token()
534
535 od.api "#{session.links.holds.href}#{if x?.productID then x.productID else ''}"
536
537 # Post-process the result, eg, fill in empty properties, sort list,
538 # remove redundant actions or add missing actions
539 .then (y) ->
540
541 # Normalize the result by adding an empty holds list
542 xs = y.holds or []
543
544 # For each hold, convert any ISO 8601 date strings into a
545 # Moment object (at the local time zone)
546 for x in xs
547 x.holdPlacedDate = momentize x.holdPlacedDate
548 x.holdExpires = momentize x.holdExpires
549 if x.holdSuspension
550 x.holdSuspension.numberOfDays = momentize x.holdSuspension.numberOfDays, 'days'
551
552 # Count the number of holds that can be checked out now
553 y.ready = _.countBy xs, (x) -> if x.actions.checkout then 'forCheckout' else 'other'
554 y.ready.forCheckout = 0 unless y.ready.forCheckout
555
556 # Delete action to release a suspension if a hold is not
557 # suspended, because such actions are redundant
558 delete x.actions.releaseSuspension for x in xs when not x.holdSuspension
559
560 # Sort the holds list by position and placed date
561 # and sort ready holds first
562 #y.holds = _.sortBy xs, ['holdListPosition', 'holdPlacedDate']
563 y.holds = _(xs)
564 .sortBy ['holdListPosition', 'holdPlacedDate']
565 .sortBy (x) -> x.actions.checkout
566 .value()
567
568 od.$.triggerHandler 'od.holds', y
569 arguments
570
571 apiCheckoutsGet: (x) ->
572 return unless is_patron_access_token()
573
574 od.api "#{session.links.checkouts.href}#{if x?.reserveID then x.reserveID else ''}"
575
576 # Post-process the result, eg, fill in empty properties, sort list,
577 # remove redundant actions or add missing actions
578 .then (y) ->
579
580 # Normalize the result by adding an empty checkouts list
581 xs = y.checkouts or []
582
583 # Convert any ISO 8601 date strings into a Moment object (at
584 # the local time zone)
585 for x in xs
586 x.expires = momentize x.expires
587
588 # Sort the checkout list by expiration date
589 y.checkouts = _.sortBy xs, 'expires'
590
591 od.$.triggerHandler 'od.checkouts', y
592 arguments
593
594 # Get a list of user's 'interests', ie, holds and checkouts
595 apiInterestsGet: ->
596 $.when(
597 od.apiHoldsGet()
598 od.apiCheckoutsGet()
599 )
600
601 # Consolidate the holds and checkouts information into an object
602 # that represents the 'interests' of the patron
603 .then (h, c) ->
604
605 # A useful condition to handle if the API calls could not
606 # be fulfilled because they are not within the scope of the
607 # current access token
608 # TODO possibly redundant or unnecessary
609 ###
610 unless h and c
611 page {}, {}
612 return
613 ###
614
615 h = h[0]
616 c = c[0]
617
618 interests =
619 nHolds: h.totalItems
620 nHoldsReady: h.ready.forCheckout
621 nCheckouts: c.totalItems
622 nCheckoutsReady: c.totalCheckouts
623 ofHolds: h.holds
624 ofCheckouts: c.checkouts
625 # The following property is a map from product ID to a hold or
626 # a checkout object, eg, interests.byID(124)
627 byID: do (hs = h.holds, cs = c.checkouts) ->
628 byID = {}
629 for v, n in hs
630 v.type = 'hold'
631 byID[v.reserveId] = v
632 for v, n in cs
633 v.type = 'checkout'
634 byID[v.reserveId] = v
635 return byID
636
637 # Publish patron's interests to all areas of the screen
638 od.$.triggerHandler 'od.interests', interests
639 return interests
640
641 return od