|
Line 0
Link Here
|
|
|
1 |
# Copyright 2015 Catalyst |
| 2 |
|
| 3 |
package WebService::ILS::OverDrive::Patron; |
| 4 |
|
| 5 |
use Modern::Perl; |
| 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<WebService::ILS INDIVIDUAL USER AUTHENTICATION AND METHODS> |
| 22 |
|
| 23 |
See L<WebService::ILS::OverDrive> |
| 24 |
|
| 25 |
=cut |
| 26 |
|
| 27 |
use Carp; |
| 28 |
use HTTP::Request::Common; |
| 29 |
use URI::Escape; |
| 30 |
use Data::Dumper; |
| 31 |
|
| 32 |
use parent qw(WebService::ILS::OverDrive); |
| 33 |
|
| 34 |
use constant CIRCULATION_API_URL => "http://patron.api.overdrive.com/"; |
| 35 |
use constant TEST_CIRCULATION_API_URL => "http://integration-patron.api.overdrive.com/"; |
| 36 |
use constant OAUTH_BASE_URL => "https://oauth.overdrive.com/"; |
| 37 |
use constant TOKEN_URL => OAUTH_BASE_URL . 'token'; |
| 38 |
use constant AUTH_URL => OAUTH_BASE_URL . 'auth'; |
| 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> => 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 |
_circulation_api_url => sub { $_[0]->test ? TEST_CIRCULATION_API_URL : CIRCULATION_API_URL }, |
| 59 |
}; |
| 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<website_id> and C<authorization_name> (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 |
my $self = shift; |
| 77 |
my $user_id = shift or croak "No user id"; |
| 78 |
my $password = shift; # can be blank |
| 79 |
my $website_id = shift or croak "No website id"; |
| 80 |
my $authorization_name = shift or croak "No authorization name"; |
| 81 |
|
| 82 |
my $request = $self->_make_access_token_by_user_id_request($user_id, $password, $website_id, $authorization_name); |
| 83 |
$self->_request_access_token($request); |
| 84 |
|
| 85 |
$self->user_id($user_id); |
| 86 |
$self->password($password); |
| 87 |
$self->website_id($website_id); |
| 88 |
$self->authorization_name($authorization_name); |
| 89 |
return wantarray ? ($self->access_token, $self->access_token_type) : $self->access_token; |
| 90 |
} |
| 91 |
|
| 92 |
sub _make_access_token_by_user_id_request { |
| 93 |
my $self = shift; |
| 94 |
my $user_id = shift or croak "No user id"; |
| 95 |
my $password = shift; # can be blank |
| 96 |
my $website_id = shift or croak "No website id"; |
| 97 |
my $authorization_name = shift or croak "No authorization name"; |
| 98 |
|
| 99 |
my %params = ( |
| 100 |
grant_type => 'password', |
| 101 |
username => $user_id, |
| 102 |
scope => "websiteid:".$website_id." authorizationname:".$authorization_name, |
| 103 |
); |
| 104 |
if ($password) { |
| 105 |
$params{password} = $password; |
| 106 |
} else { |
| 107 |
$params{password} = "[ignore]"; |
| 108 |
$params{password_required} = "false"; |
| 109 |
} |
| 110 |
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<redirect_uri> => return url which will handle redirect back after auth |
| 157 |
|
| 158 |
=item C<state_token> => 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 |
my $self = shift; |
| 168 |
my $redirect_uri = shift or croak "Redirect URI not specified"; |
| 169 |
my $state_token = shift; |
| 170 |
|
| 171 |
my $library_id = $self->library_id or croak "No Library Id"; |
| 172 |
|
| 173 |
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 |
use constant auth_code_param_name => "code"; |
| 194 |
use constant state_token_param_name => "code"; |
| 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 |
my $self = shift; |
| 204 |
my $code = shift or croak "No authorization code"; |
| 205 |
my $redirect_uri = shift or croak "Redirect URI not specified"; |
| 206 |
|
| 207 |
my $auth_type = 'authorization_code'; |
| 208 |
|
| 209 |
my $request = HTTP::Request::Common::POST( TOKEN_URL, { |
| 210 |
grant_type => 'authorization_code', |
| 211 |
code => $code, |
| 212 |
redirect_uri => $redirect_uri, |
| 213 |
} ); |
| 214 |
$self->_request_access_token($request); |
| 215 |
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 |
my $self = shift; |
| 226 |
my $auth_token = shift or croak "No authorization token"; |
| 227 |
|
| 228 |
$self->auth_token($auth_token); |
| 229 |
my $request = $self->_make_access_token_by_auth_token_request($auth_token); |
| 230 |
$self->_request_access_token($request); |
| 231 |
|
| 232 |
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 |
my $self = shift; |
| 237 |
my $auth_token = shift or croak "No authorization token"; |
| 238 |
|
| 239 |
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 |
my $self = shift; |
| 247 |
|
| 248 |
if (my $auth_token = $self->auth_token) { |
| 249 |
return $self->_make_access_token_by_auth_token_request($auth_token); |
| 250 |
} |
| 251 |
elsif (my $user_id = $self->user_id) { |
| 252 |
return $self->_make_access_token_by_user_id_request( |
| 253 |
$user_id, $self->password, $self->website_id, $self->authorization_name |
| 254 |
); |
| 255 |
} |
| 256 |
|
| 257 |
die $self->ERROR_NOT_AUTHENTICATED."\n"; |
| 258 |
} |
| 259 |
|
| 260 |
sub _request_access_token { |
| 261 |
my $self = shift; |
| 262 |
my $request = shift or croak "No request"; |
| 263 |
|
| 264 |
my $data = $self->SUPER::_request_access_token($request) |
| 265 |
or die "Unsuccessful access token request"; |
| 266 |
|
| 267 |
if (my $auth_token = $data->{refresh_token}) { |
| 268 |
$self->auth_token($auth_token); |
| 269 |
} |
| 270 |
|
| 271 |
return $data; |
| 272 |
} |
| 273 |
|
| 274 |
sub collection_token { |
| 275 |
my $self = shift; |
| 276 |
|
| 277 |
if (my $collection_token = $self->SUPER::collection_token) { |
| 278 |
return $collection_token; |
| 279 |
} |
| 280 |
|
| 281 |
$self->native_patron; # sets collection_token as a side-effect |
| 282 |
my $collection_token = $self->SUPER::collection_token |
| 283 |
or die "Patron has no collections\n"; |
| 284 |
return $collection_token; |
| 285 |
} |
| 286 |
|
| 287 |
=head1 CIRCULATION METHOD SPECIFICS |
| 288 |
|
| 289 |
Differences to general L<WebService::ILS> 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 |
my $self = shift; |
| 301 |
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 |
my $self = shift; |
| 314 |
|
| 315 |
my $holds = $self->native_holds; |
| 316 |
my $items = delete ($holds->{holds}) || []; |
| 317 |
|
| 318 |
my $res = $self->_result_xlate($holds, \%HOLDS_XLATE); |
| 319 |
$res->{items} = [ |
| 320 |
map { |
| 321 |
my $item = $self->_result_xlate($_, \%HOLDS_ITEM_XLATE); |
| 322 |
my $item_id = $item->{id}; |
| 323 |
my $metadata = $self->item_metadata($item_id); |
| 324 |
my $i = {%$item, %$metadata}; # we need my $i, don't ask me why... |
| 325 |
} @$items |
| 326 |
]; |
| 327 |
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 |
my $self = shift; |
| 346 |
|
| 347 |
my $hold = $self->native_place_hold(@_) or return; |
| 348 |
my $res = $self->_result_xlate($hold, \%HOLDS_ITEM_XLATE); |
| 349 |
$res->{total} = $hold->{numberOfHolds}; |
| 350 |
return $res; |
| 351 |
} |
| 352 |
|
| 353 |
# sub suspend_hold { - not really useful |
| 354 |
|
| 355 |
sub remove_hold { |
| 356 |
my $self = shift; |
| 357 |
my $item_id = shift or croak "No item id"; |
| 358 |
|
| 359 |
my $url = $self->circulation_action_url("/holds/$item_id"); |
| 360 |
return $self->with_delete_request( |
| 361 |
\&_basic_callback, |
| 362 |
sub { |
| 363 |
my ($data) = @_; |
| 364 |
return 1 if $data->{errorCode} eq "PatronDoesntHaveTitleOnHold"; |
| 365 |
die ($data->{message} || $data->{errorCode})."\n"; |
| 366 |
}, |
| 367 |
$url |
| 368 |
); |
| 369 |
} |
| 370 |
|
| 371 |
=head2 checkouts () |
| 372 |
|
| 373 |
For formats see C<checkout_formats()> below |
| 374 |
|
| 375 |
=cut |
| 376 |
|
| 377 |
my %CHECKOUTS_XLATE = ( |
| 378 |
totalItems => 'total', |
| 379 |
totalCheckouts => 'total_format', |
| 380 |
); |
| 381 |
sub checkouts { |
| 382 |
my $self = shift; |
| 383 |
|
| 384 |
my $checkouts = $self->native_checkouts; |
| 385 |
my $items = delete ($checkouts->{checkouts}) || []; |
| 386 |
|
| 387 |
my $res = $self->_result_xlate($checkouts, \%CHECKOUTS_XLATE); |
| 388 |
$res->{items} = [ |
| 389 |
map { |
| 390 |
my $item = $self->_checkout_item_xlate($_); |
| 391 |
my $item_id = $item->{id}; |
| 392 |
my $formats = delete ($_->{formats}); |
| 393 |
my $actions = delete ($_->{actions}); |
| 394 |
my $metadata = $self->item_metadata($item_id); |
| 395 |
if ($formats) { |
| 396 |
$formats = $self->_formats_xlate($item_id, $formats); |
| 397 |
} |
| 398 |
else { |
| 399 |
$formats = {}; |
| 400 |
} |
| 401 |
if ($actions) { |
| 402 |
if (my $format_action = $actions->{format}) { |
| 403 |
foreach (@{$format_action->{fields}}) { |
| 404 |
next unless $_->{name} eq "formatType"; |
| 405 |
|
| 406 |
foreach my $format (@{$_->{options}}) { |
| 407 |
$formats->{$format} = undef unless exists $formats->{$format}; |
| 408 |
} |
| 409 |
last; |
| 410 |
} |
| 411 |
} |
| 412 |
} |
| 413 |
my $i = {%$item, %$metadata, formats => $formats}; # we need my $i, don't ask me why... |
| 414 |
} @$items |
| 415 |
]; |
| 416 |
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 |
my $self = shift; |
| 426 |
my $item = shift; |
| 427 |
|
| 428 |
my $i = $self->_result_xlate($item, \%CHECKOUT_ITEM_XLATE); |
| 429 |
if ($item->{isFormatLockedIn}) { |
| 430 |
my $formats = $item->{formats} or die "Item $item->{reserveId}: Format locked in, but no formats returned\n"; |
| 431 |
$i->{format} = $formats->[0]{formatType}; |
| 432 |
} |
| 433 |
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<lock_format()> below). That would be the case with |
| 449 |
L<place_hold()> 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<format> if format is locked in. |
| 458 |
|
| 459 |
=cut |
| 460 |
|
| 461 |
sub checkout { |
| 462 |
my $self = shift; |
| 463 |
|
| 464 |
my $checkout = $self->native_checkout(@_) or return; |
| 465 |
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 |
my $self = shift; |
| 480 |
my $id = shift or croak "No item id"; |
| 481 |
|
| 482 |
my $formats = $self->native_checkout_formats($id) or return; |
| 483 |
$formats = $formats->{'formats'} or return; |
| 484 |
return $self->_formats_xlate($id, $formats); |
| 485 |
} |
| 486 |
|
| 487 |
sub _formats_xlate { |
| 488 |
my $self = shift; |
| 489 |
my $id = shift or croak "No item id"; |
| 490 |
my $formats = shift or croak "No formats"; |
| 491 |
|
| 492 |
my %ret; |
| 493 |
my $id_uc = uc $id; |
| 494 |
foreach (@$formats) { |
| 495 |
die "Non-matching item id\nExpected $id\nGot $_->{reserveId}" unless uc($_->{reserveId}) eq $id_uc; |
| 496 |
my $format = $_->{formatType}; |
| 497 |
my $available; |
| 498 |
if (my $lt = $_->{linkTemplates}) { |
| 499 |
$available = grep /^downloadLink/, keys %$lt; |
| 500 |
} |
| 501 |
$ret{$format} = $available; |
| 502 |
} |
| 503 |
return \%ret; |
| 504 |
} |
| 505 |
|
| 506 |
sub is_lockable { |
| 507 |
my $self = shift; |
| 508 |
my $checkout_formats = shift or croak "No checkout formats"; |
| 509 |
while (my ($format, $available) = each %$checkout_formats) { |
| 510 |
return 1 unless $available; |
| 511 |
} |
| 512 |
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 |
my $self = shift; |
| 523 |
my $item_id = shift or croak "No item id"; |
| 524 |
my $format = shift or croak "No format"; |
| 525 |
|
| 526 |
my $lock = $self->native_lock_format($item_id, $format) or return; |
| 527 |
die "Non-matching item id\nExpected $item_id\nGot $lock->{reserveId}" unless uc($lock->{reserveId}) eq uc($item_id); |
| 528 |
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<https://developer.overdrive.com/apis/download> |
| 542 |
|
| 543 |
=cut |
| 544 |
|
| 545 |
sub checkout_download_url { |
| 546 |
my $self = shift; |
| 547 |
my $item_id = shift or croak "No item id"; |
| 548 |
my $format = shift or croak "No format"; |
| 549 |
my $error_url = shift or die "No error url"; |
| 550 |
my $success_url = shift; |
| 551 |
|
| 552 |
$error_url = uri_escape($error_url); |
| 553 |
$success_url = $success_url ? uri_escape($success_url) : ''; |
| 554 |
my $url = $self->circulation_action_url("/checkouts/$item_id/formats/$format/downloadlink?errorurl=$error_url&successurl=$success_url"); |
| 555 |
my $response_data = $self->get_response($url); |
| 556 |
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 |
return $download_url; |
| 561 |
} |
| 562 |
|
| 563 |
sub return { |
| 564 |
my $self = shift; |
| 565 |
my $item_id = shift or croak "No item id"; |
| 566 |
|
| 567 |
my $url = $self->circulation_action_url("/checkouts/$item_id"); |
| 568 |
return $self->with_delete_request( |
| 569 |
\&_basic_callback, |
| 570 |
sub { |
| 571 |
my ($data) = @_; |
| 572 |
return 1 if $data->{errorCode} eq "PatronDoesntHaveTitleCheckedOut"; |
| 573 |
die ($data->{message} || $data->{errorCode})."\n"; |
| 574 |
}, |
| 575 |
$url |
| 576 |
); |
| 577 |
} |
| 578 |
|
| 579 |
=head1 NATIVE METHODS |
| 580 |
|
| 581 |
=head2 native_patron () |
| 582 |
|
| 583 |
See L<https://developer.overdrive.com/apis/patron-information> |
| 584 |
|
| 585 |
=cut |
| 586 |
|
| 587 |
sub native_patron { |
| 588 |
my $self = shift; |
| 589 |
|
| 590 |
my $url = $self->circulation_action_url(""); |
| 591 |
my $patron = $self->get_response($url) or return; |
| 592 |
if (my $collection_token = $patron->{collectionToken}) { |
| 593 |
$self->SUPER::collection_token( $collection_token); |
| 594 |
} |
| 595 |
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<https://developer.overdrive.com/apis/holds> |
| 603 |
|
| 604 |
=cut |
| 605 |
|
| 606 |
sub native_holds { |
| 607 |
my $self = shift; |
| 608 |
my $url = $self->circulation_action_url("/holds"); |
| 609 |
return $self->get_response($url); |
| 610 |
} |
| 611 |
|
| 612 |
sub native_place_hold { |
| 613 |
my $self = shift; |
| 614 |
my $item_id = shift or croak "No item id"; |
| 615 |
my $email = shift; |
| 616 |
my $auto_checkout = shift; |
| 617 |
|
| 618 |
my @fields = ( {name => "reserveId", value => $item_id } ); |
| 619 |
push @fields, {name => "autoCheckout", value => "true"} if $auto_checkout; |
| 620 |
if ($email) { |
| 621 |
push @fields, {name => "emailAddress", value => $email}; |
| 622 |
} else { |
| 623 |
push @fields, {name => "ignoreHoldEmail", value => "true"}; |
| 624 |
} |
| 625 |
|
| 626 |
my $url = $self->circulation_action_url("/holds"); |
| 627 |
return $self->with_json_request( |
| 628 |
\&_basic_callback, |
| 629 |
sub { |
| 630 |
my ($data) = @_; |
| 631 |
if ($data->{errorCode} eq "AlreadyOnWaitList") { |
| 632 |
if (my $holds = $self->native_holds) { |
| 633 |
my $item_id_uc = uc $item_id; |
| 634 |
foreach (@{ $holds->{holds} || [] }) { |
| 635 |
if ( uc($_->{reserveId}) eq $item_id_uc ) { |
| 636 |
$_->{numberOfHolds} = $holds->{totalItems}; |
| 637 |
return $_; |
| 638 |
} |
| 639 |
} |
| 640 |
} |
| 641 |
} |
| 642 |
|
| 643 |
die ($data->{message} || $data->{errorCode})."\n"; |
| 644 |
}, |
| 645 |
$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<https://developer.overdrive.com/apis/checkouts> |
| 661 |
|
| 662 |
=cut |
| 663 |
|
| 664 |
sub native_checkouts { |
| 665 |
my $self = shift; |
| 666 |
|
| 667 |
my $url = $self->circulation_action_url("/checkouts"); |
| 668 |
return $self->get_response($url); |
| 669 |
} |
| 670 |
|
| 671 |
sub native_checkout_info { |
| 672 |
my $self = shift; |
| 673 |
my $id = shift or croak "No item id"; |
| 674 |
|
| 675 |
my $url = $self->circulation_action_url("/checkouts/$id"); |
| 676 |
return $self->get_response($url); |
| 677 |
} |
| 678 |
|
| 679 |
sub native_checkout_formats { |
| 680 |
my $self = shift; |
| 681 |
my $id = shift or croak "No item id"; |
| 682 |
|
| 683 |
my $url = $self->circulation_action_url("/checkouts/$id/formats"); |
| 684 |
return $self->get_response($url); |
| 685 |
} |
| 686 |
|
| 687 |
sub native_checkout { |
| 688 |
my $self = shift; |
| 689 |
my $item_id = shift or croak "No item id"; |
| 690 |
my $format = shift; |
| 691 |
my $allow_multi = shift; |
| 692 |
|
| 693 |
if (my $checkouts = $self->native_checkouts) { |
| 694 |
my $item_id_uc = uc $item_id; |
| 695 |
foreach (@{ $checkouts->{checkouts} || [] }) { |
| 696 |
if ( uc($_->{reserveId}) eq $item_id_uc ) { |
| 697 |
if ($format) { |
| 698 |
if ($_->{isFormatLockedIn}) { |
| 699 |
return $_ if lc($_->{formats}[0]{formatType}) eq lc($format); |
| 700 |
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 |
return $_; |
| 707 |
} |
| 708 |
} |
| 709 |
} |
| 710 |
|
| 711 |
my $url = $self->circulation_action_url("/checkouts"); |
| 712 |
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 |
my $self = shift; |
| 722 |
my $item_id = shift or croak "No item id"; |
| 723 |
my $format = shift or croak "No format"; |
| 724 |
|
| 725 |
my $url = $self->circulation_action_url("/checkouts/$item_id/formats"); |
| 726 |
return $self->with_json_request( |
| 727 |
\&_basic_callback, |
| 728 |
sub { |
| 729 |
my ($data) = @_; |
| 730 |
die "$format ".($data->{message} || $data->{errorCode})."\n"; |
| 731 |
}, |
| 732 |
$url, |
| 733 |
{fields => _build_checkout_fields($item_id, $format)} |
| 734 |
); |
| 735 |
} |
| 736 |
|
| 737 |
sub _build_checkout_fields { |
| 738 |
my ($id, $format) = @_; |
| 739 |
my @fields = ( {name => "reserveId", value => $id } ); |
| 740 |
push @fields, {name => "formatType", value => $format} if $format; |
| 741 |
return \@fields; |
| 742 |
} |
| 743 |
|
| 744 |
# Circulation helpers |
| 745 |
|
| 746 |
sub circulation_action_url { |
| 747 |
my $self = shift; |
| 748 |
my $action = shift; |
| 749 |
return $self->_circulation_api_url.$self->API_VERSION."/patrons/me$action"; |
| 750 |
} |
| 751 |
|
| 752 |
# API helpers |
| 753 |
|
| 754 |
sub _extract_link { |
| 755 |
my ($data, $link) = @_; |
| 756 |
return $data->{links}{$link}->{href}; |
| 757 |
} |
| 758 |
|
| 759 |
sub _basic_callback { return $_[0]; } |
| 760 |
|
| 761 |
1; |
| 762 |
|
| 763 |
__END__ |
| 764 |
|
| 765 |
=head1 LICENSE |
| 766 |
|
| 767 |
Copyright (C) Catalyst IT NZ Ltd |
| 768 |
Copyright (C) Bywater Solutions |
| 769 |
|
| 770 |
This library is free software; you can redistribute it and/or modify |
| 771 |
it under the same terms as Perl itself. |
| 772 |
|
| 773 |
=head1 AUTHOR |
| 774 |
|
| 775 |
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt> |
| 776 |
|
| 777 |
=cut |