9e09cd936e1bfa3a48d1089e5b4fb36e03b55425
[sitka/overdrive-evergreen-opac.git] / src / od_api.coffee
1 define [
2         'jquery'
3         'lodash'
4         'json'
5         'cookies'
6         'moment'
7         'od_config'
8         'od_session'
9         'od_data'
10 ], ($, _, json, C, M, config, Session, D) ->
11
12         # Dump the given arguments or log them to console
13         log = ->
14                 try
15                         dump "#{x}\n" for x in arguments
16                         return
17                 catch
18                         console.log arguments
19                         return
20         
21         $notify = $ {}
22
23         logError = (jqXHR, textStatus, errorThrown) ->
24                 log "#{textStatus} #{jqXHR.status} #{errorThrown}"
25                 $notify.trigger 'od.fail', arguments
26
27         # Define custom event names for this module.  A custom event is triggered
28         # whenever result data becomes available after making an API request.
29         eventList = [
30                 'od.clientaccess'
31                 'od.libraryinfo'
32                 'od.metadata'
33                 'od.availability'
34
35                 'od.patronaccess'
36                 'od.patroninfo'
37                 'od.holds'
38                 'od.checkouts'
39                 'od.interests'
40                 'od.action'
41
42                 'od.hold.update'
43                 'od.hold.delete'
44                 'od.checkout.update'
45                 'od.checkout.delete'
46
47                 'od.prefs'
48                 'od.login'
49                 'od.logout'
50                 'od.error'
51         ]
52         eventObject = $({}).on eventList.join(' '), (e, x, y...) -> log e.namespace, x, y
53
54         # On page load, we unserialize the text string found in local storage into
55         # an object, or if there is no string yet, we create the default object.
56         # The session object uses a local storage mechanism based on window.name;
57         # see
58         # http://stackoverflow.com/questions/2035075/using-window-name-as-a-local-data-cache-in-web-browsers
59         # for pros and cons and alternatives.
60         session = new Session window.name, Boolean C('eg_loggedin') or window.IAMXUL
61
62         # On window unload, we serialize it into local storage so that it survives
63         # page reloads.
64         $(window).on 'unload', -> window.name = session.store()
65
66         # Customize the plain jQuery ajax to post a request for an access token
67         _api = (url, data) ->
68
69                 $.ajax $.extend {},
70                         # The Basic Authorization string is always added to the HTTP header.
71                         headers: Authorization: "Basic #{config.credentials}"
72                         # The URL endpoint is converted to its reverse proxy version,
73                         # because we are using the Evergreen server as a reverse proxy to
74                         # the Overdrive server.
75                         url: proxy url
76                         type: 'POST'
77                         # We expect data to be always given; the ajax method will convert
78                         # it to a query string.
79                         data: data
80
81         # Replace the host domain of a given URL with a proxy domain.  If the input
82         # URL specifies a protocol, it is stripped out so that the output will
83         # default to the client's protocol.
84         proxy = (x) ->
85                 return unless x
86                 y = x
87                 y = y.replace 'https://', '//'
88                 y = y.replace 'http://' , '//'
89                 y = y.replace '//oauth-patron.overdrive.com', '/od/oauth-patron'
90                 y = y.replace        '//oauth.overdrive.com', '/od/oauth'
91                 y = y.replace   '//patron.api.overdrive.com', '/od/api-patron'
92                 y = y.replace          '//api.overdrive.com', '/od/api'
93                 y = y.replace  '//images.contentreserve.com', '/od/images'
94                 y = y.replace '//fulfill.contentreserve.com', '/od/fulfill'
95                 #log "proxy #{x} -> #{y}"
96                 y
97
98         # Convert a serialized array into a serialized object
99         serializeObject = (a) ->
100                 o = {}
101                 $.each a, ->
102                         v = @value or ''
103                         if (n = o[@name]) isnt undefined
104                                 o[@name] = [n] unless n.push
105                                 o[@name].push v
106                         else
107                                 o[@name] = v
108                 return o
109
110         # TODO unused
111         $.fn.extend
112
113                 # Convert this serialized array to a serialized object
114                 _serializeObject: -> serializeObject @serializeArray()
115
116                 # Serialize this to a json string, an object, an array, a query string, or just return itself
117                 _serializeX: (X) ->
118                         switch X
119                                 when 'j' then json.stringify @_serializeX 'o'
120                                 when 'k' then json.stringify @_serializeX 'a'
121                                 when 'p' then $.param @_serializeX 'a'
122                                 when 's' then @serialize()
123                                 when 'o' then serializeObject @_serializeX 'a'
124                                 when 'a' then @serializeArray()
125                                 else @
126
127
128         # We define the public interface of the module
129         # TODO wrap od in jquery so that we can use it to trigger events and bind event handlers
130         od =
131
132                 # Povides the anchor object for implementing a publish/subscribe
133                 # mechanism for this module.
134                 $: eventObject.on
135
136                         # Notification that there are possible changes of values from
137                         # preferences page that should be updated in the session cache
138                         'od.prefs': (ev, x) -> session.prefs.update x
139
140                         # Expire patron access token if user is no longer logged into EG
141                         'od.logout': (ev, x) ->
142                                 if x is 'eg'
143                                         session = new Session() if session.token.is_patron_access()
144
145                 log: log
146
147                 proxy: proxy
148
149                 # Map format id to format name using current session object
150                 labels: (id) -> session.labels[id] or id
151
152                 # Customize the plain jQuery ajax method to handle a GET or POST method
153                 # for the Overdrive api.
154                 api: (url, method, data, beforeSend) ->
155
156                         #  Do some pre-processing of data before it is sent to server
157                         if method is 'post'
158
159                                 # Convert numberOfDays value from an ISO 8601 date string to
160                                 # number of days relative to now.  There are two subtleties
161                                 # regarding rounding errors: First, we use only use now of
162                                 # resolution to days to avoid a local round-down from 1 to 0.
163                                 # Second, we need to add one to avoid a round-down at the OD
164                                 # server.
165                                 for v in data.fields when v.name is 'numberOfDays'
166                                         v.value = 1 + M(v.value).diff M().toArray()[0..2], 'days'
167
168                         $.ajax $.extend {},
169                                 # The current Authorization string is always added to the HTTP header.
170                                 headers: Authorization: "#{session.token.token_type} #{session.token.access_token}"
171                                 # The URL endpoint is converted to its reverse proxy version, because
172                                 # we are using the Evergreen server as a reverse proxy to the Overdrive
173                                 # server.
174                                 url: proxy url
175                                 # Will default to 'get' if no method string is supplied
176                                 type: method
177                                 # A given data object is expected to be in JSON format
178                                 contentType: 'application/json; charset=utf-8'
179                                 data: json.stringify data
180                                 beforeSend: beforeSend
181
182                         .done ->
183
184                                 # For a post method, we get a data object in reply.  We publish
185                                 # the object using an event named after the data type, eg,
186                                 # 'hold', 'checkout'.  We can't easily recognize the data type
187                                 # by looking at the data, so we have to pattern match on the
188                                 # API URL.
189                                 if method is 'post'
190                                         if /\/holds|\/suspension/.test url
191                                                 x = new D.Holds holds: [ arguments[0] ]
192                                                 od.$.triggerHandler 'od.hold.update', x
193                                         if /\/checkouts/.test url
194                                                 x = new D.Checkouts checkouts: [ arguments[0] ]
195                                                 od.$.triggerHandler 'od.checkout.update', x
196
197                                 # For a delete method, we do not get a data object in reply,
198                                 # thus we pattern match for the specific ID, and trigger an
199                                 # event with the ID.
200                                 if method is 'delete'
201                                         if id = url.match /\/holds\/(.+)\/suspension$/
202                                                 return # no relevant event
203                                         if id = url.match /\/holds\/(.+)$/
204                                                 od.$.triggerHandler 'od.hold.delete', id[1]
205                                         if id = url.match /\/checkouts\/(.+)$/
206                                                 od.$.triggerHandler 'od.checkout.delete', id[1]
207
208                         .fail ->
209                                 od.$.triggerHandler 'od.error', [url, arguments[0]]
210                                 #$('<div>')._notify arguments[1].statusText, arguments[0].responseText
211
212                 # Get a library access token so that we can use the Discovery API.
213                 # The token is cached and also published to other modules.
214                 apiDiscAccess: ->
215
216                         ok = (x) ->
217                                 session.token.update x
218                                 od.$.triggerHandler 'od.clientaccess', x
219                                 return x
220
221                         _api session.links.token.href, grant_type: 'client_credentials'
222
223                         .then ok, logError
224
225                 # Use the Library Account API to get library account information,
226                 # primarily the product link and the available formats.  Since we
227                 # schedule this call on every page load, it will also tell us if
228                 # our access token has expired or not.
229                 #
230                 # If a retry is needed, we have to decide whether to get a library
231                 # access token or a patron access token.  However, getting the latter
232                 # will, in the general case, require user credentials, which means we
233                 # need to store the password in the browser across sessions.  An
234                 # alternative is to force a logout, so that the user needs to manually
235                 # relogin. In effect, we would only proceed with a retry to get a
236                 # library access token, but if the user has logged in, we would not.
237                 #
238                 apiLibraryInfo: ->
239
240                         get = -> od.api session.links.libraries.href
241
242                         ok = (x) ->
243                                 session.links.update x
244                                 session.labels.update x
245                                 od.$.triggerHandler 'od.libraryinfo', x
246                                 return x
247
248                         retry = (jqXHR) ->
249
250                                 # Retry if we got a 401 error code
251                                 if jqXHR.status is 401
252
253                                         if session.token.is_patron_access()
254                                                 # Current OD patron access token may have expired
255                                                 od.$.triggerHandler 'od.logout', 'od'
256
257                                         else
258                                                 # Renew our access token and retry the get operation
259                                                 od.apiDiscAccess()
260                                                 .then get, logError
261                                                 .then ok
262
263                         get().then ok, retry
264
265                 # We define a two-phase sequence to get a patron access token, for example,
266                 # login(credentials); do_something_involving_page_reload(); login();
267                 # where credentials is an object containing username and password
268                 # properties from the login form.
269                 #
270                 # Logging into Evergreen can proceed using either barcode or user name,
271                 # but logging into Overdrive is only a natural act using barcode. In
272                 # order to ensure that logging into OD with a username can proceed, we
273                 # presume that EG has been logged into and, as a prelude, we get the
274                 # Preferences page of the user so that we can scrape out the barcode
275                 # value for logging into OD.
276                 #
277                 # The login sequence is associated with a cache that remembers the
278                 # login response ('parameters') between login sessions. The epilogue to
279                 # the login sequence is to use the Patron Information API to get URL
280                 # links and templates that will allow the user to make further use of
281                 # the Circulation API.
282                 login: (credentials) ->
283
284                         # Temporarily store the username and password from the login form
285                         # into the session cache, and invalidate the session token so that
286                         # the final part of login sequence can complete.
287                         if credentials
288                                 session.creds.update credentials
289                                 session.token.update()
290                                 od.$.triggerHandler 'od.login'
291                                 return
292
293                         # Return a promise to a resolved deferredment if session token is still valid
294                         # TODO is true if in staff client but shouldn't be
295                         if session.token.is_patron_access()
296                                 return $.Deferred().resolve().promise()
297
298                         # Request OD service for a patron access token using credentials
299                         # pulled from the patron's preferences page
300                         login = (prefs) ->
301
302                                 # Define a function to cut the value corresponding to a label
303                                 # from prefs
304                                 x = (label) ->
305                                         r = new RegExp "#{label}<\\/td>\\s+<td.+>(.*?)<\\/td>", 'i'
306                                         prefs.match(r)?[1] or ''
307
308                                 # Retrieve values from preferences page and save them in the
309                                 # session cache for later reference
310                                 session.prefs.update
311                                         barcode:       x 'barcode'
312                                         email_address: x 'email address'
313                                         home_library:  x 'home library'
314
315                                 # Use barcode as username or the username that was stored in
316                                 # session cache (in the hope that is a barcode) or give up with
317                                 # a null string
318                                 un = session.prefs.barcode or session.creds.un()
319
320                                 # Use the password that was stored in session cache or a dummy value
321                                 pw = session.creds.pw config.password_required
322
323                                 # Remove the stored credentials from cache as soon as they are
324                                 # no longer needed
325                                 session.creds.update()
326
327                                 # Determine the Open Auth scope by mapping the long name of EG
328                                 # home library to OD authorization name
329                                 scope = "websiteid:#{config.websiteID} authorizationname:#{config.authorizationname session.prefs.home_library}"
330
331                                 # Try to get a patron access token from OD server
332                                 _api session.links.patrontoken.href,
333                                         grant_type: 'password'
334                                         username: un
335                                         password: pw
336                                         password_required: config.password_required
337                                         scope: scope
338
339                         # Complete login sequence if the session cache is invalid
340                         ok = (x) ->
341                                 session.token.update x
342                                 od.$.triggerHandler 'od.patronaccess', x
343                                 return x
344
345                         # Get patron preferences page
346                         $.get '/eg/opac/myopac/prefs'
347                         # Get the access token using credentials from preferences
348                         .then login
349                         # Update the session cache with access token
350                         .then ok
351                         # Update the session cache with session links
352                         .then od.apiPatronInfo
353                         .fail log
354
355                 # TODO not used; EG catalogue is used instead
356                 apiSearch: (x) ->
357                         return unless x
358                         od.api session.links.products.href, get, x
359
360                 apiMetadata: (x) ->
361                         return unless x.id
362
363                         od.api "#{session.links.products.href}/#{x.id}/metadata"
364
365                         .then (y) ->
366                                 y = new D.Metadata y
367                                 od.$.triggerHandler 'od.metadata', y
368                                 y
369
370                         .fail -> od.$.triggerHandler 'od.metadata', x
371
372                 apiAvailability: (x) ->
373                         return unless x.id
374
375                         url =
376                                 if (alink = session.links.availability?.href)
377                                         alink.replace '{crId}', x.id # Use this link if logged in
378                                 else
379                                         "#{session.links.products.href}/#{x.id}/availability"
380
381                         od.api url
382
383                         .then (y) ->
384                                 y = new D.Availability y, session.prefs.email_address
385                                 od.$.triggerHandler 'od.availability', y
386                                 return y
387
388                         .fail -> od.$.triggerHandler 'od.availability', x
389
390                 apiPatronInfo: ->
391
392                         ok = (x) ->
393                                 session.links.update x
394                                 od.$.triggerHandler 'od.patroninfo', x
395                                 return x
396
397                         od.api session.links.patrons.href
398                         .then ok, logError
399
400                 # Get a specific hold or all holds
401                 apiHoldsGet: (x) ->
402                         return unless session.token.is_patron_access()
403
404                         od.api "#{session.links.holds.href}#{if x?.productID then x.productID else ''}"
405
406                         .then (y) ->
407                                 y = new D.Holds y
408                                 od.$.triggerHandler 'od.holds', y
409                                 return y
410
411                 # Get a specific checkout or all checkouts
412                 apiCheckoutsGet: (x) ->
413                         return unless session.token.is_patron_access()
414
415                         od.api "#{session.links.checkouts.href}#{if x?.reserveID then x.reserveID else ''}"
416
417                         .then (y) ->
418                                 y = new D.Checkouts y
419                                 od.$.triggerHandler 'od.checkouts', y
420                                 return y
421
422                 # Consolidate the holds and checkouts lists into an object that
423                 # represents the 'interests' of the patron
424                 apiInterestsGet: ->
425                         $.when(
426                                 od.apiHoldsGet()
427                                 od.apiCheckoutsGet()
428                         )
429                         .then (h, c) ->
430                                 y = new D.Interests h, c
431                                 od.$.triggerHandler 'od.interests', y
432                                 return y
433
434         return od