File Coverage

blib/lib/WebService/ILS/OverDrive/Patron.pm
Criterion Covered Total %
statement 42 281 14.9
branch 0 156 0.0
condition 0 19 0.0
subroutine 14 52 26.9
pod 22 26 84.6
total 78 534 14.6


line stmt bran cond sub pod time code
1             # Copyright 2015 Catalyst
2              
3             package WebService::ILS::OverDrive::Patron;
4              
5 2     2   1250 use Modern::Perl;
  2         3  
  2         10  
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   250 use Carp;
  2         4  
  2         109  
28 2     2   616 use HTTP::Request::Common;
  2         19377  
  2         116  
29 2     2   12 use URI::Escape;
  2         5  
  2         77  
30 2     2   10 use Data::Dumper;
  2         3  
  2         79  
31              
32 2     2   234 use parent qw(WebService::ILS::OverDrive);
  2         260  
  2         8  
33              
34 2     2   90 use constant CIRCULATION_API_URL => "http://patron.api.overdrive.com/";
  2         4  
  2         98  
35 2     2   10 use constant TEST_CIRCULATION_API_URL => "http://integration-patron.api.overdrive.com/";
  2         12  
  2         80  
36 2     2   9 use constant OAUTH_BASE_URL => "https://oauth.overdrive.com/";
  2         4  
  2         81  
37 2     2   10 use constant TOKEN_URL => OAUTH_BASE_URL . 'token';
  2         3  
  2         73  
38 2     2   9 use constant AUTH_URL => OAUTH_BASE_URL . 'auth';
  2         3  
  2         148  
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   12 };
  2         2  
  2         11  
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   2029 use constant auth_code_param_name => "code";
  2         4  
  2         92  
194 2     2   10 use constant state_token_param_name => "code";
  2         4  
  2         5169  
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 $metadata = $self->_item_metadata($_);
323 0           my $i = {%$item, %$metadata}; # we need my $i, don't ask me why...
324             } @$items
325             ];
326 0           return $res;
327             }
328              
329             =head2 place_hold ($item_id, $notification_email_address, $auto_checkout)
330              
331             C<$notification_email_address> and C<$auto_checkout> are optional.
332             C<$auto_checkout> defaults to false.
333              
334             =head3 Returns holds item record
335              
336             It is prefered that the C<$notification_email_address> is specified.
337              
338             If C<$auto_checkout> is set to true, the item will be checked out as soon as
339             it becomes available.
340              
341             =cut
342              
343             sub place_hold {
344 0     0 1   my $self = shift;
345              
346 0 0         my $hold = $self->native_place_hold(@_) or return;
347 0           my $res = $self->_result_xlate($hold, \%HOLDS_ITEM_XLATE);
348 0           $res->{total} = $hold->{numberOfHolds};
349 0           return $res;
350             }
351              
352             # sub suspend_hold { - not really useful
353              
354             sub remove_hold {
355 0     0 1   my $self = shift;
356 0 0         my $item_id = shift or croak "No item id";
357              
358 0           my $url = $self->circulation_action_url("/holds/$item_id");
359             return $self->with_delete_request(
360             \&_basic_callback,
361             sub {
362 0     0     my ($data) = @_;
363 0 0         return 1 if $data->{errorCode} eq "PatronDoesntHaveTitleOnHold";
364 0   0       die ($data->{message} || $data->{errorCode})."\n";
365             },
366 0           $url
367             );
368             }
369              
370             =head2 checkouts ()
371              
372             For formats see C below
373              
374             =cut
375              
376             my %CHECKOUTS_XLATE = (
377             totalItems => 'total',
378             totalCheckouts => 'total_format',
379             );
380             sub checkouts {
381 0     0 1   my $self = shift;
382              
383 0           my $checkouts = $self->native_checkouts;
384 0   0       my $items = delete ($checkouts->{checkouts}) || [];
385              
386 0           my $res = $self->_result_xlate($checkouts, \%CHECKOUTS_XLATE);
387             $res->{items} = [
388             map {
389 0           my $item = $self->_checkout_item_xlate($_);
  0            
390 0           my $formats = delete ($_->{formats});
391 0           my $actions = delete ($_->{actions});
392 0           my $metadata = $self->_item_metadata($_);
393 0 0         if ($formats) {
394 0           $formats = $self->_formats_xlate($item->{id}, $formats);
395             }
396             else {
397 0           $formats = {};
398             }
399 0 0         if ($actions) {
400 0 0         if (my $format_action = $actions->{format}) {
401 0           foreach (@{$format_action->{fields}}) {
  0            
402 0 0         next unless $_->{name} eq "formatType";
403              
404 0           foreach my $format (@{$_->{options}}) {
  0            
405 0 0         $formats->{$format} = undef unless exists $formats->{$format};
406             }
407 0           last;
408             }
409             }
410             }
411 0           my $i = {%$item, %$metadata, formats => $formats}; # we need my $i, don't ask me why...
412             } @$items
413             ];
414 0           return $res;
415             }
416              
417             my %CHECKOUT_ITEM_XLATE = (
418             reserveId => 'id',
419             checkoutDate => 'checkout_datetime',
420             expires => 'expires',
421             );
422             sub _checkout_item_xlate {
423 0     0     my $self = shift;
424 0           my $item = shift;
425              
426 0           my $i = $self->_result_xlate($item, \%CHECKOUT_ITEM_XLATE);
427 0 0         if ($item->{isFormatLockedIn}) {
428 0 0         my $formats = $item->{formats} or die "Item $item->{reserveId}: Format locked in, but no formats returned\n";
429 0           $i->{format} = $formats->[0]{formatType};
430             }
431 0           return $i;
432             }
433              
434             =head2 checkout ($item_id, $format, $allow_multiple_format_checkouts)
435              
436             C<$format> and C<$allow_multiple_format_checkouts> are optional.
437             C<$allow_multiple_format_checkouts> defaults to false.
438              
439             =head3 Returns checkout item record
440              
441             An item can be available in multiple formats. Checkout is complete only
442             when the format is specified.
443              
444             Checkout can be actioned without format being specified. In that case an
445             early return can be actioned. To complete checkout format must be locked
446             later (see L below). That would be the case with
447             L with C<$auto_checkout> set to true. Once format is locked,
448             an early return is not possible.
449              
450             If C<$allow_multiple_format_checkouts> flag is set to true, mutiple formats
451             of the same item can be acioned. If it is false (default) and the item was
452             already checked out, the checked out item record will be returned regardless
453             of the format.
454              
455             Checkout record will have an extra field C if format is locked in.
456              
457             =cut
458              
459             sub checkout {
460 0     0 1   my $self = shift;
461              
462 0 0         my $checkout = $self->native_checkout(@_) or return;
463 0           return $self->_checkout_item_xlate($checkout);
464             }
465              
466             =head2 checkout_formats ($item_id)
467              
468             =head3 Returns a hashref of available title formats and immediate availability
469              
470             { format => available, ... }
471              
472             If format is not immediately available it must be locked first
473              
474             =cut
475              
476             sub checkout_formats {
477 0     0 1   my $self = shift;
478 0 0         my $id = shift or croak "No item id";
479              
480 0 0         my $formats = $self->native_checkout_formats($id) or return;
481 0 0         $formats = $formats->{'formats'} or return;
482 0           return $self->_formats_xlate($id, $formats);
483             }
484              
485             sub _formats_xlate {
486 0     0     my $self = shift;
487 0 0         my $id = shift or croak "No item id";
488 0 0         my $formats = shift or croak "No formats";
489              
490 0           my %ret;
491 0           my $id_uc = uc $id;
492 0           foreach (@$formats) {
493 0 0         die "Non-matching item id\nExpected $id\nGot $_->{reserveId}" unless uc($_->{reserveId}) eq $id_uc;
494 0           my $format = $_->{formatType};
495 0           my $available;
496 0 0         if (my $lt = $_->{linkTemplates}) {
497 0           $available = grep /^downloadLink/, keys %$lt;
498             }
499 0           $ret{$format} = $available;
500             }
501 0           return \%ret;
502             }
503              
504             sub is_lockable {
505 0     0 0   my $self = shift;
506 0 0         my $checkout_formats = shift or croak "No checkout formats";
507 0           while (my ($format, $available) = each %$checkout_formats) {
508 0 0         return 1 unless $available;
509             }
510 0           return 0;
511             }
512              
513             =head2 lock_format ($item_id, $format)
514              
515             =head3 Returns locked format (should be the same as the input value)
516              
517             =cut
518              
519             sub lock_format {
520 0     0 1   my $self = shift;
521 0 0         my $item_id = shift or croak "No item id";
522 0 0         my $format = shift or croak "No format";
523              
524 0 0         my $lock = $self->native_lock_format($item_id, $format) or return;
525 0 0         die "Non-matching item id\nExpected $item_id\nGot $lock->{reserveId}" unless uc($lock->{reserveId}) eq uc($item_id);
526 0           return $lock->{formatType};
527             }
528              
529             =head2 checkout_download_url ($item_id, $format, $error_url, $success_url)
530              
531             =head3 Returns OverDrive download url
532              
533             Checked out items must be downloaded by users on the OverDrive site.
534             This method returns the url where the user should be sent to (redirected).
535             Once the download is complete, user will be redirected back to
536             C<$error_url> in case of an error, otherwise to optional C<$success_url>
537             if specified.
538              
539             See L
540              
541             =cut
542              
543             sub checkout_download_url {
544 0     0 1   my $self = shift;
545 0 0         my $item_id = shift or croak "No item id";
546 0 0         my $format = shift or croak "No format";
547 0 0         my $error_url = shift or die "No error url";
548 0           my $success_url = shift;
549              
550 0           $error_url = uri_escape($error_url);
551 0 0         $success_url = $success_url ? uri_escape($success_url) : '';
552 0           my $url = $self->circulation_action_url("/checkouts/$item_id/formats/$format/downloadlink?errorurl=$error_url&successurl=$success_url");
553 0           my $response_data = $self->get_response($url);
554 0 0 0       my $download_url =
555             _extract_link($response_data, 'contentLink') ||
556             _extract_link($response_data, 'contentlink')
557             or die "Cannot get download url\n".Dumper($response_data);
558 0           return $download_url;
559             }
560              
561             sub return {
562 0     0 1   my $self = shift;
563 0 0         my $item_id = shift or croak "No item id";
564              
565 0           my $url = $self->circulation_action_url("/checkouts/$item_id");
566             return $self->with_delete_request(
567             \&_basic_callback,
568             sub {
569 0     0     my ($data) = @_;
570 0 0         return 1 if $data->{errorCode} eq "PatronDoesntHaveTitleCheckedOut";
571 0   0       die ($data->{message} || $data->{errorCode})."\n";
572             },
573 0           $url
574             );
575             }
576              
577             =head1 NATIVE METHODS
578              
579             =head2 native_patron ()
580              
581             See L
582              
583             =cut
584              
585             sub native_patron {
586 0     0 1   my $self = shift;
587              
588 0           my $url = $self->circulation_action_url("");
589 0 0         my $patron = $self->get_response($url) or return;
590 0 0         if (my $collection_token = $patron->{collectionToken}) {
591 0           $self->SUPER::collection_token( $collection_token);
592             }
593 0           return $patron;
594             }
595              
596             =head2 native_holds ()
597              
598             =head2 native_place_hold ($item_id, $notification_email_address, $auto_checkout)
599              
600             See L
601              
602             =cut
603              
604             sub native_holds {
605 0     0 1   my $self = shift;
606 0           my $url = $self->circulation_action_url("/holds");
607 0           return $self->get_response($url);
608             }
609              
610             sub native_place_hold {
611 0     0 1   my $self = shift;
612 0 0         my $item_id = shift or croak "No item id";
613 0           my $email = shift;
614 0           my $auto_checkout = shift;
615              
616 0           my @fields = ( {name => "reserveId", value => $item_id } );
617 0 0         push @fields, {name => "autoCheckout", value => "true"} if $auto_checkout;
618 0 0         if ($email) {
619 0           push @fields, {name => "emailAddress", value => $email};
620             } else {
621 0           push @fields, {name => "ignoreHoldEmail", value => "true"};
622             }
623              
624 0           my $url = $self->circulation_action_url("/holds");
625             return $self->with_json_request(
626             \&_basic_callback,
627             sub {
628 0     0     my ($data) = @_;
629 0 0         if ($data->{errorCode} eq "AlreadyOnWaitList") {
630 0 0         if (my $holds = $self->native_holds) {
631 0           my $item_id_uc = uc $item_id;
632 0 0         foreach (@{ $holds->{holds} || [] }) {
  0            
633 0 0         if ( uc($_->{reserveId}) eq $item_id_uc ) {
634 0           $_->{numberOfHolds} = $holds->{totalItems};
635 0           return $_;
636             }
637             }
638             }
639             }
640              
641 0   0       die ($data->{message} || $data->{errorCode})."\n";
642             },
643 0           $url,
644             {fields => \@fields}
645             );
646             }
647              
648             =head2 native_checkouts ()
649              
650             =head2 native_checkout_info ($item_id)
651              
652             =head2 native_checkout ($item_id, $format, $allow_multiple_format_checkouts)
653              
654             =head2 native_checkout_formats ($item_id)
655              
656             =head2 native_lock_format ($item_id, $format)
657              
658             See L
659              
660             =cut
661              
662             sub native_checkouts {
663 0     0 1   my $self = shift;
664              
665 0           my $url = $self->circulation_action_url("/checkouts");
666 0           return $self->get_response($url);
667             }
668              
669             sub native_checkout_info {
670 0     0 1   my $self = shift;
671 0 0         my $id = shift or croak "No item id";
672              
673 0           my $url = $self->circulation_action_url("/checkouts/$id");
674 0           return $self->get_response($url);
675             }
676              
677             sub native_checkout_formats {
678 0     0 1   my $self = shift;
679 0 0         my $id = shift or croak "No item id";
680              
681 0           my $url = $self->circulation_action_url("/checkouts/$id/formats");
682 0           return $self->get_response($url);
683             }
684              
685             sub native_checkout {
686 0     0 1   my $self = shift;
687 0 0         my $item_id = shift or croak "No item id";
688 0           my $format = shift;
689 0           my $allow_multi = shift;
690              
691 0 0         if (my $checkouts = $self->native_checkouts) {
692 0           my $item_id_uc = uc $item_id;
693 0 0         foreach (@{ $checkouts->{checkouts} || [] }) {
  0            
694 0 0         if ( uc($_->{reserveId}) eq $item_id_uc ) {
695 0 0         if ($format) {
696 0 0         if ($_->{isFormatLockedIn}) {
697 0 0         return $_ if lc($_->{formats}[0]{formatType}) eq lc($format);
698 0 0         die "Item $item_id has already been locked for different format '$_->{formats}[0]{formatType}'\n"
699             unless $allow_multi;
700             }
701             # else { $self->native_lock_format()? }
702             }
703             # else { die if !$allow_multi ? }
704 0           return $_;
705             }
706             }
707             }
708              
709 0           my $url = $self->circulation_action_url("/checkouts");
710 0           return $self->with_json_request(
711             \&_basic_callback,
712             undef,
713             $url,
714             {fields => _build_checkout_fields($item_id, $format)}
715             );
716             }
717              
718             sub native_lock_format {
719 0     0 1   my $self = shift;
720 0 0         my $item_id = shift or croak "No item id";
721 0 0         my $format = shift or croak "No format";
722              
723 0           my $url = $self->circulation_action_url("/checkouts/$item_id/formats");
724             return $self->with_json_request(
725             \&_basic_callback,
726             sub {
727 0     0     my ($data) = @_;
728 0   0       die "$format ".($data->{message} || $data->{errorCode})."\n";
729             },
730 0           $url,
731             {fields => _build_checkout_fields($item_id, $format)}
732             );
733             }
734              
735             sub _build_checkout_fields {
736 0     0     my ($id, $format) = @_;
737 0           my @fields = ( {name => "reserveId", value => $id } );
738 0 0         push @fields, {name => "formatType", value => $format} if $format;
739 0           return \@fields;
740             }
741              
742             # Circulation helpers
743              
744             sub circulation_action_url {
745 0     0 0   my $self = shift;
746 0           my $action = shift;
747 0           return $self->_circulation_api_url.$self->API_VERSION."/patrons/me$action";
748             }
749              
750             # API helpers
751              
752             sub _extract_link {
753 0     0     my ($data, $link) = @_;
754 0           return $data->{links}{$link}->{href};
755             }
756              
757 0     0     sub _basic_callback { return $_[0]; }
758              
759             1;
760              
761             __END__