File Coverage

blib/lib/WebService/ILS/RecordedBooks.pm
Criterion Covered Total %
statement 27 295 9.1
branch 0 148 0.0
condition 0 25 0.0
subroutine 9 48 18.7
pod 19 30 63.3
total 55 546 10.0


line stmt bran cond sub pod time code
1             package WebService::ILS::RecordedBooks;
2              
3 1     1   300 use Modern::Perl;
  1         3  
  1         9  
4              
5             =encoding utf-8
6              
7             =head1 NAME
8              
9             WebService::ILS::RecordedBooks - WebService::ILS module for RecordedBooks services
10              
11             =head1 SYNOPSIS
12              
13             use WebService::ILS::RecordedBooks::Partner;
14             or
15             use WebService::ILS::RecordedBooks::Patron;
16              
17             =head1 DESCRIPTION
18              
19             L - services
20             that use partner credentials, for any patron
21              
22             L - same as above,
23             except it operates on a single patron account
24              
25             L - services
26             that use individual patron credentials, in addition to partner credentials
27              
28             L is preferred over
29             L because the later requires patron
30             credentials - username and password. However, if you do not know patron's
31             email or RecordedBooks id (barcode) you are stuck with Patron interface.
32              
33             See L
34              
35             =cut
36              
37 1     1   112 use Carp;
  1         2  
  1         37  
38 1     1   5 use HTTP::Request::Common;
  1         2  
  1         42  
39 1     1   4 use URI::Escape;
  1         2  
  1         43  
40 1     1   362 use JSON qw(to_json);
  1         5372  
  1         4  
41              
42 1     1   123 use parent qw(WebService::ILS::JSON);
  1         14  
  1         9  
43              
44 1     1   45 use constant API_VERSION => "v1";
  1         2  
  1         45  
45 1     1   5 use constant BASE_DOMAIN => "rbdigital.com";
  1         2  
  1         41  
46              
47             =head1 CONSTRUCTOR
48              
49             =head2 new (%params_hash or $params_hashref)
50              
51             =head3 Additional constructor params:
52              
53             =over 12
54              
55             =item C => if set to true use https
56              
57             =item C => RecordedBooks domain for title url
58              
59             =back
60              
61             C is either RecordedBooks id (barcode) or email
62              
63             C if set is either "whatever.rbdigital.com" or "whatever",
64             in which case rbdigital.com is appended.
65              
66             =cut
67              
68 1         3 use Class::Tiny qw(
69             ssl
70             domain
71             _api_base_url
72 1     1   4 );
  1         3  
73              
74             __PACKAGE__->_set_param_spec({
75             client_id => { required => 0 },
76             library_id => { required => 1 },
77             domain => { required => 0 },
78             ssl => { required => 0, default => 1 },
79             });
80              
81             sub BUILD {
82 0     0 0   my $self = shift;
83 0           my $params = shift;
84              
85 0 0         if (my $domain = $self->domain) {
86 0 0         $self->domain("$domain.".BASE_DOMAIN) unless $domain =~ m/\./;
87             }
88              
89 0           my $ssl = $self->ssl;
90 0           my $ua = $self->user_agent;
91 0 0         $ua->ssl_opts( verify_hostname => 0 ) if $ssl;
92              
93 0 0         my $api_url = sprintf "%s://api.%s", $ssl ? "https" : "http", BASE_DOMAIN;
94 0           $self->_api_base_url($api_url);
95             }
96              
97             sub api_url {
98 0     0 0   my $self = shift;
99 0 0         my $action = shift or croak "No action";
100              
101 0           return sprintf "%s/%s%s", $self->_api_base_url, API_VERSION, $action;
102             }
103              
104             sub library_action_base_url {
105 0     0 0   my $self = shift;
106              
107 0           return $self->api_url("/libraries/".$self->library_id);
108             }
109              
110             sub products_url {
111 0     0 0   my $self = shift;
112 0           return $self->library_action_base_url."/search";
113             }
114              
115             sub circulation_action_url {
116 0     0 0   my $self = shift;
117 0 0         my $action = shift or croak "No action";
118              
119 0           return $self->circulation_action_base_url(@_).$action;
120             }
121              
122             sub _access_auth_string {
123 0     0     my $self = shift;
124 0           return $self->client_secret;
125             }
126              
127             sub native_countries {
128 0     0 0   my $self = shift;
129              
130 0           my $url = $self->api_url("/countries");
131 0           return $self->get_without_auth($url);
132             }
133              
134             sub native_facets {
135 0     0 0   my $self = shift;
136              
137 0           my $url = $self->api_url("/facets");
138 0           return $self->get_response($url);
139             }
140              
141              
142             sub native_facet_values {
143 0     0 0   my $self = shift;
144 0 0         my $facet = shift or croak "No facet";
145              
146 0           my $url = $self->api_url("/facets/$facet");
147 0           return $self->get_without_auth($url);
148             }
149              
150             sub native_libraries_search {
151 0     0 0   my $self = shift;
152 0 0         my $query = shift or croak "No query";
153 0           my $region = shift;
154              
155 0           my %search_params = ( term => $query );
156 0 0         $search_params{ar} = $region if $region;
157 0           my $url = $self->api_url("/suggestive/libraries");
158 0           return $self->get_without_auth($url, \%search_params);
159             }
160              
161             sub get_without_auth {
162 0     0 0   my $self = shift;
163 0 0         my $url = shift or croak "No url";
164 0           my $get_params = shift; # hash ref
165              
166 0           my $uri = URI->new($url);
167 0 0         $uri->query_form($get_params) if $get_params;
168 0           my $request = HTTP::Request::Common::GET( $uri );
169 0           my $response = $self->user_agent->request( $request );
170 0           $self->check_response($response);
171              
172             return $self->process_json_response($response, sub {
173 0     0     my ($data) = @_;
174 0 0         die "No data\n" unless $data;
175 0           return $data;
176 0           });
177             }
178              
179             =head1 DISCOVERY METHODS
180              
181             =head2 facets ()
182              
183             =head3 Returns a hashref of facet => [values]
184              
185             =cut
186              
187             sub facets {
188 0     0 1   my $self = shift;
189              
190 0           my $facets = $self->native_facets;
191 0           my %facet_values;
192 0           foreach (@$facets) {
193 0           my $f = $_->{facetToken};
194 0           $facet_values{$f} = [map $_->{valueToken}, @{ $self->native_facet_values($f) }];
  0            
195             }
196 0           return \%facet_values;
197             }
198              
199             =head2 search ($params_hashref)
200              
201             =head3 Additional input params:
202              
203             =over 12
204              
205             =item C => a hashref of facet values
206              
207             =back
208              
209             =cut
210              
211             my %SORT_XLATE = (
212             rating => undef,
213             publication_date => undef, # not available
214             );
215             sub search {
216 0     0 1   my $self = shift;
217 0   0       my $params = shift || {};
218              
219 0           my $url = $self->products_url;
220              
221 0 0         if (my $query = delete $params->{query}) {
222 0 0         $query = join " ", @$query if ref $query;
223 0           $params->{all} = $query;
224             }
225 0 0         if (my $page_size = delete $params->{page_size}) {
226 0           $params->{'page-size'} = $page_size;
227             }
228 0 0         if (my $page_number = delete $params->{page}) {
229 0 0         die "page_size must be specified for paging" unless $params->{'page-size'};
230 0           $params->{'page-index'} = $page_number - 1;
231             }
232 0 0         if (my $sort = delete $params->{sort}) {
233 0           my $sa = $self->_parse_sort_string($sort, \%SORT_XLATE);
234 0 0         if (@$sa) {
235 0           my @params = %$params;
236 0           foreach (@$sa) {
237 0           my ($s, $d) = split ':';
238 0           push @params, "sort-by", $s;
239 0 0         push @params, "sort-order", $d if $d;
240             }
241 0           return $self->_search_result_xlate( $self->get_response($url, \@params) );
242             }
243             }
244              
245 0           return $self->_search_result_xlate( $self->get_response($url, $params) );
246             }
247              
248             sub _search_result_xlate {
249 0     0     my $self = shift;
250 0 0         my $res = shift or return;
251              
252 0           my $domain = $self->domain;
253             return {
254             items => [ map {
255 0           my $i = $self->_item_xlate($_->{item});
256 0 0 0       $i->{url} ||= "https://$domain/#titles/$i->{isbn}" if $domain;
257 0           $i;
258 0 0         } @{$res->{items} || []} ],
259             page_size => $res->{pageSize},
260             page => $res->{pageIndex} + 1,
261             pages => $res->{pageCount},
262 0           };
263             }
264              
265             my %SEARCH_RESULT_ITEM_XLATE = (
266             id => "id",
267             title => "title",
268             subtitle => "subtitle",
269             shortDescription => "description",
270             mediaType => "media",
271             downloadUrl => "url",
272             encryptionKey => "encryption_key",
273             isbn => "isbn",
274             hasDrm => "drm",
275             releasedDate => "publication_date",
276             size => "size",
277             language => "language",
278             expiration => "expires",
279             );
280             my %ITEM_FILES_XLATE = (
281             id => "id",
282             filename => "filename",
283             display => "title",
284             downloadUrl => "url",
285             size => "size",
286             );
287             sub _item_xlate {
288 0     0     my $self = shift;
289 0           my $item = shift;
290              
291 0           my $std_item = $self->_result_xlate($item, \%SEARCH_RESULT_ITEM_XLATE);
292              
293 0 0         if (my $images = delete $item->{images}) { # XXX let's say that caller wouldn't mind
294 0           $std_item->{images} = {map { $_->{name} => $_->{url} } @$images};
  0            
295             }
296              
297 0 0         if (my $files = delete $item->{files}) {
298 0           $std_item->{files} = [ map $self->_result_xlate($_, \%ITEM_FILES_XLATE), @$files ];
299             }
300              
301 0           my %facets;
302 0 0         if (my $publisher = delete $item->{publisher}) {
303 0 0         if (ref $publisher) {
304 0 0         if (my $f = $publisher->{facet}) {
305 0           $facets{$f} = [$publisher->{token}];
306             }
307 0           $publisher = $publisher->{text};
308             }
309 0           $std_item->{publisher} = $publisher;
310             }
311 0 0         if (my $authors = delete $item->{authors}) {
312 0           my @a;
313 0 0         if (ref $authors) {
314 0           foreach (@$authors) {
315 0 0         push @a, $_->{text} if $_->{text};
316 0 0         if (my $f = $_->{facet}) {
317 0   0       my $f_a = $facets{$f} ||= [];
318 0           push @$f_a, $_->{token};
319             }
320             }
321             }
322             else {
323 0           push @a, $authors;
324             }
325 0           $std_item->{author} = join ", ", @a;
326             }
327 0           foreach my $v (values %$item) {
328 0 0         my $ref = ref $v or next;
329 0 0         $v = [$v] if $ref eq "HASH";
330 0 0         next unless ref($v) eq "ARRAY";
331 0           foreach (@$v) {
332 0 0         if (my $f = $_->{facet}) {
333 0   0       my $f_a = $facets{$f} ||= [];
334 0           push @$f_a, $_->{token};
335             }
336             }
337             }
338 0 0         $std_item->{facets} = \%facets if keys %facets;
339              
340 0           return $std_item;
341             }
342              
343             =head2 named_query_search ($query, $media)
344              
345             See C below for $query, $media
346              
347             =cut
348              
349             sub named_query_search {
350 0     0 1   my $self = shift;
351 0           return $self->_search_result_xlate( $self->native_named_query_search(@_) );
352             }
353              
354             =head2 facet_search ($facets)
355              
356             See C below for $facets
357              
358             =cut
359              
360             sub facet_search {
361 0     0 1   my $self = shift;
362 0           return $self->_search_result_xlate( $self->native_facet_search(@_) );
363             }
364              
365             sub item_metadata {
366 0     0 1   my $self = shift;
367 0 0         my $ni = $self->native_item(@_) or return;
368 0           return $self->_item_xlate( $ni->{item} );
369             }
370              
371             =head1 CIRCULATION METHOD SPECIFICS
372              
373             Differences to general L interface
374              
375             =cut
376              
377             =head2 holds ()
378              
379             =head2 place_hold ($isbn)
380              
381             =head2 remove_hold ($isbn)
382              
383             =cut
384              
385             sub holds {
386 0     0 1   my $self = shift;
387              
388 0           my $items = $self->native_holds(@_);
389             return {
390             total => scalar @$items,
391             items => [ map {
392 0           my $i = $self->_item_xlate($_);
  0            
393 0           $i->{hold_id} = $_->{transactionId};
394 0           $i;
395             } @$items ],
396             };
397             }
398              
399             sub place_hold {
400 0     0 1   my $self = shift;
401 0 0         my $isbn = shift or croak "No isbn";
402              
403 0           my $url = $self->circulation_action_url("/holds/$isbn", @_);
404 0           my $request = HTTP::Request::Common::POST( $url );
405 0           my $response = $self->_request_with_auth($request);
406 0 0         unless ($response->is_success) {
407             $self->process_json_error_response($response, sub {
408 0     0     my ($data) = @_;
409 0 0         if (my $message = $data->{message}) {
410 0 0         return 1 if $message =~ m/already exists/i;
411 0           die $message;
412             }
413 0   0       die $self->_error_from_json($data) || "Cannot place hold: ".to_json($data);
414 0           });
415             }
416              
417 0 0         if (my $holds = $self->holds(@_)) {
418 0           foreach my $i (@{ $holds->{items} }) {
  0            
419 0 0         if ($i->{isbn} eq $isbn) {
420 0           $i->{total} = $holds->{total};
421 0           return $i;
422             }
423             }
424             }
425              
426 0           my $content = $response->decoded_content;
427 0           my $content_type = $response->header('Content-Type');
428 0           my $error;
429 0 0 0       if ($content_type && $content_type =~ m!application/json!) {
430 0 0         if (my $data = eval { from_json( $content ) }) {
  0            
431 0           $error = $self->_error_from_json($data);
432             }
433             }
434              
435 0   0       die $error || "Cannot place hold:\n$content";
436             }
437              
438             sub remove_hold {
439 0     0 1   my $self = shift;
440 0 0         my $isbn = shift or croak "No isbn";
441              
442 0           my $url = $self->circulation_action_url("/holds/$isbn", @_);
443 0           my $request = HTTP::Request::Common::DELETE( $url );
444 0           my $response = $self->_request_with_auth($request);
445 0 0         unless ($response->is_success) {
446             return $self->process_json_error_response($response, sub {
447 0     0     my ($data) = @_;
448 0 0         if (my $message = $data->{message}) {
449 0 0         return 1 if $message =~ m/not exists|expired/i;
450 0           die $message;
451             }
452 0   0       die $self->_error_from_json($data) || "Cannot remove hold: ".to_json($data);
453 0           });
454             }
455 0           return 1;
456             }
457              
458             =head2 checkouts ()
459              
460             =head2 checkout ($isbn, $days)
461              
462             =head2 renew ($isbn)
463              
464             =head2 return ($isbn)
465              
466             =cut
467              
468             sub checkouts {
469 0     0 1   my $self = shift;
470              
471 0           my $items = $self->native_checkouts(@_);
472             return {
473             total => scalar @$items,
474             items => [ map {
475 0           my $i = $self->_item_xlate($_);
  0            
476 0           $i->{checkout_id} = $_->{transactionId};
477 0           $i;
478             } @$items ],
479             };
480             }
481              
482             sub checkout {
483 0     0 1   my $self = shift;
484 0 0         my $isbn = shift or croak "No isbn";
485 0           my $days = shift;
486              
487 0 0         if (my $checkouts = $self->checkouts(@_)) {
488 0           foreach my $i (@{ $checkouts->{items} }) {
  0            
489 0 0         if ( $i->{isbn} eq $isbn ) {
490 0           $i->{total} = scalar @{ $checkouts->{items} };
  0            
491 0           return $i;
492             }
493             }
494             }
495              
496 0           my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
497 0 0         $url .= "?days=$days" if $days;
498 0           my $res = $self->with_post_request(
499             \&_basic_callback,
500             $url
501             );
502              
503 0 0         my $checkouts = $self->checkouts(@_) or die "Cannot checkout, unknown error";
504 0           foreach my $i (@{ $checkouts->{items} }) {
  0            
505 0 0         if ($i->{isbn} eq $isbn) {
506 0           $i->{total} = scalar @{ $checkouts->{items} };
  0            
507 0           return $i;
508             }
509             }
510 0   0       die $res->{message} || "Cannot checkout, unknown error";
511             }
512              
513             sub renew {
514 0     0 1   my $self = shift;
515 0 0         my $isbn = shift or croak "No isbn";
516              
517 0           my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
518 0           my $res = $self->with_put_request(
519             \&_basic_callback,
520             $url
521             );
522              
523 0 0         my $checkouts = $self->checkouts(@_) or die "Cannot renew, unkmown error";
524 0           foreach my $i (@{ $checkouts->{items} }) {
  0            
525 0 0         if ($i->{isbn} eq $isbn) {
526 0           $i->{total} = scalar @{ $checkouts->{items} };
  0            
527 0           return $i;
528             }
529             }
530 0   0       die $res->{output} || "Cannot renew, unknown error";
531             }
532              
533             sub return {
534 0     0 1   my $self = shift;
535 0 0         my $isbn = shift or croak "No isbn";
536              
537 0           my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
538 0           my $request = HTTP::Request::Common::DELETE( $url );
539 0           my $response = $self->_request_with_auth($request);
540 0 0         unless ($response->is_success) {
541             return $self->process_json_error_response($response, sub {
542 0     0     my ($data) = @_;
543 0 0         if (my $message = $data->{message}) {
544 0 0         return 1 if $message =~ m/not exists|expired/i;
545 0           die $message;
546             }
547 0           die "Cannot return: ".to_json($data);
548 0           });
549             }
550 0           return 1;
551             }
552              
553             =head1 NATIVE METHODS
554              
555             =head2 native_search ($params_hashref)
556              
557             See L
558              
559             =cut
560              
561             sub native_search {
562 0     0 1   my $self = shift;
563 0           my $search_params = shift;
564              
565 0           return $self->get_response($self->products_url, $search_params);
566             }
567              
568             =head2 native_named_query_search ($query, $media)
569              
570             $query can be one of 'bestsellers', 'most-popular', 'newly-added'
571             $media can be 'eaudio' or 'ebook'
572              
573             =cut
574              
575             my @MEDIA = qw( eaudio ebook );
576             my @NAMED_QUERY = ( 'bestsellers', 'most-popular', 'newly-added' );
577             sub native_named_query_search {
578 0     0 1   my $self = shift;
579 0 0         my $query = shift or croak "No query";
580 0 0         my $media = shift or croak "No media";
581              
582             croak "Invalid media $media - should be one of ".join(", ", @MEDIA)
583 0 0         unless grep { $_ eq $media } @MEDIA;
  0            
584             croak "Invalid named query $query - should be one of ".join(", ", @NAMED_QUERY)
585 0 0         unless grep { $_ eq $query } @NAMED_QUERY;
  0            
586              
587 0           my $url = $self->products_url."/$media/$query";
588 0           return $self->get_response($url);
589             }
590              
591             =head2 native_facet_search ($facets)
592              
593             $facets can be either:
594             * a hashref of facet => [values],
595             * an arrayref of values
596             * a single value
597              
598             =cut
599              
600             sub native_facet_search {
601 0     0 1   my $self = shift;
602 0 0         my $facets = shift or croak "No facets";
603 0 0         $facets = [$facets] unless ref $facets;
604              
605 0           my $url = $self->products_url;
606 0 0         if (ref ($facets) eq "ARRAY") {
607 0           $url = join "/", $url, @$facets;
608 0           undef $facets;
609             }
610 0           return $self->get_response($url, $facets);
611             }
612              
613             # Item API
614              
615             =head2 native_item ($isbn)
616              
617             =head2 native_item_summary ($isbn)
618              
619             =head3 Returns subset of item fields, with addition of summary field
620              
621             =cut
622              
623             sub native_item {
624 0     0 1   my $self = shift;
625 0 0         my $isbn = shift or croak "No isbn";
626              
627 0           my $url = $self->title_url($isbn);
628 0           return $self->get_response($url);
629             }
630              
631             sub native_item_summary {
632 0     0 1   my $self = shift;
633 0 0         my $isbn = shift or croak "No isbn";
634              
635 0           my $url = $self->title_url("$isbn/summary");
636 0           return $self->get_response($url);
637             }
638              
639             =head2 native_holds ()
640              
641             See L
642              
643             =cut
644              
645             sub native_holds {
646 0     0 1   my $self = shift;
647              
648 0           my $url = $self->circulation_action_url("/holds/all", @_);
649 0           return $self->get_response($url);
650             }
651              
652             =head2 native_checkouts ()
653              
654             =cut
655              
656             sub native_checkouts {
657 0     0 1   my $self = shift;
658              
659 0           my $url = $self->circulation_action_url("/checkouts/all", @_);
660 0           return $self->get_response($url);
661             }
662              
663             # Utility methods
664              
665 0     0     sub _basic_callback { return $_[0]; }
666              
667             sub get_response {
668 0     0 0   my $self = shift;
669 0 0         my $url = shift or croak "No url";
670 0           my $get_params = shift; # hash ref
671              
672 0           return $self->with_get_request(\&_basic_callback, $url, $get_params);
673             }
674              
675             sub _error_from_json {
676 0     0     my $self = shift;
677 0 0         my $data = shift or croak "No json data";
678 0           return join " ", grep defined, $data->{errorCode}, $data->{message};
679             }
680              
681             1;
682              
683             __END__