File Coverage

blib/lib/WebService/ILS/OverDrive/Patron.pm
Criterion Covered Total %
statement 42 283 14.8
branch 0 156 0.0
condition 0 19 0.0
subroutine 14 52 26.9
pod 22 26 84.6
total 78 536 14.5


line stmt bran cond sub pod time code
1             # Copyright 2015 Catalyst
2              
3             package WebService::ILS::OverDrive::Patron;
4              
5 2     2   1313 use Modern::Perl;
  2         4  
  2         13  
6              
7             =encoding utf-8
8              
9             =head1 NAME
10              
11             WebService::ILS::OverDrive::Patron - WebService::ILS module for OverDrive
12             circulation services
13              
14             =head1 SYNOPSIS
15              
16             use WebService::ILS::OverDrive::Patron;
17              
18             =head1 DESCRIPTION
19              
20             These services require individual user credentials.
21             See L
22              
23             See L
24              
25             =cut
26              
27 2     2   260 use Carp;
  2         3  
  2         150  
28 2     2   599 use HTTP::Request::Common;
  2         17727  
  2         135  
29 2     2   16 use URI::Escape;
  2         5  
  2         93  
30 2     2   11 use Data::Dumper;
  2         4  
  2         89  
31              
32 2     2   245 use parent qw(WebService::ILS::OverDrive);
  2         218  
  2         12  
33              
34 2     2   100 use constant CIRCULATION_API_URL => "http://patron.api.overdrive.com/";
  2         4  
  2         128  
35 2     2   11 use constant TEST_CIRCULATION_API_URL => "http://integration-patron.api.overdrive.com/";
  2         4  
  2         95  
36 2     2   10 use constant OAUTH_BASE_URL => "https://oauth.overdrive.com/";
  2         4  
  2         102  
37 2     2   12 use constant TOKEN_URL => OAUTH_BASE_URL . 'token';
  2         3  
  2         83  
38 2     2   14 use constant AUTH_URL => OAUTH_BASE_URL . 'auth';
  2         4  
  2         173  
39              
40             =head1 CONSTRUCTOR
41              
42             =head2 new (%params_hash or $params_hashref)
43              
44             =head3 Additional constructor params:
45              
46             =over 16
47              
48             =item C => auth token as previously obtained
49              
50             =back
51              
52             =cut
53              
54             use Class::Tiny qw(
55             user_id password website_id authorization_name
56             auth_token
57             ), {
58 0 0       0 _circulation_api_url => sub { $_[0]->test ? TEST_CIRCULATION_API_URL : CIRCULATION_API_URL },
59 2     2   11 };
  2         5  
  2         15  
60              
61             __PACKAGE__->_set_param_spec({
62             auth_token => { required => 0 },
63             });
64              
65             =head1 INDIVIDUAL USER AUTHENTICATION METHODS
66              
67             =head2 auth_by_user_id ($user_id, $password, $website_id, $authorization_name)
68              
69             C and C (domain) are provided by OverDrive
70              
71             =head3 Returns (access_token, access_token_type) or access_token
72              
73             =cut
74              
75             sub auth_by_user_id {
76 0     0 1   my $self = shift;
77 0 0         my $user_id = shift or croak "No user id";
78 0           my $password = shift; # can be blank
79 0 0         my $website_id = shift or croak "No website id";
80 0 0         my $authorization_name = shift or croak "No authorization name";
81              
82 0           my $request = $self->_make_access_token_by_user_id_request($user_id, $password, $website_id, $authorization_name);
83 0           $self->_request_access_token($request);
84              
85 0           $self->user_id($user_id);
86 0           $self->password($password);
87 0           $self->website_id($website_id);
88 0           $self->authorization_name($authorization_name);
89 0 0         return wantarray ? ($self->access_token, $self->access_token_type) : $self->access_token;
90             }
91              
92             sub _make_access_token_by_user_id_request {
93 0     0     my $self = shift;
94 0 0         my $user_id = shift or croak "No user id";
95 0           my $password = shift; # can be blank
96 0 0         my $website_id = shift or croak "No website id";
97 0 0         my $authorization_name = shift or croak "No authorization name";
98              
99 0           my %params = (
100             grant_type => 'password',
101             username => $user_id,
102             scope => "websiteid:".$website_id." authorizationname:".$authorization_name,
103             );
104 0 0         if ($password) {
105 0           $params{password} = $password;
106             } else {
107 0           $params{password} = "[ignore]";
108 0           $params{password_required} = "false";
109             }
110 0           return HTTP::Request::Common::POST( 'https://oauth-patron.overdrive.com/patrontoken', \%params );
111             }
112              
113             =head2 Authentication at OverDrive - Granted or "3-Legged" Authorization
114              
115             With OverDrive there's an extra step - an auth code is returned to the
116             redirect back handler that needs to make an API call to convert it into
117             a auth token.
118              
119             An example:
120              
121             my $overdrive = WebService::ILS::OverDrive::Patron({
122             client_id => $client_id,
123             client_secret => $client_secret,
124             library_id => $library_id,
125             });
126             my $redirect_url = $overdrive->auth_url("http://myapp.com/overdrive-auth");
127             $response->redirect($redirect_url);
128             ...
129             /overdrive-auth handler:
130             my $auth_code = $req->param( $overdrive->auth_code_param_name )
131             or some_error_handling(), return;
132             # my $state = $req->param( $overdrive->state_token_param_name )...
133             local $@;
134             eval { $overdrive->auth_by_code( $auth_code ) };
135             if ($@) { some_error_handling(); return; }
136             $session{overdrive_access_token} = $access_token;
137             $session{overdrive_access_token_type} = $access_token_type;
138             $session{overdrive_auth_token} = $auth_token;
139             ...
140             Somewhere else in your app:
141             my $ils = WebService::ILS::Provider({
142             client_id => $client_id,
143             client_secret => $client_secret,
144             access_token => $session{overdrive_access_token},
145             access_token_type => $session{overdrive_access_token_type},
146             auth_token = $session{overdrive_auth_token}
147             });
148             my $checkouts = $overdrive->checkouts;
149              
150             =head2 auth_url ($redirect_uri, $state_token)
151              
152             =head3 Input params:
153              
154             =over 18
155              
156             =item C => return url which will handle redirect back after auth
157              
158             =item C => a token that is returned back unchanged;
159              
160             for additional security; not required
161              
162             =back
163              
164             =cut
165              
166             sub auth_url {
167 0     0 1   my $self = shift;
168 0 0         my $redirect_uri = shift or croak "Redirect URI not specified";
169 0           my $state_token = shift;
170              
171 0 0         my $library_id = $self->library_id or croak "No Library Id";
172              
173 0 0         return sprintf AUTH_URL .
174             "?client_id=%s" .
175             "&redirect_uri=%s" .
176             "&scope=%s" .
177             "&response_type=code" .
178             "&state=%s",
179             map uri_escape($_),
180             $self->client_id,
181             $redirect_uri,
182             "accountid:$library_id",
183             defined ($state_token) ? $state_token : ""
184             ;
185             }
186              
187             =head2 auth_code_param_name ()
188              
189             =head2 state_token_param_name ()
190              
191             =cut
192              
193 2     2   2030 use constant auth_code_param_name => "code";
  2         6  
  2         140  
194 2     2   12 use constant state_token_param_name => "code";
  2         4  
  2         5132  
195              
196             =head2 auth_by_code ($provider_code, $redirect_uri)
197              
198             =head3 Returns (access_token, access_token_type, auth_token) or access_token
199              
200             =cut
201              
202             sub auth_by_code {
203 0     0 1   my $self = shift;
204 0 0         my $code = shift or croak "No authorization code";
205 0 0         my $redirect_uri = shift or croak "Redirect URI not specified";
206              
207 0           my $auth_type = 'authorization_code';
208              
209 0           my $request = HTTP::Request::Common::POST( TOKEN_URL, {
210             grant_type => 'authorization_code',
211             code => $code,
212             redirect_uri => $redirect_uri,
213             } );
214 0           $self->_request_access_token($request);
215 0 0         return wantarray ? ($self->access_token, $self->access_token_type, $self->auth_token) : $self->access_token;
216             }
217              
218             =head2 auth_by_token ($provider_token)
219              
220             =head3 Returns (access_token, access_token_type, auth_token) or access_token
221              
222             =cut
223              
224             sub auth_by_token {
225 0     0 1   my $self = shift;
226 0 0         my $auth_token = shift or croak "No authorization token";
227              
228 0           $self->auth_token($auth_token);
229 0           my $request = $self->_make_access_token_by_auth_token_request($auth_token);
230 0           $self->_request_access_token($request);
231              
232 0 0         return wantarray ? ($self->access_token, $self->access_token_type, $self->auth_token) : $self->access_token;
233             }
234              
235             sub _make_access_token_by_auth_token_request {
236 0     0     my $self = shift;
237 0 0         my $auth_token = shift or croak "No authorization token";
238              
239 0           return HTTP::Request::Common::POST( TOKEN_URL, {
240             grant_type => 'refresh_token',
241             refresh_token => $auth_token,
242             } );
243             }
244              
245             sub make_access_token_request {
246 0     0 0   my $self = shift;
247              
248 0 0         if (my $auth_token = $self->auth_token) {
    0          
249 0           return $self->_make_access_token_by_auth_token_request($auth_token);
250             }
251             elsif (my $user_id = $self->user_id) {
252 0           return $self->_make_access_token_by_user_id_request(
253             $user_id, $self->password, $self->website_id, $self->authorization_name
254             );
255             }
256            
257 0           die $self->ERROR_NOT_AUTHENTICATED."\n";
258             }
259              
260             sub _request_access_token {
261 0     0     my $self = shift;
262 0 0         my $request = shift or croak "No request";
263              
264 0 0         my $data = $self->SUPER::_request_access_token($request)
265             or die "Unsuccessful access token request";
266              
267 0 0         if (my $auth_token = $data->{refresh_token}) {
268 0           $self->auth_token($auth_token);
269             }
270              
271 0           return $data;
272             }
273              
274             sub collection_token {
275 0     0 0   my $self = shift;
276              
277 0 0         if (my $collection_token = $self->SUPER::collection_token) {
278 0           return $collection_token;
279             }
280            
281 0           $self->native_patron; # sets collection_token as a side-effect
282 0 0         my $collection_token = $self->SUPER::collection_token
283             or die "Patron has no collections\n";
284 0           return $collection_token;
285             }
286              
287             =head1 CIRCULATION METHOD SPECIFICS
288              
289             Differences to general L interface
290              
291             =cut
292              
293             my %PATRON_XLATE = (
294             checkoutLimit => "checkout_limit",
295             existingPatron => 'active',
296             patronId => 'id',
297             holdLimit => 'hold_limit',
298             );
299             sub patron {
300 0     0 1   my $self = shift;
301 0           return $self->_result_xlate($self->native_patron, \%PATRON_XLATE);
302             }
303              
304             my %HOLDS_XLATE = (
305             totalItems => 'total',
306             );
307             my %HOLDS_ITEM_XLATE = (
308             reserveId => 'id',
309             holdPlacedDate => 'placed_datetime',
310             holdListPosition => 'queue_position',
311             );
312             sub holds {
313 0     0 1   my $self = shift;
314              
315 0           my $holds = $self->native_holds;
316 0   0       my $items = delete ($holds->{holds}) || [];
317              
318 0           my $res = $self->_result_xlate($holds, \%HOLDS_XLATE);
319             $res->{items} = [
320             map {
321 0           my $item = $self->_result_xlate($_, \%HOLDS_ITEM_XLATE);
  0            
322 0           my $item_id = $item->{id};
323 0           my $metadata = $self->item_metadata($item_id);
324 0           my $i = {%$item, %$metadata}; # we need my $i, don't ask me why...
325             } @$items
326             ];
327 0           return $res;
328             }
329              
330             =head2 place_hold ($item_id, $notification_email_address, $auto_checkout)
331              
332             C<$notification_email_address> and C<$auto_checkout> are optional.
333             C<$auto_checkout> defaults to false.
334              
335             =head3 Returns holds item record
336              
337             It is prefered that the C<$notification_email_address> is specified.
338              
339             If C<$auto_checkout> is set to true, the item will be checked out as soon as
340             it becomes available.
341              
342             =cut
343              
344             sub place_hold {
345 0     0 1   my $self = shift;
346              
347 0 0         my $hold = $self->native_place_hold(@_) or return;
348 0           my $res = $self->_result_xlate($hold, \%HOLDS_ITEM_XLATE);
349 0           $res->{total} = $hold->{numberOfHolds};
350 0           return $res;
351             }
352              
353             # sub suspend_hold { - not really useful
354              
355             sub remove_hold {
356 0     0 1   my $self = shift;
357 0 0         my $item_id = shift or croak "No item id";
358              
359 0           my $url = $self->circulation_action_url("/holds/$item_id");
360             return $self->with_delete_request(
361             \&_basic_callback,
362             sub {
363 0     0     my ($data) = @_;
364 0 0         return 1 if $data->{errorCode} eq "PatronDoesntHaveTitleOnHold";
365 0   0       die ($data->{message} || $data->{errorCode})."\n";
366             },
367 0           $url
368             );
369             }
370              
371             =head2 checkouts ()
372              
373             For formats see C below
374              
375             =cut
376              
377             my %CHECKOUTS_XLATE = (
378             totalItems => 'total',
379             totalCheckouts => 'total_format',
380             );
381             sub checkouts {
382 0     0 1   my $self = shift;
383              
384 0           my $checkouts = $self->native_checkouts;
385 0   0       my $items = delete ($checkouts->{checkouts}) || [];
386              
387 0           my $res = $self->_result_xlate($checkouts, \%CHECKOUTS_XLATE);
388             $res->{items} = [
389             map {
390 0           my $item = $self->_checkout_item_xlate($_);
  0            
391 0           my $item_id = $item->{id};
392 0           my $formats = delete ($_->{formats});
393 0           my $actions = delete ($_->{actions});
394 0           my $metadata = $self->item_metadata($item_id);
395 0 0         if ($formats) {
396 0           $formats = $self->_formats_xlate($item_id, $formats);
397             }
398             else {
399 0           $formats = {};
400             }
401 0 0         if ($actions) {
402 0 0         if (my $format_action = $actions->{format}) {
403 0           foreach (@{$format_action->{fields}}) {
  0            
404 0 0         next unless $_->{name} eq "formatType";
405              
406 0           foreach my $format (@{$_->{options}}) {
  0            
407 0 0         $formats->{$format} = undef unless exists $formats->{$format};
408             }
409 0           last;
410             }
411             }
412             }
413 0           my $i = {%$item, %$metadata, formats => $formats}; # we need my $i, don't ask me why...
414             } @$items
415             ];
416 0           return $res;
417             }
418              
419             my %CHECKOUT_ITEM_XLATE = (
420             reserveId => 'id',
421             checkoutDate => 'checkout_datetime',
422             expires => 'expires',
423             );
424             sub _checkout_item_xlate {
425 0     0     my $self = shift;
426 0           my $item = shift;
427              
428 0           my $i = $self->_result_xlate($item, \%CHECKOUT_ITEM_XLATE);
429 0 0         if ($item->{isFormatLockedIn}) {
430 0 0         my $formats = $item->{formats} or die "Item $item->{reserveId}: Format locked in, but no formats returned\n";
431 0           $i->{format} = $formats->[0]{formatType};
432             }
433 0           return $i;
434             }
435              
436             =head2 checkout ($item_id, $format, $allow_multiple_format_checkouts)
437              
438             C<$format> and C<$allow_multiple_format_checkouts> are optional.
439             C<$allow_multiple_format_checkouts> defaults to false.
440              
441             =head3 Returns checkout item record
442              
443             An item can be available in multiple formats. Checkout is complete only
444             when the format is specified.
445              
446             Checkout can be actioned without format being specified. In that case an
447             early return can be actioned. To complete checkout format must be locked
448             later (see L below). That would be the case with
449             L with C<$auto_checkout> set to true. Once format is locked,
450             an early return is not possible.
451              
452             If C<$allow_multiple_format_checkouts> flag is set to true, mutiple formats
453             of the same item can be acioned. If it is false (default) and the item was
454             already checked out, the checked out item record will be returned regardless
455             of the format.
456              
457             Checkout record will have an extra field C if format is locked in.
458              
459             =cut
460              
461             sub checkout {
462 0     0 1   my $self = shift;
463              
464 0 0         my $checkout = $self->native_checkout(@_) or return;
465 0           return $self->_checkout_item_xlate($checkout);
466             }
467              
468             =head2 checkout_formats ($item_id)
469              
470             =head3 Returns a hashref of available title formats and immediate availability
471              
472             { format => available, ... }
473              
474             If format is not immediately available it must be locked first
475              
476             =cut
477              
478             sub checkout_formats {
479 0     0 1   my $self = shift;
480 0 0         my $id = shift or croak "No item id";
481              
482 0 0         my $formats = $self->native_checkout_formats($id) or return;
483 0 0         $formats = $formats->{'formats'} or return;
484 0           return $self->_formats_xlate($id, $formats);
485             }
486              
487             sub _formats_xlate {
488 0     0     my $self = shift;
489 0 0         my $id = shift or croak "No item id";
490 0 0         my $formats = shift or croak "No formats";
491              
492 0           my %ret;
493 0           my $id_uc = uc $id;
494 0           foreach (@$formats) {
495 0 0         die "Non-matching item id\nExpected $id\nGot $_->{reserveId}" unless uc($_->{reserveId}) eq $id_uc;
496 0           my $format = $_->{formatType};
497 0           my $available;
498 0 0         if (my $lt = $_->{linkTemplates}) {
499 0           $available = grep /^downloadLink/, keys %$lt;
500             }
501 0           $ret{$format} = $available;
502             }
503 0           return \%ret;
504             }
505              
506             sub is_lockable {
507 0     0 0   my $self = shift;
508 0 0         my $checkout_formats = shift or croak "No checkout formats";
509 0           while (my ($format, $available) = each %$checkout_formats) {
510 0 0         return 1 unless $available;
511             }
512 0           return 0;
513             }
514              
515             =head2 lock_format ($item_id, $format)
516              
517             =head3 Returns locked format (should be the same as the input value)
518              
519             =cut
520              
521             sub lock_format {
522 0     0 1   my $self = shift;
523 0 0         my $item_id = shift or croak "No item id";
524 0 0         my $format = shift or croak "No format";
525              
526 0 0         my $lock = $self->native_lock_format($item_id, $format) or return;
527 0 0         die "Non-matching item id\nExpected $item_id\nGot $lock->{reserveId}" unless uc($lock->{reserveId}) eq uc($item_id);
528 0           return $lock->{formatType};
529             }
530              
531             =head2 checkout_download_url ($item_id, $format, $error_url, $success_url)
532              
533             =head3 Returns OverDrive download url
534              
535             Checked out items must be downloaded by users on the OverDrive site.
536             This method returns the url where the user should be sent to (redirected).
537             Once the download is complete, user will be redirected back to
538             C<$error_url> in case of an error, otherwise to optional C<$success_url>
539             if specified.
540              
541             See L
542              
543             =cut
544              
545             sub checkout_download_url {
546 0     0 1   my $self = shift;
547 0 0         my $item_id = shift or croak "No item id";
548 0 0         my $format = shift or croak "No format";
549 0 0         my $error_url = shift or die "No error url";
550 0           my $success_url = shift;
551              
552 0           $error_url = uri_escape($error_url);
553 0 0         $success_url = $success_url ? uri_escape($success_url) : '';
554 0           my $url = $self->circulation_action_url("/checkouts/$item_id/formats/$format/downloadlink?errorurl=$error_url&successurl=$success_url");
555 0           my $response_data = $self->get_response($url);
556 0 0 0       my $download_url =
557             _extract_link($response_data, 'contentLink') ||
558             _extract_link($response_data, 'contentlink')
559             or die "Cannot get download url\n".Dumper($response_data);
560 0           return $download_url;
561             }
562              
563             sub return {
564 0     0 1   my $self = shift;
565 0 0         my $item_id = shift or croak "No item id";
566              
567 0           my $url = $self->circulation_action_url("/checkouts/$item_id");
568             return $self->with_delete_request(
569             \&_basic_callback,
570             sub {
571 0     0     my ($data) = @_;
572 0 0         return 1 if $data->{errorCode} eq "PatronDoesntHaveTitleCheckedOut";
573 0   0       die ($data->{message} || $data->{errorCode})."\n";
574             },
575 0           $url
576             );
577             }
578              
579             =head1 NATIVE METHODS
580              
581             =head2 native_patron ()
582              
583             See L
584              
585             =cut
586              
587             sub native_patron {
588 0     0 1   my $self = shift;
589              
590 0           my $url = $self->circulation_action_url("");
591 0 0         my $patron = $self->get_response($url) or return;
592 0 0         if (my $collection_token = $patron->{collectionToken}) {
593 0           $self->SUPER::collection_token( $collection_token);
594             }
595 0           return $patron;
596             }
597              
598             =head2 native_holds ()
599              
600             =head2 native_place_hold ($item_id, $notification_email_address, $auto_checkout)
601              
602             See L
603              
604             =cut
605              
606             sub native_holds {
607 0     0 1   my $self = shift;
608 0           my $url = $self->circulation_action_url("/holds");
609 0           return $self->get_response($url);
610             }
611              
612             sub native_place_hold {
613 0     0 1   my $self = shift;
614 0 0         my $item_id = shift or croak "No item id";
615 0           my $email = shift;
616 0           my $auto_checkout = shift;
617              
618 0           my @fields = ( {name => "reserveId", value => $item_id } );
619 0 0         push @fields, {name => "autoCheckout", value => "true"} if $auto_checkout;
620 0 0         if ($email) {
621 0           push @fields, {name => "emailAddress", value => $email};
622             } else {
623 0           push @fields, {name => "ignoreHoldEmail", value => "true"};
624             }
625              
626 0           my $url = $self->circulation_action_url("/holds");
627             return $self->with_json_request(
628             \&_basic_callback,
629             sub {
630 0     0     my ($data) = @_;
631 0 0         if ($data->{errorCode} eq "AlreadyOnWaitList") {
632 0 0         if (my $holds = $self->native_holds) {
633 0           my $item_id_uc = uc $item_id;
634 0 0         foreach (@{ $holds->{holds} || [] }) {
  0            
635 0 0         if ( uc($_->{reserveId}) eq $item_id_uc ) {
636 0           $_->{numberOfHolds} = $holds->{totalItems};
637 0           return $_;
638             }
639             }
640             }
641             }
642              
643 0   0       die ($data->{message} || $data->{errorCode})."\n";
644             },
645 0           $url,
646             {fields => \@fields}
647             );
648             }
649              
650             =head2 native_checkouts ()
651              
652             =head2 native_checkout_info ($item_id)
653              
654             =head2 native_checkout ($item_id, $format, $allow_multiple_format_checkouts)
655              
656             =head2 native_checkout_formats ($item_id)
657              
658             =head2 native_lock_format ($item_id, $format)
659              
660             See L
661              
662             =cut
663              
664             sub native_checkouts {
665 0     0 1   my $self = shift;
666              
667 0           my $url = $self->circulation_action_url("/checkouts");
668 0           return $self->get_response($url);
669             }
670              
671             sub native_checkout_info {
672 0     0 1   my $self = shift;
673 0 0         my $id = shift or croak "No item id";
674              
675 0           my $url = $self->circulation_action_url("/checkouts/$id");
676 0           return $self->get_response($url);
677             }
678              
679             sub native_checkout_formats {
680 0     0 1   my $self = shift;
681 0 0         my $id = shift or croak "No item id";
682              
683 0           my $url = $self->circulation_action_url("/checkouts/$id/formats");
684 0           return $self->get_response($url);
685             }
686              
687             sub native_checkout {
688 0     0 1   my $self = shift;
689 0 0         my $item_id = shift or croak "No item id";
690 0           my $format = shift;
691 0           my $allow_multi = shift;
692              
693 0 0         if (my $checkouts = $self->native_checkouts) {
694 0           my $item_id_uc = uc $item_id;
695 0 0         foreach (@{ $checkouts->{checkouts} || [] }) {
  0            
696 0 0         if ( uc($_->{reserveId}) eq $item_id_uc ) {
697 0 0         if ($format) {
698 0 0         if ($_->{isFormatLockedIn}) {
699 0 0         return $_ if lc($_->{formats}[0]{formatType}) eq lc($format);
700 0 0         die "Item $item_id has already been locked for different format '$_->{formats}[0]{formatType}'\n"
701             unless $allow_multi;
702             }
703             # else { $self->native_lock_format()? }
704             }
705             # else { die if !$allow_multi ? }
706 0           return $_;
707             }
708             }
709             }
710              
711 0           my $url = $self->circulation_action_url("/checkouts");
712 0           return $self->with_json_request(
713             \&_basic_callback,
714             undef,
715             $url,
716             {fields => _build_checkout_fields($item_id, $format)}
717             );
718             }
719              
720             sub native_lock_format {
721 0     0 1   my $self = shift;
722 0 0         my $item_id = shift or croak "No item id";
723 0 0         my $format = shift or croak "No format";
724              
725 0           my $url = $self->circulation_action_url("/checkouts/$item_id/formats");
726             return $self->with_json_request(
727             \&_basic_callback,
728             sub {
729 0     0     my ($data) = @_;
730 0   0       die "$format ".($data->{message} || $data->{errorCode})."\n";
731             },
732 0           $url,
733             {fields => _build_checkout_fields($item_id, $format)}
734             );
735             }
736              
737             sub _build_checkout_fields {
738 0     0     my ($id, $format) = @_;
739 0           my @fields = ( {name => "reserveId", value => $id } );
740 0 0         push @fields, {name => "formatType", value => $format} if $format;
741 0           return \@fields;
742             }
743              
744             # Circulation helpers
745              
746             sub circulation_action_url {
747 0     0 0   my $self = shift;
748 0           my $action = shift;
749 0           return $self->_circulation_api_url.$self->API_VERSION."/patrons/me$action";
750             }
751              
752             # API helpers
753              
754             sub _extract_link {
755 0     0     my ($data, $link) = @_;
756 0           return $data->{links}{$link}->{href};
757             }
758              
759 0     0     sub _basic_callback { return $_[0]; }
760              
761             1;
762              
763             __END__