File Coverage

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


line stmt bran cond sub pod time code
1             package WebService::ILS::RecordedBooks;
2              
3 1     1   521 use Modern::Perl;
  1         3  
  1         7  
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   117 use Carp;
  1         3  
  1         47  
38 1     1   6 use HTTP::Request::Common;
  1         2  
  1         66  
39 1     1   6 use URI::Escape;
  1         2  
  1         47  
40 1     1   635 use JSON qw(to_json);
  1         7815  
  1         5  
41              
42 1     1   174 use parent qw(WebService::ILS::JSON);
  1         6  
  1         9  
43              
44 1     1   62 use constant API_VERSION => "v1";
  1         2  
  1         58  
45 1     1   6 use constant BASE_DOMAIN => "rbdigital.com";
  1         2  
  1         54  
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         5 use Class::Tiny qw(
69             ssl
70             domain
71             _api_base_url
72 1     1   5 );
  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->{available} = $_->{interest}{isAvailable};
258 0           $i;
259 0 0         } @{$res->{items} || []} ],
260             page_size => $res->{pageSize},
261             page => $res->{pageIndex} + 1,
262             pages => $res->{pageCount},
263 0           };
264             }
265              
266             my %SEARCH_RESULT_ITEM_XLATE = (
267             id => "id",
268             title => "title",
269             subtitle => "subtitle",
270             shortDescription => "description",
271             mediaType => "media",
272             downloadUrl => "url",
273             encryptionKey => "encryption_key",
274             isbn => "isbn",
275             hasDrm => "drm",
276             releasedDate => "publication_date",
277             size => "size",
278             language => "language",
279             expiration => "expires",
280             );
281             my %ITEM_FILES_XLATE = (
282             id => "id",
283             filename => "filename",
284             display => "title",
285             downloadUrl => "url",
286             size => "size",
287             );
288             sub _item_xlate {
289 0     0     my $self = shift;
290 0           my $item = shift;
291              
292 0           my $std_item = $self->_result_xlate($item, \%SEARCH_RESULT_ITEM_XLATE);
293              
294 0 0         if (my $images = delete $item->{images}) { # XXX let's say that caller wouldn't mind
295 0           $std_item->{images} = {map { $_->{name} => $_->{url} } @$images};
  0            
296             }
297              
298 0 0         if (my $files = delete $item->{files}) {
299 0           $std_item->{files} = [ map $self->_result_xlate($_, \%ITEM_FILES_XLATE), @$files ];
300             }
301              
302 0           my %facets;
303 0 0         if (my $publisher = delete $item->{publisher}) {
304 0 0         if (ref $publisher) {
305 0 0         if (my $f = $publisher->{facet}) {
306 0           $facets{$f} = [$publisher->{token}];
307             }
308 0           $publisher = $publisher->{text};
309             }
310 0           $std_item->{publisher} = $publisher;
311             }
312 0 0         if (my $authors = delete $item->{authors}) {
313 0           my @a;
314 0 0         if (ref $authors) {
315 0           foreach (@$authors) {
316 0 0         push @a, $_->{text} if $_->{text};
317 0 0         if (my $f = $_->{facet}) {
318 0   0       my $f_a = $facets{$f} ||= [];
319 0           push @$f_a, $_->{token};
320             }
321             }
322             }
323             else {
324 0           push @a, $authors;
325             }
326 0           $std_item->{author} = join ", ", @a;
327             }
328 0           foreach my $v (values %$item) {
329 0 0         my $ref = ref $v or next;
330 0 0         $v = [$v] if $ref eq "HASH";
331 0 0         next unless ref($v) eq "ARRAY";
332 0           foreach (@$v) {
333 0 0         if (my $f = $_->{facet}) {
334 0   0       my $f_a = $facets{$f} ||= [];
335 0           push @$f_a, $_->{token};
336             }
337             }
338             }
339 0 0         $std_item->{facets} = \%facets if keys %facets;
340              
341 0           return $std_item;
342             }
343              
344             =head2 named_query_search ($query, $media)
345              
346             See C below for $query, $media
347              
348             =cut
349              
350             sub named_query_search {
351 0     0 1   my $self = shift;
352 0           return $self->_search_result_xlate( $self->native_named_query_search(@_) );
353             }
354              
355             =head2 facet_search ($facets)
356              
357             See C below for $facets
358              
359             =cut
360              
361             sub facet_search {
362 0     0 1   my $self = shift;
363 0           return $self->_search_result_xlate( $self->native_facet_search(@_) );
364             }
365              
366             sub item_metadata {
367 0     0 1   my $self = shift;
368 0 0         my $ni = $self->native_item(@_) or return;
369 0           return $self->_item_xlate( $ni->{item} );
370             }
371              
372             =head1 CIRCULATION METHOD SPECIFICS
373              
374             Differences to general L interface
375              
376             =cut
377              
378             =head2 holds ()
379              
380             =head2 place_hold ($isbn)
381              
382             =head2 remove_hold ($isbn)
383              
384             =cut
385              
386             sub holds {
387 0     0 1   my $self = shift;
388              
389 0           my $items = $self->native_holds(@_);
390             return {
391             total => scalar @$items,
392             items => [ map {
393 0           my $i = $self->_item_xlate($_);
  0            
394 0           $i->{hold_id} = $_->{transactionId};
395 0           $i;
396             } @$items ],
397             };
398             }
399              
400             sub place_hold {
401 0     0 1   my $self = shift;
402 0 0         my $isbn = shift or croak "No isbn";
403              
404 0           my $url = $self->circulation_action_url("/holds/$isbn", @_);
405 0           my $request = HTTP::Request::Common::POST( $url );
406 0           my $response = $self->_request_with_auth($request);
407 0 0         unless ($response->is_success) {
408             $self->process_json_error_response($response, sub {
409 0     0     my ($data) = @_;
410 0 0         if (my $message = $data->{message}) {
411 0 0         return 1 if $message =~ m/already exists/i;
412 0           die $message;
413             }
414 0   0       die $self->_error_from_json($data) || "Cannot place hold: ".to_json($data);
415 0           });
416             }
417              
418 0 0         if (my $holds = $self->holds(@_)) {
419 0           foreach my $i (@{ $holds->{items} }) {
  0            
420 0 0         if ($i->{isbn} eq $isbn) {
421 0           $i->{total} = $holds->{total};
422 0           return $i;
423             }
424             }
425             }
426              
427 0           my $content = $response->decoded_content;
428 0           my $content_type = $response->header('Content-Type');
429 0           my $error;
430 0 0 0       if ($content_type && $content_type =~ m!application/json!) {
431 0 0         if (my $data = eval { from_json( $content ) }) {
  0            
432 0           $error = $self->_error_from_json($data);
433             }
434             }
435              
436 0   0       die $error || "Cannot place hold:\n$content";
437             }
438              
439             sub remove_hold {
440 0     0 1   my $self = shift;
441 0 0         my $isbn = shift or croak "No isbn";
442              
443 0           my $url = $self->circulation_action_url("/holds/$isbn", @_);
444 0           my $request = HTTP::Request::Common::DELETE( $url );
445 0           my $response = $self->_request_with_auth($request);
446 0 0         unless ($response->is_success) {
447             return $self->process_json_error_response($response, sub {
448 0     0     my ($data) = @_;
449 0 0         if (my $message = $data->{message}) {
450 0 0         return 1 if $message =~ m/not exists|expired/i;
451 0           die $message;
452             }
453 0   0       die $self->_error_from_json($data) || "Cannot remove hold: ".to_json($data);
454 0           });
455             }
456 0           return 1;
457             }
458              
459             =head2 checkouts ()
460              
461             =head2 checkout ($isbn, $days)
462              
463             =head2 renew ($isbn)
464              
465             =head2 return ($isbn)
466              
467             =cut
468              
469             sub checkouts {
470 0     0 1   my $self = shift;
471              
472 0           my $items = $self->native_checkouts(@_);
473             return {
474             total => scalar @$items,
475             items => [ map {
476 0           my $i = $self->_item_xlate($_);
  0            
477 0           $i->{checkout_id} = $_->{transactionId};
478 0           $i;
479             } @$items ],
480             };
481             }
482              
483             sub checkout {
484 0     0 1   my $self = shift;
485 0 0         my $isbn = shift or croak "No isbn";
486 0           my $days = shift;
487              
488 0 0         if (my $checkouts = $self->checkouts(@_)) {
489 0           foreach my $i (@{ $checkouts->{items} }) {
  0            
490 0 0         if ( $i->{isbn} eq $isbn ) {
491 0           $i->{total} = scalar @{ $checkouts->{items} };
  0            
492 0           return $i;
493             }
494             }
495             }
496              
497 0           my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
498 0 0         $url .= "?days=$days" if $days;
499 0           my $res = $self->with_post_request(
500             \&_basic_callback,
501             $url
502             );
503              
504 0 0         my $checkouts = $self->checkouts(@_) or die "Cannot checkout, unknown error";
505 0           foreach my $i (@{ $checkouts->{items} }) {
  0            
506 0 0         if ($i->{isbn} eq $isbn) {
507 0           $i->{total} = scalar @{ $checkouts->{items} };
  0            
508 0           return $i;
509             }
510             }
511 0   0       die $res->{message} || "Cannot checkout, unknown error";
512             }
513              
514             sub renew {
515 0     0 1   my $self = shift;
516 0 0         my $isbn = shift or croak "No isbn";
517              
518 0           my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
519 0           my $res = $self->with_put_request(
520             \&_basic_callback,
521             $url
522             );
523              
524 0 0         my $checkouts = $self->checkouts(@_) or die "Cannot renew, unkmown error";
525 0           foreach my $i (@{ $checkouts->{items} }) {
  0            
526 0 0         if ($i->{isbn} eq $isbn) {
527 0           $i->{total} = scalar @{ $checkouts->{items} };
  0            
528 0           return $i;
529             }
530             }
531 0   0       die $res->{output} || "Cannot renew, unknown error";
532             }
533              
534             sub return {
535 0     0 1   my $self = shift;
536 0 0         my $isbn = shift or croak "No isbn";
537              
538 0           my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
539 0           my $request = HTTP::Request::Common::DELETE( $url );
540 0           my $response = $self->_request_with_auth($request);
541 0 0         unless ($response->is_success) {
542             return $self->process_json_error_response($response, sub {
543 0     0     my ($data) = @_;
544 0 0         if (my $message = $data->{message}) {
545 0 0         return 1 if $message =~ m/not exists|expired/i;
546 0           die $message;
547             }
548 0           die "Cannot return: ".to_json($data);
549 0           });
550             }
551 0           return 1;
552             }
553              
554             =head1 NATIVE METHODS
555              
556             =head2 native_search ($params_hashref)
557              
558             See L
559              
560             =cut
561              
562             sub native_search {
563 0     0 1   my $self = shift;
564 0           my $search_params = shift;
565              
566 0           return $self->get_response($self->products_url, $search_params);
567             }
568              
569             =head2 native_named_query_search ($query, $media)
570              
571             $query can be one of 'bestsellers', 'most-popular', 'newly-added'
572             $media can be 'eaudio' or 'ebook'
573              
574             =cut
575              
576             my @MEDIA = qw( eaudio ebook );
577             my @NAMED_QUERY = ( 'bestsellers', 'most-popular', 'newly-added' );
578             sub native_named_query_search {
579 0     0 1   my $self = shift;
580 0 0         my $query = shift or croak "No query";
581 0 0         my $media = shift or croak "No media";
582              
583             croak "Invalid media $media - should be one of ".join(", ", @MEDIA)
584 0 0         unless grep { $_ eq $media } @MEDIA;
  0            
585             croak "Invalid named query $query - should be one of ".join(", ", @NAMED_QUERY)
586 0 0         unless grep { $_ eq $query } @NAMED_QUERY;
  0            
587              
588 0           my $url = $self->products_url."/$media/$query";
589 0           return $self->get_response($url);
590             }
591              
592             =head2 native_facet_search ($facets)
593              
594             $facets can be either:
595             * a hashref of facet => [values],
596             * an arrayref of values
597             * a single value
598              
599             =cut
600              
601             sub native_facet_search {
602 0     0 1   my $self = shift;
603 0 0         my $facets = shift or croak "No facets";
604 0 0         $facets = [$facets] unless ref $facets;
605              
606 0           my $url = $self->products_url;
607 0 0         if (ref ($facets) eq "ARRAY") {
608 0           $url = join "/", $url, @$facets;
609 0           undef $facets;
610             }
611 0           return $self->get_response($url, $facets);
612             }
613              
614             # Item API
615              
616             =head2 native_item ($isbn)
617              
618             =head2 native_item_summary ($isbn)
619              
620             =head3 Returns subset of item fields, with addition of summary field
621              
622             =cut
623              
624             sub native_item {
625 0     0 1   my $self = shift;
626 0 0         my $isbn = shift or croak "No isbn";
627              
628 0           my $url = $self->title_url($isbn);
629 0           return $self->get_response($url);
630             }
631              
632             sub native_item_summary {
633 0     0 1   my $self = shift;
634 0 0         my $isbn = shift or croak "No isbn";
635              
636 0           my $url = $self->title_url("$isbn/summary");
637 0           return $self->get_response($url);
638             }
639              
640             =head2 native_holds ()
641              
642             See L
643              
644             =cut
645              
646             sub native_holds {
647 0     0 1   my $self = shift;
648              
649 0           my $url = $self->circulation_action_url("/holds/all", @_);
650 0           return $self->get_response($url);
651             }
652              
653             =head2 native_checkouts ()
654              
655             =cut
656              
657             sub native_checkouts {
658 0     0 1   my $self = shift;
659              
660 0           my $url = $self->circulation_action_url("/checkouts/all", @_);
661 0           return $self->get_response($url);
662             }
663              
664             # Utility methods
665              
666 0     0     sub _basic_callback { return $_[0]; }
667              
668             sub get_response {
669 0     0 0   my $self = shift;
670 0 0         my $url = shift or croak "No url";
671 0           my $get_params = shift; # hash ref
672              
673 0           return $self->with_get_request(\&_basic_callback, $url, $get_params);
674             }
675              
676             sub _error_from_json {
677 0     0     my $self = shift;
678 0 0         my $data = shift or croak "No json data";
679 0           return join " ", grep defined, $data->{errorCode}, $data->{message};
680             }
681              
682             1;
683              
684             __END__