Bug 41521: Add WebService::ILS to lib/

Signed-off-by: Victor Grousset/tuxayo <victor@tuxayo.net>
Signed-off-by: David Cook <dcook@prosentient.com.au>
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
This commit is contained in:
Jonathan Druart 2026-01-23 09:29:51 +01:00 committed by Martin Renvoize
parent 63a1924b27
commit 5d516102c8
No known key found for this signature in database
GPG Key ID: 2D86E0EAEC83711B
11 changed files with 3514 additions and 0 deletions

810
lib/WebService/ILS.pm Normal file
View File

@ -0,0 +1,810 @@
package WebService::ILS;
use Modern::Perl;
our $VERSION = "0.18";
=encoding utf-8
=head1 NAME
WebService::ILS - Standardised library discovery/circulation services
=head1 SYNOPSIS
use WebService::ILS::<Provider Subclass>;
my $ils = WebService::ILS::<Provider Subclass>->new({
client_id => $client_id,
client_secret => $client_secret
});
my %search_params = (
query => "Some keyword",
sort => "rating",
);
my $result = $ils->search(\%search_params);
foreach (@{ $result->{items} }) {
...
}
foreach (2..$result->{pages}) {
$search_params{page} = $_;
my $next_results = $ils->search(\%search_params);
...
}
or
my $native_result = $ils->native_search(\%native_search_params);
=head1 DESCRIPTION
WebService::ILS is an attempt to create a standardised interface for
online library services providers.
In addition, native API interface is provided.
Here we will describe constructor parameters and methods common to all
service providers. Diversions and native interfaces are documented
in corresponding modules.
=head2 Supported service providers
=over 4
=item B<WebService::ILS::OverDrive::Library>
OverDrive Library API L<https://developer.overdrive.com/discovery-apis>
=item B<WebService::ILS::OverDrive::Patron>
OverDrive Circulation API L<https://developer.overdrive.com/circulation-apis>
=back
=head1 INTERFACE
=head2 Error handling
Method calls will die on error. $@ will contain a multi-line string.
See C<error_message()> below.
=head2 Item record
Item record is returned by many methods, so we specify it here.
=over 12
=item C<id>
=item C<isbn>
=item C<title>
=item C<subtitle>
=item C<description>
=item C<author>
=item C<publisher>
=item C<publication_date>
=item C<language>
=item C<rating> => user ratings metrics
=item C<popularity> => checkout metrics
=item C<subjects> => subject categories (tags)
=item C<facets> => a hashref of facet => [values]
=item C<media> => book, e-book, video, audio etc
=item C<formats> => an arrayref of available formats
=item C<images> => a hashref of size => url
=item C<encryption_key> => for decryption purposes
=item C<drm> => subject to drm
=back
Not all fields are available for all service providers.
Field values are not standardised.
=cut
use Carp;
use Hash::Merge;
use Params::Check;
use LWP::UserAgent;
use HTTP::Status qw(:constants);
use MIME::Base64 qw();
use JSON qw(from_json);
our $DEBUG;
my %CONSTRUCTOR_PARAMS_SPEC;
sub _set_param_spec {
my $class = shift;
my $param_spec = shift;
$CONSTRUCTOR_PARAMS_SPEC{$class} = $param_spec;
}
sub _get_param_spec {
my $class = shift;
if (my $ref = ref($class)) {
$class = $ref;
}
my $p_s = $CONSTRUCTOR_PARAMS_SPEC{$class};
return $p_s if $class eq __PACKAGE__;
(my $superclass = $class) =~ s/::\w+$//o;
return Hash::Merge::merge($p_s || {}, $superclass->_get_param_spec);
}
=head1 CONSTRUCTOR
=head2 new (%params_hash or $params_hashref)
=head3 Client (vendor) related constructor params, given by service provider:
=over 12
=item C<client_id> => client (vendor) identifier
=item C<client_secret> => secret key (password)
=item C<library_id> => sometimes service providers provide access
to differnt "libraries"
=back
=head3 General constructor params:
=over 12
=item C<user_agent> => LWP::UserAgent or a derivative;
usually not needed, one is created for you.
=item C<user_agent_params> => LWP::UserAgent constructor params
so you don't need to create user agent yourself
=item C<access_token> => as returned from the provider authentication system
=item C<access_token_type> => as returned from the provider authentication system
=back
These are also read-only attributes
Not all of client/library params are required for all service providers.
=cut
use Class::Tiny qw(
user_agent
client_id client_secret library_id
access_token access_token_type
);
__PACKAGE__->_set_param_spec({
client_id => { required => 1, defined => 1 },
client_secret => { required => 1, defined => 1 },
library_id => { required => 0, defined => 1 },
access_token => { required => 0 },
access_token_type => { required => 0 },
user_agent => { required => 0 },
user_agent_params => { required => 0 },
});
sub BUILDARGS {
my $self = shift;
my $params = shift || {};
if (!ref( $params )) {
$params = {$params, @_};
}
local $Params::Check::WARNINGS_FATAL = 1;
$params = Params::Check::check($self->_get_param_spec, $params)
or croak "Invalid parameters: ".Params::Check::last_error();
return $params;
}
sub BUILD {
my $self = shift;
my $params = shift;
my $ua_params = delete $params->{user_agent_params} || {};
$self->user_agent( LWP::UserAgent->new(%$ua_params) ) unless $self->user_agent;
delete $self->{user_agent_params};
}
=head1 ATTRIBUTES
=head2 user_agent
As provided to constructor, or auto created. Useful if one wants to
change user agent attributes on the fly, eg
$ils->user_agent->timeout(120);
=head1 DISCOVERY METHODS
=head2 search ($params_hashref)
=head3 Input params:
=over 12
=item C<query> => query (search) string
=item C<page_size> => number of items per results page
=item C<page> => wanted page number
=item C<sort> => resultset sort option (see below)
=back
Sort options are either an array or a comma separated string of options:
=over 12
=item C<publication_date> => date title was published
=item C<available_date> => date title became available for users
=item C<rating> => number of items per results page
=back
Sort order can be added after option with ":", eg
"publication_date:desc,rating:desc"
=head3 Returns search results record:
=over 12
=item C<items> => an array of item records
=item C<page_size> => number of items per results page
=item C<page> => results page number
=item C<pages> => total number of pages
=item C<total> => total number of items found by the search
=back
=head2 item_metadata ($item_id)
=head3 Returns item record
=head2 item_availability ($item_id)
=head3 Returns item availability record:
=over 12
=item C<id>
=item C<available> => boolean
=item C<copies_available> => number of copies available
=item C<copies_owned> => number of copies owned
=item C<type> => availability type, provider dependant
=back
Not all fields are available for all service providers.
For example, some will provide "copies_available", making "available"
redundant, whereas others will just provide "available".
=head2 is_item_available ($item_id)
=head3 Returns boolean
Simplified version of L<item_availability()>
=cut
sub search {
die "search() not implemented";
}
# relevancy availability available_date title author popularity rating price publisher publication_date
sub _parse_sort_string {
my $self = shift;
my $sort = shift or croak "No sort options";
my $xlate_table = shift || {};
my $camelise = shift;
$sort = [split /\s*,\s*/, $sort] unless ref $sort;
foreach (@$sort) {
my ($s,$d) = split ':';
if (exists $xlate_table->{$s}) {
next unless $xlate_table->{$s};
$_ = $xlate_table->{$s};
}
else {
$_ = $s;
}
# join('', map{ ucfirst $_ } split(/(?<=[A-Za-z])_(?=[A-Za-z])|\b/, $s));
$_ = join '', map ucfirst, split /(?<=[A-Za-z])_(?=[A-Za-z])|\b/ if $camelise;
$_ = "$_:$d" if $d;
}
return $sort;
}
sub item_metadata {
die "item_metadata() not implemented";
}
sub item_availability {
die "item_availability() not implemented";
}
=head1 INDIVIDUAL USER AUTHENTICATION AND METHODS
=head2 user_id / password
Provider authentication API is used to get an authorized session.
=head3 auth_by_user_id($user_id, $password)
An example:
my $ils = WebService::ILS::Provider({
client_id => $client_id,
client_secret => $client_secret,
});
eval { $ils->auth_by_user_id( $user_id, $password ) };
if ($@) { some_error_handling(); return; }
$session{ils_access_token} = $ils->access_token;
$session{ils_access_token_type} = $ils->access_token_type;
...
Somewhere else in your app:
my $ils = WebService::ILS::Provider({
client_id => $client_id,
client_secret => $client_secret,
access_token => $session{ils_access_token},
access_token_type => $session{ils_access_token_type},
});
my $checkouts = $ils->checkouts;
=head2 Authentication at the provider
User is redirected to the provider authentication url, and after
authenticating at the provider redirected back with some kind of auth token.
Requires url to handle return redirect from the provider.
It can be used as an alternative to FB and Google auth.
This is just to give an idea, specifics heavily depend on the provider
=head3 auth_url ($redirect_back_uri)
Returns provider authentication url to redirect to
=head3 auth_token_param_name ()
Returns auth code url param name
=head3 auth_by_token ($provider_token)
An example:
my $ils = WebService::ILS::Provider({
client_id => $client_id,
client_secret => $client_secret,
});
my $redirect_url = $ils->auth_url("http://myapp.com/ils-auth");
$response->redirect($redirect_url);
...
After successful authentication at the provider, provider redirects
back to specified app url (http://myapp.com/ils-auth)
/ils-auth handler:
my $auth_token = $req->param( $ils->auth_token_param_name )
or some_error_handling(), return;
local $@;
eval { $ils->auth_by_token( $auth_token ) };
if ($@) { some_error_handling(); return; }
$session{ils_access_token} = $ils->access_token;
$session{ils_access_token_type} = $ils->access_token_type;
...
Somewhere else in your app:
passing access token to the constructor as above
=cut
=head1 CIRCULATION METHODS
=head2 patron ()
=head3 Returns patron record:
=over 12
=item C<id>
=item C<active> => boolean
=item C<copies_available> => number of copies available
=item C<checkout_limit> => number of checkouts allowed
=item C<hold_limit> => number of holds allowed
=back
=head2 holds ()
=head3 Returns holds record:
=over 12
=item C<total> => number of items on hold
=item C<items> => list of individual items
=back
In addition to Item record fields described above,
item records will have:
=over 12
=item C<placed_datetime> => hold timestamp, with or without timezone
=item C<queue_position> => user's position in the waiting queue,
if available
=back
=head2 place_hold ($item_id)
=head3 Returns holds item record (as described above)
In addition, C<total> field will be incorported as well.
=head2 remove_hold ($item_id)
=head3 Returns true to indicate success
Returns true in case user does not have a hold on the item.
Throws exception in case of any other failure.
=head2 checkouts ()
=head3 Returns checkout record:
=over 12
=item C<total> => number of items on hold
=item C<items> => list of individual items
=back
In addition to Item record fields described above,
item records will have:
=over 12
=item C<checkout_datetime> => checkout timestamp, with or without timezone
=item C<expires> => date (time) checkout expires
=item C<url> => download/stream url
=item C<files> => an arrayref of downloadable file details
title, url, size
=back
=head2 checkout ($item_id)
=head3 Returns checkout item record (as described above)
In addition, C<total> field will be incorported as well.
=head2 return ($item_id)
=head3 Returns true to indicate success
Returns true in case user does not have the item checked out.
Throws exception in case of any other failure.
=cut
=head1 NATIVE METHODS
All Discovery and Circulation methods (with exception of remove_hold()
and return(), where it does not make sense) have native_*() counterparts,
eg native_search(), native_item_availability(), native_checkout() etc.
In case of single item methods, native_item_availability(),
native_checkout() etc, they take item_id as parameter. Otherwise, it's a
hashref of HTTP request params (GET or POST).
Return value is a record as returned by API.
Individual provider subclasses provide additional provider specific
native methods.
=head1 UTILITY METHODS
=head2 Error constants
=over 4
=item C<ERROR_ACCESS_TOKEN>
=item C<ERROR_NOT_AUTHENTICATED>
=back
=cut
use constant ERROR_ACCESS_TOKEN => "Error: Authorization Failed";
use constant ERROR_NOT_AUTHENTICATED => "Error: User Not Authenticated";
sub invalid_response_exception_string {
my $self = shift;
my $response = shift;
return join "\n",
$response->message,
"Request:" => $response->request->as_string,
"Response:" => $response->as_string
;
}
sub check_response {
my $self = shift;
my $response = shift;
die $self->invalid_response_exception_string($response) unless $response->is_success;
}
=head2 error_message ($exception_string)
=head3 Returns error message probably suitable for displaying to the user
Example:
my $res = eval { $ils->checkout($id) };
if ($@) {
my $msg = $ils->error_message($@);
display($msg);
log_error($@);
}
=head2 is_access_token_error ($exception_string)
=head3 Returns true if the error is access token related
=head2 is_not_authenticated_error ($exception_string)
=head3 Returns true if the error is "Not authenticated"
=cut
sub error_message {
my $self = shift;
my $die_string = shift or return;
$die_string =~ m/(.*?)\n/o;
(my $msg = $1 || $die_string) =~ s! at /.* line \d+\.$!!;
return $msg;
}
sub is_access_token_error {
my $self = shift;
my $die_string = shift or croak "No error message";
return $self->error_message($die_string) eq ERROR_ACCESS_TOKEN;
}
sub is_not_authenticated_error {
my $self = shift;
my $die_string = shift or croak "No error message";
return $self->error_message($die_string) eq ERROR_NOT_AUTHENTICATED;
}
# Client access authorization
#
sub _request_with_auth {
my $self = shift;
my $request = shift or croak "No request";
my $has_token = $self->access_token;
my $response = $self->_request_with_token($request);
# token expired?
$response = $self->_request_with_token($request, "FRESH TOKEN")
if $response->code == HTTP_UNAUTHORIZED && $has_token;
return $response;
}
sub make_access_token_request {
die "make_access_token_request() not implemented";
}
sub _request_access_token {
my $self = shift;
my $request = shift or croak "No request";
$request->header(
Authorization => "Basic " . $self->_access_auth_string
);
my $response = $self->user_agent->request( $request );
# XXX check content type
return $self->process_json_response(
$response,
sub {
my ($data) = @_;
my ($token, $token_type) = $self->_extract_token_from_response($data);
$token or die "No access token\n";
$self->access_token($token);
$self->access_token_type($token_type || 'Bearer');
return $data;
},
sub {
my ($data) = @_;
die join "\n", ERROR_ACCESS_TOKEN, $self->_error_from_json($data) || $response->decoded_content;
}
);
}
sub _access_auth_string {
my $self = shift;
return MIME::Base64::encode( join(":", $self->client_id, $self->client_secret) );
}
sub _extract_token_from_response {
my $self = shift;
my $data = shift;
return ($data->{access_token}, $data->{token_type});
}
sub _request_with_token {
my $self = shift;
my $request = shift or croak "No request";
my $force_fresh = shift;
my $token = $force_fresh ? undef : $self->access_token;
unless ($token) {
my $request = $self->make_access_token_request;
$self->_request_access_token($request);
$token = $self->access_token;
}
die "No access token" unless $token;
my $token_type = $self->access_token_type;
$request->header( Authorization => "$token_type $token" );
return $self->user_agent->request( $request );
}
# Strictly speaking process_json_response() and process_json_error_response()
# should go to ::JSON. However, JSON is used for authentication services even for
# APIs that are XML, so need to be available
sub process_json_response {
my $self = shift;
my $response = shift or croak "No response";
my $success_callback = shift;
my $error_callback = shift;
unless ($response->is_success) {
return $self->process_json_error_response($response, $error_callback);
}
my $content_type = $response->header('Content-Type');
die "Invalid Content-Type\n".$response->as_string
unless $content_type && $content_type =~ m!application/json!;
my $content = $response->decoded_content
or die $self->invalid_response_exception_string($response);
local $@;
my $data = $content ? eval { from_json( $content ) } : {};
die "$@\nResponse:\n".$response->as_string if $@;
return $data unless $success_callback;
my $res = eval {
$success_callback->($data);
};
die "$@\nResponse:\n$content" if $@;
return $res;
}
sub process_json_error_response {
my $self = shift;
my $response = shift or croak "No response";
my $error_callback = shift;
my $content_type = $response->header('Content-Type');
if ($content_type && $content_type =~ m!application/json!) {
my $content = $response->decoded_content
or die $self->invalid_response_exception_string($response);
my $data = eval { from_json( $content ) };
die $content || $self->invalid_response_exception_string($response) if $@;
if ($error_callback) {
return $error_callback->($data);
}
die $self->_error_from_json($data) || "Invalid response:\n$content";
}
die $self->invalid_response_exception_string($response);
}
sub _error_from_json {};
# wrapper around error response handlers to include some debugging if the debug flag is set
sub _error_result {
my $self = shift;
my $process_sub = shift or croak "No process sub";
my $request = shift or croak "No HTTP request";
my $response = shift or croak "No HTTP response";
return $process_sub->() unless $DEBUG;
local $@;
my $ret = eval { $process_sub->() };
die join "\n", $@, "Request:", $request->as_string, "Response:", $response->as_string
if $@;
return $ret;
}
sub _result_xlate {
my $self = shift;
my $res = shift;
my $xlate_table = shift;
return {
map {
my $val = $res->{$_};
defined($val) ? ($xlate_table->{$_} => $val) : ()
} keys %$xlate_table
};
}
=head1 TODO
Federated search
=cut
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

141
lib/WebService/ILS/JSON.pm Normal file
View File

@ -0,0 +1,141 @@
package WebService::ILS::JSON;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::JSON - WebService::ILS module for services with JSON API
=head1 DESCRIPTION
To be subclassed
See L<WebService::ILS>
=cut
use Carp;
use HTTP::Request::Common;
use JSON qw(encode_json);
use URI;
use parent qw(WebService::ILS);
sub with_get_request {
my $self = shift;
my $callback = shift or croak "No callback";
my $url = shift or croak "No url";
my $get_params = shift; # hash ref
my $uri = URI->new($url);
$uri->query_form($get_params) if $get_params;
my $request = HTTP::Request::Common::GET( $uri );
my $response = $self->_request_with_auth($request);
return $self->process_json_response($response, $callback);
}
sub with_delete_request {
my $self = shift;
my $callback = shift or croak "No callback";
my $error_callback = shift;
my $url = shift or croak "No url";
my $request = HTTP::Request::Common::DELETE( $url );
my $response = $self->_request_with_auth($request);
return $response->content ? $self->process_json_response($response, $callback) : 1
if $response->is_success;
return $self->_error_result(
sub { $self->process_json_error_response($response, $error_callback); },
$request,
$response
);
}
sub with_post_request {
my $self = shift;
my $callback = shift or croak "No callback";
my $url = shift or croak "No url";
my $post_params = shift || {}; # hash ref
my $request = HTTP::Request::Common::POST( $url, $post_params );
my $response = $self->_request_with_auth($request);
return $self->process_json_response($response, $callback);
}
# This will probably not suit everyone
sub with_put_request {
my $self = shift;
my $callback = shift or croak "No callback";
my $url = shift or croak "No url";
my $put_params = shift;
my $request = HTTP::Request::Common::PUT( $url );
my $content;
if ($put_params) {
my $url = URI->new('http:');
$url->query_form(ref($put_params) eq "HASH" ? %$put_params : @$put_params);
$content = $url->query;
}
if( $content ) {
# HTML/4.01 says that line breaks are represented as "CR LF" pairs (i.e., `%0D%0A')
$content =~ s/(?<!%0D)%0A/%0D%0A/go;
$request->content_type("application/x-www-form-urlencoded");
$request->content_length(length $content);
$request->content($content);
}
else {
$request->content_length(0);
}
my $response = $self->_request_with_auth($request);
return $self->process_json_response($response, $callback);
}
sub with_json_request {
my $self = shift;
my $callback = shift or croak "No callback";
my $error_callback = shift;
my $url = shift or croak "No url";
my $post_params = shift || {}; # hashref
my $method = shift || 'post';
my $req_builder = "HTTP::Request::Common::".uc( $method );
no strict 'refs';
my $request = $req_builder->( $url );
$self->_json_request_content($request, $post_params);
my $response = $self->_request_with_auth($request);
return $self->process_json_response($response, $callback, $error_callback);
}
sub _json_request_content {
my $self = shift;
my $request = shift or croak "No request";
my $data = shift or croak "No data"; # hashref
$request->header( 'Content-Type' => 'application/json; charset=utf-8' );
$request->content( encode_json($data) );
$request->header( 'Content-Length' => bytes::length($request->content));
return $request;
}
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

View File

@ -0,0 +1,383 @@
package WebService::ILS::OverDrive;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::OverDrive - WebService::ILS module for OverDrive services
=head1 SYNOPSIS
use WebService::ILS::OverDrive::Library;
or
use WebService::ILS::OverDrive::Patron;
=head1 DESCRIPTION
L<WebService::ILS::OverDrive::Library> - anonymous discovery
services - no individual user credentials required
L<WebService::ILS::OverDrive::Patron> - discovery and circulation
services that require individual user credentials
See L<WebService::ILS>
=cut
use Carp;
use HTTP::Request::Common;
use URI::Escape;
use parent qw(WebService::ILS::JSON);
use constant API_VERSION => "v1";
use constant DISCOVERY_API_URL => "http://api.overdrive.com/";
use constant TEST_DISCOVERY_API_URL => "http://integration.api.overdrive.com/";
=head1 CONSTRUCTOR
=head2 new (%params_hash or $params_hashref)
=head3 Additional constructor params:
=over 10
=item C<test> => if set to true use OverDrive test API urls
=back
=cut
use Class::Tiny qw(
collection_token
test
), {
_discovery_api_url => sub { $_[0]->test ? TEST_DISCOVERY_API_URL : DISCOVERY_API_URL },
};
__PACKAGE__->_set_param_spec({
test => { required => 0 },
});
=head1 DISCOVERY METHODS
=head2 search ($params_hashref)
=head3 Additional input params:
=over 16
=item C<no_details> => if true, no metadata calls will be made for result items;
only id, title, rating and media will be available
=back
=cut
my %SORT_XLATE = (
available_date => "dateadded",
rating => "starrating",
publication_date => undef, # not available
);
sub search {
my $self = shift;
my $params = shift || {};
my $short_response = delete $params->{no_details};
my $url = $self->products_url;
if (my $query = delete $params->{query}) {
$query = join " ", @$query if ref $query;
$params->{q} = $query;
}
my $page_size = delete $params->{page_size};
$params->{limit} = $page_size if $page_size;
if (my $page_number = delete $params->{page}) {
croak "page_size must be specified for paging" unless $params->{limit};
$params->{offset} = ($page_number - 1)*$page_size;
}
if (my $sort = delete $params->{sort}) {
$params->{sort} = join ",", @{ $self->_parse_sort_string($sort, \%SORT_XLATE) };
}
$params->{formats} = join ",", @{$params->{formats}} if ref $params->{formats};
my $res = $self->get_response($url, $params);
my @items;
foreach (@{$res->{products} || []}) {
my $item;
if ($short_response) {
$item = $self->_item_xlate($_);
} else {
my $native_metadata = $self->native_item_metadata($_) or next;
$item = $self->_item_metadata_xlate($native_metadata);
}
next unless $item;
push @items, $item;
}
my $tot = $res->{totalItems};
my %ret = (
total => $tot,
items => \@items,
);
if (my $page_size = $res->{limit}) {
my $pages = int($tot/$page_size);
$pages++ if $tot > $page_size*$pages;
$ret{pages} = $pages;
$ret{page_size} = $page_size;
$ret{page} = $res->{offset}/$page_size + 1;
}
return \%ret;
}
my %SEARCH_RESULT_ITEM_XLATE = (
id => "id",
title => "title",
subtitle => "subtitle",
starRating => "rating",
mediaType => "media",
);
sub _item_xlate {
my $self = shift;
my $item = shift;
my $std_item = $self->_result_xlate($item, \%SEARCH_RESULT_ITEM_XLATE);
if (my $formats = $item->{formats}) {
$std_item->{formats} = [map $_->{id}, @$formats];
}
if (my $images = $item->{images}) {
$std_item->{images} = {map { $_ => $images->{$_}{href} } keys %$images};
}
# XXX
#if (my $details = $item->{contentDetails}) {
# $std_item->{details_url} = $details->{href};
#}
return $std_item;
}
my %METADATA_XLATE = (
id => "id",
mediaType => "media",
title => "title",
publisher => "publisher",
shortDescription => "subtitle",
starRating => "rating",
popularity => "popularity",
);
sub item_metadata {
my $self = shift;
my $id = shift or croak "No item id";
my $native_metadata = $self->get_response($self->products_url."/$id/metadata");
return $self->_item_metadata_xlate($native_metadata);
}
sub _item_metadata_xlate {
my $self = shift;
my $metadata = shift or croak "No native metadata";
my $item = $self->_result_xlate($metadata, \%METADATA_XLATE);
my @authors;
foreach (@{ $metadata->{creators} }) {
push @authors, $_->{name} if $_->{role} eq "Author";
}
$item->{author} = join ", ", @authors;
if (my $images = $metadata->{images}) {
$item->{images} = {map { $_ => $images->{$_}{href} } keys %$images};
}
if (my $languages = $metadata->{languages}) {
$item->{languages} = [map $_->{name}, @$languages];
}
if (my $subjects = $metadata->{subjects}) {
$item->{subjects} = [map $_->{value}, @$subjects];
}
if (my $formats = $metadata->{formats}) {
$item->{formats} = [map $_->{id}, @$formats];
}
return $item;
}
my %AVAILABILITY_RESULT_XLATE = (
id => "id",
available => "available",
copiesAvailable => "copies_available",
copiesOwned => "copies_owned",
availabilityType => "type",
);
sub item_availability {
my $self = shift;
my $id = shift or croak "No item id";
return $self->_result_xlate(
$self->get_response($self->products_url."/$id/availability"),
\%AVAILABILITY_RESULT_XLATE
);
}
sub is_item_available {
my $self = shift;
my $id = shift or croak "No item id";
my $type = shift;
my $availability = $self->item_availability($id) or return;
return unless $availability->{available};
return !$type || $type eq $availability->{type};
}
=head1 NATIVE METHODS
=head2 native_search ($params_hashref)
See L<https://developer.overdrive.com/apis/search>
=head2 native_search_[next|prev|first|last] ($data_as returned_by_native_search*)
For iterating through search result pages. Each native_search_*() method
accepts record returned by any native_search*() method as input.
Example:
my $res = $od->native_search({q => "Dogs"});
while ($res) {
do_something($res);
$res = $od->native_search_next($res);
}
or
my $res = $od->native_search({q => "Dogs"});
my $last = $od->native_search_last($res);
my $next_to_last = $od->native_search_prev($last);
my $first = $od->native_search_first($next_to_last)
# Same as $od->native_search_first($last)
# Same as $res
=cut
# params: q, limit, offset, formats, sort ? availability
sub native_search {
my $self = shift;
my $search_params = shift;
return $self->get_response($self->products_url, $search_params);
}
foreach my $f (qw(next prev first last)) {
no strict 'refs';
my $method = "native_search_$f";
*$method = sub {
my $self = shift;
my $search_data = shift or croak "No search result data";
my $url = _extract_link($search_data, $f) or return;
return $self->get_response($url);
}
}
# Item API
=head2 native_item_metadata ($item_data as returned by native_search*)
=head2 native_item_availability ($item_data as returned by native_search*)
Example:
my $res = $od->native_search({q => "Dogs"});
foreach (@{ $res->{products} }) {
my $meta = $od->native_item_metadata($_);
my $availability = $od->native_item_availability($_);
...
}
=cut
sub native_item_metadata {
my $self = shift;
my $item = shift or croak "No item record";
my $url = _extract_link($item, 'metadata') or die "No metadata link\n";
return $self->get_response($url);
}
sub native_item_availability {
my $self = shift;
my $item = shift or croak "No item record";
return $self->get_response(_extract_link($item, 'availability'));
}
# Discovery helpers
sub discovery_action_url {
my $self = shift;
my $action = shift;
return $self->_discovery_api_url.$self->API_VERSION.$action;
}
sub products_url {
my $self = shift;
my $collection_token = $self->collection_token or die "No collection token";
if ($collection_token) {
return $self->_discovery_api_url.$self->API_VERSION."/collections/$collection_token/products";
}
}
# API helpers
sub _extract_link {
my ($data, $link) = @_;
my $href = $data->{links}{$link}{href}
or croak "No '$link' url in data";
}
# Utility methods
sub _basic_callback { return $_[0]; }
# This is not exatly how we meant to use with_get_request()
# ie processing should be placed within the callback.
# However, if all goes well, it is faster (from the development perspective)
# this way.
sub get_response {
my $self = shift;
my $url = shift or croak "No url";
my $get_params = shift; # hash ref
return $self->with_get_request(\&_basic_callback, $url, $get_params);
}
sub _error_from_json {
my $self = shift;
my $data = shift or croak "No json data";
my $error = join " ", grep defined($_), $data->{errorCode}, $data->{error_description} || $data->{error} || $data->{message} || $data->{Message};
$error = "$error\n" if $error; # strip code line when dying
return $error;
}
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

View File

@ -0,0 +1,93 @@
package WebService::ILS::OverDrive::Library;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::OverDrive::Library - WebService::ILS module for OverDrive
discovery only services
=head1 SYNOPSIS
use WebService::ILS::OverDrive::Library;
=head1 DESCRIPTION
See L<WebService::ILS::OverDrive>
=cut
use Carp;
use HTTP::Request::Common;
use parent qw(WebService::ILS::OverDrive);
__PACKAGE__->_set_param_spec({
library_id => { required => 1, defined => 1 },
});
sub make_access_token_request {
my $self = shift;
return HTTP::Request::Common::POST( 'https://oauth.overdrive.com/token', {
grant_type => 'client_credentials'
} );
}
sub collection_token {
my $self = shift;
if (my $collection_token = $self->SUPER::collection_token) {
return $collection_token;
}
$self->native_library_account;
my $collection_token = $self->SUPER::collection_token
or die "Library has no collections\n";
return $collection_token;
}
=head1 NATIVE METHODS
=head2 native_library_account ()
See L<https://developer.overdrive.com/apis/library-account>
=cut
sub native_library_account {
my $self = shift;
my $library = $self->get_response($self->library_url);
if (my $collection_token = $library->{collectionToken}) {
$self->SUPER::collection_token( $collection_token);
}
return $library;
}
# Discovery helpers
sub library_url {
my $self = shift;
return $self->discovery_action_url("/libraries/".$self->library_id);
}
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

View File

@ -0,0 +1,777 @@
# Copyright 2015 Catalyst
package WebService::ILS::OverDrive::Patron;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::OverDrive::Patron - WebService::ILS module for OverDrive
circulation services
=head1 SYNOPSIS
use WebService::ILS::OverDrive::Patron;
=head1 DESCRIPTION
These services require individual user credentials.
See L<WebService::ILS INDIVIDUAL USER AUTHENTICATION AND METHODS>
See L<WebService::ILS::OverDrive>
=cut
use Carp;
use HTTP::Request::Common;
use URI::Escape;
use Data::Dumper;
use parent qw(WebService::ILS::OverDrive);
use constant CIRCULATION_API_URL => "http://patron.api.overdrive.com/";
use constant TEST_CIRCULATION_API_URL => "http://integration-patron.api.overdrive.com/";
use constant OAUTH_BASE_URL => "https://oauth.overdrive.com/";
use constant TOKEN_URL => OAUTH_BASE_URL . 'token';
use constant AUTH_URL => OAUTH_BASE_URL . 'auth';
=head1 CONSTRUCTOR
=head2 new (%params_hash or $params_hashref)
=head3 Additional constructor params:
=over 16
=item C<auth_token> => auth token as previously obtained
=back
=cut
use Class::Tiny qw(
user_id password website_id authorization_name
auth_token
), {
_circulation_api_url => sub { $_[0]->test ? TEST_CIRCULATION_API_URL : CIRCULATION_API_URL },
};
__PACKAGE__->_set_param_spec({
auth_token => { required => 0 },
});
=head1 INDIVIDUAL USER AUTHENTICATION METHODS
=head2 auth_by_user_id ($user_id, $password, $website_id, $authorization_name)
C<website_id> and C<authorization_name> (domain) are provided by OverDrive
=head3 Returns (access_token, access_token_type) or access_token
=cut
sub auth_by_user_id {
my $self = shift;
my $user_id = shift or croak "No user id";
my $password = shift; # can be blank
my $website_id = shift or croak "No website id";
my $authorization_name = shift or croak "No authorization name";
my $request = $self->_make_access_token_by_user_id_request($user_id, $password, $website_id, $authorization_name);
$self->_request_access_token($request);
$self->user_id($user_id);
$self->password($password);
$self->website_id($website_id);
$self->authorization_name($authorization_name);
return wantarray ? ($self->access_token, $self->access_token_type) : $self->access_token;
}
sub _make_access_token_by_user_id_request {
my $self = shift;
my $user_id = shift or croak "No user id";
my $password = shift; # can be blank
my $website_id = shift or croak "No website id";
my $authorization_name = shift or croak "No authorization name";
my %params = (
grant_type => 'password',
username => $user_id,
scope => "websiteid:".$website_id." authorizationname:".$authorization_name,
);
if ($password) {
$params{password} = $password;
} else {
$params{password} = "[ignore]";
$params{password_required} = "false";
}
return HTTP::Request::Common::POST( 'https://oauth-patron.overdrive.com/patrontoken', \%params );
}
=head2 Authentication at OverDrive - Granted or "3-Legged" Authorization
With OverDrive there's an extra step - an auth code is returned to the
redirect back handler that needs to make an API call to convert it into
a auth token.
An example:
my $overdrive = WebService::ILS::OverDrive::Patron({
client_id => $client_id,
client_secret => $client_secret,
library_id => $library_id,
});
my $redirect_url = $overdrive->auth_url("http://myapp.com/overdrive-auth");
$response->redirect($redirect_url);
...
/overdrive-auth handler:
my $auth_code = $req->param( $overdrive->auth_code_param_name )
or some_error_handling(), return;
# my $state = $req->param( $overdrive->state_token_param_name )...
local $@;
eval { $overdrive->auth_by_code( $auth_code ) };
if ($@) { some_error_handling(); return; }
$session{overdrive_access_token} = $access_token;
$session{overdrive_access_token_type} = $access_token_type;
$session{overdrive_auth_token} = $auth_token;
...
Somewhere else in your app:
my $ils = WebService::ILS::Provider({
client_id => $client_id,
client_secret => $client_secret,
access_token => $session{overdrive_access_token},
access_token_type => $session{overdrive_access_token_type},
auth_token = $session{overdrive_auth_token}
});
my $checkouts = $overdrive->checkouts;
=head2 auth_url ($redirect_uri, $state_token)
=head3 Input params:
=over 18
=item C<redirect_uri> => return url which will handle redirect back after auth
=item C<state_token> => a token that is returned back unchanged;
for additional security; not required
=back
=cut
sub auth_url {
my $self = shift;
my $redirect_uri = shift or croak "Redirect URI not specified";
my $state_token = shift;
my $library_id = $self->library_id or croak "No Library Id";
return sprintf AUTH_URL .
"?client_id=%s" .
"&redirect_uri=%s" .
"&scope=%s" .
"&response_type=code" .
"&state=%s",
map uri_escape($_),
$self->client_id,
$redirect_uri,
"accountid:$library_id",
defined ($state_token) ? $state_token : ""
;
}
=head2 auth_code_param_name ()
=head2 state_token_param_name ()
=cut
use constant auth_code_param_name => "code";
use constant state_token_param_name => "code";
=head2 auth_by_code ($provider_code, $redirect_uri)
=head3 Returns (access_token, access_token_type, auth_token) or access_token
=cut
sub auth_by_code {
my $self = shift;
my $code = shift or croak "No authorization code";
my $redirect_uri = shift or croak "Redirect URI not specified";
my $auth_type = 'authorization_code';
my $request = HTTP::Request::Common::POST( TOKEN_URL, {
grant_type => 'authorization_code',
code => $code,
redirect_uri => $redirect_uri,
} );
$self->_request_access_token($request);
return wantarray ? ($self->access_token, $self->access_token_type, $self->auth_token) : $self->access_token;
}
=head2 auth_by_token ($provider_token)
=head3 Returns (access_token, access_token_type, auth_token) or access_token
=cut
sub auth_by_token {
my $self = shift;
my $auth_token = shift or croak "No authorization token";
$self->auth_token($auth_token);
my $request = $self->_make_access_token_by_auth_token_request($auth_token);
$self->_request_access_token($request);
return wantarray ? ($self->access_token, $self->access_token_type, $self->auth_token) : $self->access_token;
}
sub _make_access_token_by_auth_token_request {
my $self = shift;
my $auth_token = shift or croak "No authorization token";
return HTTP::Request::Common::POST( TOKEN_URL, {
grant_type => 'refresh_token',
refresh_token => $auth_token,
} );
}
sub make_access_token_request {
my $self = shift;
if (my $auth_token = $self->auth_token) {
return $self->_make_access_token_by_auth_token_request($auth_token);
}
elsif (my $user_id = $self->user_id) {
return $self->_make_access_token_by_user_id_request(
$user_id, $self->password, $self->website_id, $self->authorization_name
);
}
die $self->ERROR_NOT_AUTHENTICATED."\n";
}
sub _request_access_token {
my $self = shift;
my $request = shift or croak "No request";
my $data = $self->SUPER::_request_access_token($request)
or die "Unsuccessful access token request";
if (my $auth_token = $data->{refresh_token}) {
$self->auth_token($auth_token);
}
return $data;
}
sub collection_token {
my $self = shift;
if (my $collection_token = $self->SUPER::collection_token) {
return $collection_token;
}
$self->native_patron; # sets collection_token as a side-effect
my $collection_token = $self->SUPER::collection_token
or die "Patron has no collections\n";
return $collection_token;
}
=head1 CIRCULATION METHOD SPECIFICS
Differences to general L<WebService::ILS> interface
=cut
my %PATRON_XLATE = (
checkoutLimit => "checkout_limit",
existingPatron => 'active',
patronId => 'id',
holdLimit => 'hold_limit',
);
sub patron {
my $self = shift;
return $self->_result_xlate($self->native_patron, \%PATRON_XLATE);
}
my %HOLDS_XLATE = (
totalItems => 'total',
);
my %HOLDS_ITEM_XLATE = (
reserveId => 'id',
holdPlacedDate => 'placed_datetime',
holdListPosition => 'queue_position',
);
sub holds {
my $self = shift;
my $holds = $self->native_holds;
my $items = delete ($holds->{holds}) || [];
my $res = $self->_result_xlate($holds, \%HOLDS_XLATE);
$res->{items} = [
map {
my $item = $self->_result_xlate($_, \%HOLDS_ITEM_XLATE);
my $item_id = $item->{id};
my $metadata = $self->item_metadata($item_id);
my $i = {%$item, %$metadata}; # we need my $i, don't ask me why...
} @$items
];
return $res;
}
=head2 place_hold ($item_id, $notification_email_address, $auto_checkout)
C<$notification_email_address> and C<$auto_checkout> are optional.
C<$auto_checkout> defaults to false.
=head3 Returns holds item record
It is prefered that the C<$notification_email_address> is specified.
If C<$auto_checkout> is set to true, the item will be checked out as soon as
it becomes available.
=cut
sub place_hold {
my $self = shift;
my $hold = $self->native_place_hold(@_) or return;
my $res = $self->_result_xlate($hold, \%HOLDS_ITEM_XLATE);
$res->{total} = $hold->{numberOfHolds};
return $res;
}
# sub suspend_hold { - not really useful
sub remove_hold {
my $self = shift;
my $item_id = shift or croak "No item id";
my $url = $self->circulation_action_url("/holds/$item_id");
return $self->with_delete_request(
\&_basic_callback,
sub {
my ($data) = @_;
return 1 if $data->{errorCode} eq "PatronDoesntHaveTitleOnHold";
die ($data->{message} || $data->{errorCode})."\n";
},
$url
);
}
=head2 checkouts ()
For formats see C<checkout_formats()> below
=cut
my %CHECKOUTS_XLATE = (
totalItems => 'total',
totalCheckouts => 'total_format',
);
sub checkouts {
my $self = shift;
my $checkouts = $self->native_checkouts;
my $items = delete ($checkouts->{checkouts}) || [];
my $res = $self->_result_xlate($checkouts, \%CHECKOUTS_XLATE);
$res->{items} = [
map {
my $item = $self->_checkout_item_xlate($_);
my $item_id = $item->{id};
my $formats = delete ($_->{formats});
my $actions = delete ($_->{actions});
my $metadata = $self->item_metadata($item_id);
if ($formats) {
$formats = $self->_formats_xlate($item_id, $formats);
}
else {
$formats = {};
}
if ($actions) {
if (my $format_action = $actions->{format}) {
foreach (@{$format_action->{fields}}) {
next unless $_->{name} eq "formatType";
foreach my $format (@{$_->{options}}) {
$formats->{$format} = undef unless exists $formats->{$format};
}
last;
}
}
}
my $i = {%$item, %$metadata, formats => $formats}; # we need my $i, don't ask me why...
} @$items
];
return $res;
}
my %CHECKOUT_ITEM_XLATE = (
reserveId => 'id',
checkoutDate => 'checkout_datetime',
expires => 'expires',
);
sub _checkout_item_xlate {
my $self = shift;
my $item = shift;
my $i = $self->_result_xlate($item, \%CHECKOUT_ITEM_XLATE);
if ($item->{isFormatLockedIn}) {
my $formats = $item->{formats} or die "Item $item->{reserveId}: Format locked in, but no formats returned\n";
$i->{format} = $formats->[0]{formatType};
}
return $i;
}
=head2 checkout ($item_id, $format, $allow_multiple_format_checkouts)
C<$format> and C<$allow_multiple_format_checkouts> are optional.
C<$allow_multiple_format_checkouts> defaults to false.
=head3 Returns checkout item record
An item can be available in multiple formats. Checkout is complete only
when the format is specified.
Checkout can be actioned without format being specified. In that case an
early return can be actioned. To complete checkout format must be locked
later (see L<lock_format()> below). That would be the case with
L<place_hold()> with C<$auto_checkout> set to true. Once format is locked,
an early return is not possible.
If C<$allow_multiple_format_checkouts> flag is set to true, mutiple formats
of the same item can be acioned. If it is false (default) and the item was
already checked out, the checked out item record will be returned regardless
of the format.
Checkout record will have an extra field C<format> if format is locked in.
=cut
sub checkout {
my $self = shift;
my $checkout = $self->native_checkout(@_) or return;
return $self->_checkout_item_xlate($checkout);
}
=head2 checkout_formats ($item_id)
=head3 Returns a hashref of available title formats and immediate availability
{ format => available, ... }
If format is not immediately available it must be locked first
=cut
sub checkout_formats {
my $self = shift;
my $id = shift or croak "No item id";
my $formats = $self->native_checkout_formats($id) or return;
$formats = $formats->{'formats'} or return;
return $self->_formats_xlate($id, $formats);
}
sub _formats_xlate {
my $self = shift;
my $id = shift or croak "No item id";
my $formats = shift or croak "No formats";
my %ret;
my $id_uc = uc $id;
foreach (@$formats) {
die "Non-matching item id\nExpected $id\nGot $_->{reserveId}" unless uc($_->{reserveId}) eq $id_uc;
my $format = $_->{formatType};
my $available;
if (my $lt = $_->{linkTemplates}) {
$available = grep /^downloadLink/, keys %$lt;
}
$ret{$format} = $available;
}
return \%ret;
}
sub is_lockable {
my $self = shift;
my $checkout_formats = shift or croak "No checkout formats";
while (my ($format, $available) = each %$checkout_formats) {
return 1 unless $available;
}
return 0;
}
=head2 lock_format ($item_id, $format)
=head3 Returns locked format (should be the same as the input value)
=cut
sub lock_format {
my $self = shift;
my $item_id = shift or croak "No item id";
my $format = shift or croak "No format";
my $lock = $self->native_lock_format($item_id, $format) or return;
die "Non-matching item id\nExpected $item_id\nGot $lock->{reserveId}" unless uc($lock->{reserveId}) eq uc($item_id);
return $lock->{formatType};
}
=head2 checkout_download_url ($item_id, $format, $error_url, $success_url)
=head3 Returns OverDrive download url
Checked out items must be downloaded by users on the OverDrive site.
This method returns the url where the user should be sent to (redirected).
Once the download is complete, user will be redirected back to
C<$error_url> in case of an error, otherwise to optional C<$success_url>
if specified.
See L<https://developer.overdrive.com/apis/download>
=cut
sub checkout_download_url {
my $self = shift;
my $item_id = shift or croak "No item id";
my $format = shift or croak "No format";
my $error_url = shift or die "No error url";
my $success_url = shift;
$error_url = uri_escape($error_url);
$success_url = $success_url ? uri_escape($success_url) : '';
my $url = $self->circulation_action_url("/checkouts/$item_id/formats/$format/downloadlink?errorurl=$error_url&successurl=$success_url");
my $response_data = $self->get_response($url);
my $download_url =
_extract_link($response_data, 'contentLink') ||
_extract_link($response_data, 'contentlink')
or die "Cannot get download url\n".Dumper($response_data);
return $download_url;
}
sub return {
my $self = shift;
my $item_id = shift or croak "No item id";
my $url = $self->circulation_action_url("/checkouts/$item_id");
return $self->with_delete_request(
\&_basic_callback,
sub {
my ($data) = @_;
return 1 if $data->{errorCode} eq "PatronDoesntHaveTitleCheckedOut";
die ($data->{message} || $data->{errorCode})."\n";
},
$url
);
}
=head1 NATIVE METHODS
=head2 native_patron ()
See L<https://developer.overdrive.com/apis/patron-information>
=cut
sub native_patron {
my $self = shift;
my $url = $self->circulation_action_url("");
my $patron = $self->get_response($url) or return;
if (my $collection_token = $patron->{collectionToken}) {
$self->SUPER::collection_token( $collection_token);
}
return $patron;
}
=head2 native_holds ()
=head2 native_place_hold ($item_id, $notification_email_address, $auto_checkout)
See L<https://developer.overdrive.com/apis/holds>
=cut
sub native_holds {
my $self = shift;
my $url = $self->circulation_action_url("/holds");
return $self->get_response($url);
}
sub native_place_hold {
my $self = shift;
my $item_id = shift or croak "No item id";
my $email = shift;
my $auto_checkout = shift;
my @fields = ( {name => "reserveId", value => $item_id } );
push @fields, {name => "autoCheckout", value => "true"} if $auto_checkout;
if ($email) {
push @fields, {name => "emailAddress", value => $email};
} else {
push @fields, {name => "ignoreHoldEmail", value => "true"};
}
my $url = $self->circulation_action_url("/holds");
return $self->with_json_request(
\&_basic_callback,
sub {
my ($data) = @_;
if ($data->{errorCode} eq "AlreadyOnWaitList") {
if (my $holds = $self->native_holds) {
my $item_id_uc = uc $item_id;
foreach (@{ $holds->{holds} || [] }) {
if ( uc($_->{reserveId}) eq $item_id_uc ) {
$_->{numberOfHolds} = $holds->{totalItems};
return $_;
}
}
}
}
die ($data->{message} || $data->{errorCode})."\n";
},
$url,
{fields => \@fields}
);
}
=head2 native_checkouts ()
=head2 native_checkout_info ($item_id)
=head2 native_checkout ($item_id, $format, $allow_multiple_format_checkouts)
=head2 native_checkout_formats ($item_id)
=head2 native_lock_format ($item_id, $format)
See L<https://developer.overdrive.com/apis/checkouts>
=cut
sub native_checkouts {
my $self = shift;
my $url = $self->circulation_action_url("/checkouts");
return $self->get_response($url);
}
sub native_checkout_info {
my $self = shift;
my $id = shift or croak "No item id";
my $url = $self->circulation_action_url("/checkouts/$id");
return $self->get_response($url);
}
sub native_checkout_formats {
my $self = shift;
my $id = shift or croak "No item id";
my $url = $self->circulation_action_url("/checkouts/$id/formats");
return $self->get_response($url);
}
sub native_checkout {
my $self = shift;
my $item_id = shift or croak "No item id";
my $format = shift;
my $allow_multi = shift;
if (my $checkouts = $self->native_checkouts) {
my $item_id_uc = uc $item_id;
foreach (@{ $checkouts->{checkouts} || [] }) {
if ( uc($_->{reserveId}) eq $item_id_uc ) {
if ($format) {
if ($_->{isFormatLockedIn}) {
return $_ if lc($_->{formats}[0]{formatType}) eq lc($format);
die "Item $item_id has already been locked for different format '$_->{formats}[0]{formatType}'\n"
unless $allow_multi;
}
# else { $self->native_lock_format()? }
}
# else { die if !$allow_multi ? }
return $_;
}
}
}
my $url = $self->circulation_action_url("/checkouts");
return $self->with_json_request(
\&_basic_callback,
undef,
$url,
{fields => _build_checkout_fields($item_id, $format)}
);
}
sub native_lock_format {
my $self = shift;
my $item_id = shift or croak "No item id";
my $format = shift or croak "No format";
my $url = $self->circulation_action_url("/checkouts/$item_id/formats");
return $self->with_json_request(
\&_basic_callback,
sub {
my ($data) = @_;
die "$format ".($data->{message} || $data->{errorCode})."\n";
},
$url,
{fields => _build_checkout_fields($item_id, $format)}
);
}
sub _build_checkout_fields {
my ($id, $format) = @_;
my @fields = ( {name => "reserveId", value => $id } );
push @fields, {name => "formatType", value => $format} if $format;
return \@fields;
}
# Circulation helpers
sub circulation_action_url {
my $self = shift;
my $action = shift;
return $self->_circulation_api_url.$self->API_VERSION."/patrons/me$action";
}
# API helpers
sub _extract_link {
my ($data, $link) = @_;
return $data->{links}{$link}->{href};
}
sub _basic_callback { return $_[0]; }
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

View File

@ -0,0 +1,698 @@
package WebService::ILS::RecordedBooks;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::RecordedBooks - WebService::ILS module for RecordedBooks services
=head1 SYNOPSIS
use WebService::ILS::RecordedBooks::Partner;
or
use WebService::ILS::RecordedBooks::Patron;
=head1 DESCRIPTION
L<WebService::ILS::RecordedBooks::Partner> - services
that use partner credentials, for any patron
L<WebService::ILS::RecordedBooks::PartnerPatron> - same as above,
except it operates on a single patron account
L<WebService::ILS::RecordedBooks::Patron> - services
that use individual patron credentials, in addition to partner credentials
L<WebService::ILS::RecordedBooks::PartnerPatron> is preferred over
L<WebService::ILS::RecordedBooks::Patron> because the later requires patron
credentials - username and password. However, if you do not know patron's
email or RecordedBooks id (barcode) you are stuck with Patron interface.
See L<WebService::ILS>
=cut
use Carp;
use HTTP::Request::Common;
use URI::Escape;
use JSON qw(to_json);
use parent qw(WebService::ILS::JSON);
use constant API_VERSION => "v1";
use constant BASE_DOMAIN => "rbdigital.com";
=head1 CONSTRUCTOR
=head2 new (%params_hash or $params_hashref)
=head3 Additional constructor params:
=over 12
=item C<ssl> => if set to true use https
=item C<domain> => RecordedBooks domain for title url
=back
C<client_id> is either RecordedBooks id (barcode) or email
C<domain> if set is either "whatever.rbdigital.com" or "whatever",
in which case rbdigital.com is appended.
=cut
use Class::Tiny qw(
ssl
domain
_api_base_url
);
__PACKAGE__->_set_param_spec({
client_id => { required => 0 },
library_id => { required => 1 },
domain => { required => 0 },
ssl => { required => 0, default => 1 },
});
sub BUILD {
my $self = shift;
my $params = shift;
if (my $domain = $self->domain) {
$self->domain("$domain.".BASE_DOMAIN) unless $domain =~ m/\./;
}
my $ssl = $self->ssl;
my $ua = $self->user_agent;
$ua->ssl_opts( verify_hostname => 0 ) if $ssl;
my $api_url = sprintf "%s://api.%s", $ssl ? "https" : "http", BASE_DOMAIN;
$self->_api_base_url($api_url);
}
sub api_url {
my $self = shift;
my $action = shift or croak "No action";
return sprintf "%s/%s%s", $self->_api_base_url, API_VERSION, $action;
}
sub library_action_base_url {
my $self = shift;
return $self->api_url("/libraries/".$self->library_id);
}
sub products_url {
my $self = shift;
return $self->library_action_base_url."/search";
}
sub circulation_action_url {
my $self = shift;
my $action = shift or croak "No action";
return $self->circulation_action_base_url(@_).$action;
}
sub _access_auth_string {
my $self = shift;
return $self->client_secret;
}
sub native_countries {
my $self = shift;
my $url = $self->api_url("/countries");
return $self->get_without_auth($url);
}
sub native_facets {
my $self = shift;
my $url = $self->api_url("/facets");
return $self->get_response($url);
}
sub native_facet_values {
my $self = shift;
my $facet = shift or croak "No facet";
my $url = $self->api_url("/facets/$facet");
return $self->get_without_auth($url);
}
sub native_libraries_search {
my $self = shift;
my $query = shift or croak "No query";
my $region = shift;
my %search_params = ( term => $query );
$search_params{ar} = $region if $region;
my $url = $self->api_url("/suggestive/libraries");
return $self->get_without_auth($url, \%search_params);
}
sub get_without_auth {
my $self = shift;
my $url = shift or croak "No url";
my $get_params = shift; # hash ref
my $uri = URI->new($url);
$uri->query_form($get_params) if $get_params;
my $request = HTTP::Request::Common::GET( $uri );
my $response = $self->user_agent->request( $request );
$self->check_response($response);
return $self->process_json_response($response, sub {
my ($data) = @_;
die "No data\n" unless $data;
return $data;
});
}
=head1 DISCOVERY METHODS
=head2 facets ()
=head3 Returns a hashref of facet => [values]
=cut
sub facets {
my $self = shift;
my $facets = $self->native_facets;
my %facet_values;
foreach (@$facets) {
my $f = $_->{facetToken};
$facet_values{$f} = [map $_->{valueToken}, @{ $self->native_facet_values($f) }];
}
return \%facet_values;
}
=head2 search ($params_hashref)
=head3 Additional input params:
=over 12
=item C<facets> => a hashref of facet values
=back
=cut
my %SORT_XLATE = (
rating => undef,
publication_date => undef, # not available
);
sub search {
my $self = shift;
my $params = shift || {};
my $url = $self->products_url;
if (my $query = delete $params->{query}) {
$query = join " ", @$query if ref $query;
$params->{all} = $query;
}
if (my $page_size = delete $params->{page_size}) {
$params->{'page-size'} = $page_size;
}
if (my $page_number = delete $params->{page}) {
die "page_size must be specified for paging" unless $params->{'page-size'};
$params->{'page-index'} = $page_number - 1;
}
if (my $sort = delete $params->{sort}) {
my $sa = $self->_parse_sort_string($sort, \%SORT_XLATE);
if (@$sa) {
my @params = %$params;
foreach (@$sa) {
my ($s, $d) = split ':';
push @params, "sort-by", $s;
push @params, "sort-order", $d if $d;
}
return $self->_search_result_xlate( $self->get_response($url, \@params) );
}
}
return $self->_search_result_xlate( $self->get_response($url, $params) );
}
sub _search_result_xlate {
my $self = shift;
my $res = shift or return;
my $domain = $self->domain;
return {
items => [ map {
my $i = $self->_item_xlate($_->{item});
$i->{url} ||= "https://$domain/#titles/$i->{isbn}" if $domain;
$i->{available} = $_->{interest}{isAvailable};
$i;
} @{$res->{items} || []} ],
page_size => $res->{pageSize},
page => $res->{pageIndex} + 1,
pages => $res->{pageCount},
};
}
my %SEARCH_RESULT_ITEM_XLATE = (
id => "id",
title => "title",
subtitle => "subtitle",
shortDescription => "description",
mediaType => "media",
downloadUrl => "url",
encryptionKey => "encryption_key",
isbn => "isbn",
hasDrm => "drm",
releasedDate => "publication_date",
size => "size",
language => "language",
expiration => "expires",
);
my %ITEM_FILES_XLATE = (
id => "id",
filename => "filename",
display => "title",
downloadUrl => "url",
size => "size",
);
sub _item_xlate {
my $self = shift;
my $item = shift;
my $std_item = $self->_result_xlate($item, \%SEARCH_RESULT_ITEM_XLATE);
if (my $images = delete $item->{images}) { # XXX let's say that caller wouldn't mind
$std_item->{images} = {map { $_->{name} => $_->{url} } @$images};
}
if (my $files = delete $item->{files}) {
$std_item->{files} = [ map $self->_result_xlate($_, \%ITEM_FILES_XLATE), @$files ];
}
my %facets;
if (my $publisher = delete $item->{publisher}) {
if (ref $publisher) {
if (my $f = $publisher->{facet}) {
$facets{$f} = [$publisher->{token}];
}
$publisher = $publisher->{text};
}
$std_item->{publisher} = $publisher;
}
if (my $authors = delete $item->{authors}) {
my @a;
if (ref $authors) {
foreach (@$authors) {
push @a, $_->{text} if $_->{text};
if (my $f = $_->{facet}) {
my $f_a = $facets{$f} ||= [];
push @$f_a, $_->{token};
}
}
}
else {
push @a, $authors;
}
$std_item->{author} = join ", ", @a;
}
foreach my $v (values %$item) {
my $ref = ref $v or next;
$v = [$v] if $ref eq "HASH";
next unless ref($v) eq "ARRAY";
foreach (@$v) {
if (my $f = $_->{facet}) {
my $f_a = $facets{$f} ||= [];
push @$f_a, $_->{token};
}
}
}
$std_item->{facets} = \%facets if keys %facets;
return $std_item;
}
=head2 named_query_search ($query, $media)
See C<native_named_query_search()> below for $query, $media
=cut
sub named_query_search {
my $self = shift;
return $self->_search_result_xlate( $self->native_named_query_search(@_) );
}
=head2 facet_search ($facets)
See C<native_facet_search()> below for $facets
=cut
sub facet_search {
my $self = shift;
return $self->_search_result_xlate( $self->native_facet_search(@_) );
}
sub item_metadata {
my $self = shift;
my $ni = $self->native_item(@_) or return;
return $self->_item_xlate( $ni->{item} );
}
=head1 CIRCULATION METHOD SPECIFICS
Differences to general L<WebService::ILS> interface
=cut
=head2 holds ()
=head2 place_hold ($isbn)
=head2 remove_hold ($isbn)
=cut
sub holds {
my $self = shift;
my $items = $self->native_holds(@_);
return {
total => scalar @$items,
items => [ map {
my $i = $self->_item_xlate($_);
$i->{hold_id} = $_->{transactionId};
$i;
} @$items ],
};
}
sub place_hold {
my $self = shift;
my $isbn = shift or croak "No isbn";
my $url = $self->circulation_action_url("/holds/$isbn", @_);
my $request = HTTP::Request::Common::POST( $url );
my $response = $self->_request_with_auth($request);
unless ($response->is_success) {
$self->process_json_error_response($response, sub {
my ($data) = @_;
if (my $message = $data->{message}) {
return 1 if $message =~ m/already exists/i;
die $message;
}
die $self->_error_from_json($data) || "Cannot place hold: ".to_json($data);
});
}
if (my $holds = $self->holds(@_)) {
foreach my $i (@{ $holds->{items} }) {
if ($i->{isbn} eq $isbn) {
$i->{total} = $holds->{total};
return $i;
}
}
}
my $content = $response->decoded_content;
my $content_type = $response->header('Content-Type');
my $error;
if ($content_type && $content_type =~ m!application/json!) {
if (my $data = eval { from_json( $content ) }) {
$error = $self->_error_from_json($data);
}
}
die $error || "Cannot place hold:\n$content";
}
sub remove_hold {
my $self = shift;
my $isbn = shift or croak "No isbn";
my $url = $self->circulation_action_url("/holds/$isbn", @_);
my $request = HTTP::Request::Common::DELETE( $url );
my $response = $self->_request_with_auth($request);
unless ($response->is_success) {
return $self->process_json_error_response($response, sub {
my ($data) = @_;
if (my $message = $data->{message}) {
return 1 if $message =~ m/not exists|expired/i;
die $message;
}
die $self->_error_from_json($data) || "Cannot remove hold: ".to_json($data);
});
}
return 1;
}
=head2 checkouts ()
=head2 checkout ($isbn, $days)
=head2 renew ($isbn)
=head2 return ($isbn)
=cut
sub checkouts {
my $self = shift;
my $items = $self->native_checkouts(@_);
return {
total => scalar @$items,
items => [ map {
my $i = $self->_item_xlate($_);
$i->{checkout_id} = $_->{transactionId};
$i;
} @$items ],
};
}
sub checkout {
my $self = shift;
my $isbn = shift or croak "No isbn";
my $days = shift;
if (my $checkouts = $self->checkouts(@_)) {
foreach my $i (@{ $checkouts->{items} }) {
if ( $i->{isbn} eq $isbn ) {
$i->{total} = scalar @{ $checkouts->{items} };
return $i;
}
}
}
my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
$url .= "?days=$days" if $days;
my $res = $self->with_post_request(
\&_basic_callback,
$url
);
my $checkouts = $self->checkouts(@_) or die "Cannot checkout, unknown error";
foreach my $i (@{ $checkouts->{items} }) {
if ($i->{isbn} eq $isbn) {
$i->{total} = scalar @{ $checkouts->{items} };
return $i;
}
}
die $res->{message} || "Cannot checkout, unknown error";
}
sub renew {
my $self = shift;
my $isbn = shift or croak "No isbn";
my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
my $res = $self->with_put_request(
\&_basic_callback,
$url
);
my $checkouts = $self->checkouts(@_) or die "Cannot renew, unkmown error";
foreach my $i (@{ $checkouts->{items} }) {
if ($i->{isbn} eq $isbn) {
$i->{total} = scalar @{ $checkouts->{items} };
return $i;
}
}
die $res->{output} || "Cannot renew, unknown error";
}
sub return {
my $self = shift;
my $isbn = shift or croak "No isbn";
my $url = $self->circulation_action_url("/checkouts/$isbn", @_);
my $request = HTTP::Request::Common::DELETE( $url );
my $response = $self->_request_with_auth($request);
unless ($response->is_success) {
return $self->process_json_error_response($response, sub {
my ($data) = @_;
if (my $message = $data->{message}) {
return 1 if $message =~ m/not exists|expired/i;
die $message;
}
die "Cannot return: ".to_json($data);
});
}
return 1;
}
=head1 NATIVE METHODS
=head2 native_search ($params_hashref)
See L<https://developer.overdrive.com/apis/search>
=cut
sub native_search {
my $self = shift;
my $search_params = shift;
return $self->get_response($self->products_url, $search_params);
}
=head2 native_named_query_search ($query, $media)
$query can be one of 'bestsellers', 'most-popular', 'newly-added'
$media can be 'eaudio' or 'ebook'
=cut
my @MEDIA = qw( eaudio ebook );
my @NAMED_QUERY = ( 'bestsellers', 'most-popular', 'newly-added' );
sub native_named_query_search {
my $self = shift;
my $query = shift or croak "No query";
my $media = shift or croak "No media";
croak "Invalid media $media - should be one of ".join(", ", @MEDIA)
unless grep { $_ eq $media } @MEDIA;
croak "Invalid named query $query - should be one of ".join(", ", @NAMED_QUERY)
unless grep { $_ eq $query } @NAMED_QUERY;
my $url = $self->products_url."/$media/$query";
return $self->get_response($url);
}
=head2 native_facet_search ($facets)
$facets can be either:
* a hashref of facet => [values],
* an arrayref of values
* a single value
=cut
sub native_facet_search {
my $self = shift;
my $facets = shift or croak "No facets";
$facets = [$facets] unless ref $facets;
my $url = $self->products_url;
if (ref ($facets) eq "ARRAY") {
$url = join "/", $url, @$facets;
undef $facets;
}
return $self->get_response($url, $facets);
}
# Item API
=head2 native_item ($isbn)
=head2 native_item_summary ($isbn)
=head3 Returns subset of item fields, with addition of summary field
=cut
sub native_item {
my $self = shift;
my $isbn = shift or croak "No isbn";
my $url = $self->title_url($isbn);
return $self->get_response($url);
}
sub native_item_summary {
my $self = shift;
my $isbn = shift or croak "No isbn";
my $url = $self->title_url("$isbn/summary");
return $self->get_response($url);
}
=head2 native_holds ()
See L<http://developer.rbdigital.com/endpoints/title-holds>
=cut
sub native_holds {
my $self = shift;
my $url = $self->circulation_action_url("/holds/all", @_);
return $self->get_response($url);
}
=head2 native_checkouts ()
=cut
sub native_checkouts {
my $self = shift;
my $url = $self->circulation_action_url("/checkouts/all", @_);
return $self->get_response($url);
}
# Utility methods
sub _basic_callback { return $_[0]; }
sub get_response {
my $self = shift;
my $url = shift or croak "No url";
my $get_params = shift; # hash ref
return $self->with_get_request(\&_basic_callback, $url, $get_params);
}
sub _error_from_json {
my $self = shift;
my $data = shift or croak "No json data";
return join " ", grep defined, $data->{errorCode}, $data->{message};
}
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

View File

@ -0,0 +1,126 @@
package WebService::ILS::RecordedBooks::Partner;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::RecordedBooks::Partner - RecordedBooks partner API
=head1 SYNOPSIS
use WebService::ILS::RecordedBooks::Partner;
=head1 DESCRIPTION
L<WebService::ILS::RecordedBooks::Partner> - services
that use trusted partner credentials
See L<WebService::ILS::RecordedBooks>
=cut
use Carp;
use parent qw(WebService::ILS::RecordedBooks::PartnerBase);
sub circulation_action_base_url {
my $self = shift;
my $patron_id = shift or croak "No patron id";
return $self->library_action_base_url."/patrons/${patron_id}";
}
=head1 DISCOVERY METHODS
=head2 facet_search ($facets)
See C<native_facet_search()> below for $facets
=head2 named_query_search ($query, $media)
See C<native_named_query_search()> below for $query, $media
=head1 CIRCULATION METHOD SPECIFICS
Differences to general L<WebService::ILS> interface
=head2 patron_id ($email_or_id)
=head2 holds ($patron_id)
=head2 place_hold ($patron_id, $isbn)
=head2 checkouts ($patron_id)
=head2 checkout ($patron_id, $isbn)
=head2 renew ($patron_id, $isbn)
=head2 return ($patron_id, $isbn)
=cut
foreach my $sub (qw(place_hold remove_hold renew return)) {
no strict "refs";
*$sub = sub {
my $self = shift;
my $patron_id = shift or croak "No patron id";
my $isbn = shift or croak "No isbn";
my $supersub = "SUPER::$sub";
return $self->$supersub($isbn, $patron_id);
};
}
sub checkout {
my $self = shift;
my $patron_id = shift or croak "No patron id";
my $isbn = shift or croak "No isbn";
my $days = shift;
return $self->SUPER::checkout($isbn, $days, $patron_id);
}
=head1 NATIVE METHODS
=head2 native_quick_search ($query, $category)
$category can be one of 'all', 'title', 'author', or 'narrator';
optional, defaults to 'all'
=cut
=head2 native_facet_search ($facets)
$facets can be either:
* a hashref of facet => [values],
* an arrayref of values
* a single value
=head2 native_named_query_search ($query, $media)
$query can be one of 'bestsellers', 'most-popular', 'newly-added'
$media can be 'eaudio' or 'ebook'
=head2 native_patron ($email_or_id)
=cut
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

View File

@ -0,0 +1,90 @@
package WebService::ILS::RecordedBooks::PartnerBase;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::RecordedBooks::PartnerBase - RecordedBooks partner API
=head1 SYNOPSIS
See L<WebService::ILS::RecordedBooks::Partner>
and L<WebService::ILS::RecordedBooks::PartnerPatron>;
=cut
use Carp;
use URI::Escape;
use parent qw(WebService::ILS::RecordedBooks);
sub title_url {
my $self = shift;
my $isbn = shift or croak "No isbn";
return $self->library_action_base_url."/titles/$isbn";
}
sub _request_with_token {
my $self = shift;
my $request = shift or croak "No request";
$request->header( Authorization => "Basic ".$self->client_secret );
return $self->user_agent->request( $request );
}
=head1 CIRCULATION METHOD SPECIFICS
=cut
use constant NATIVE_PATRON_ID_KEY => "patronId";
my %PATRON_XLATE = (
NATIVE_PATRON_ID_KEY() => 'id',
);
sub patron {
my $self = shift;
return $self->_result_xlate($self->native_patron(@_), \%PATRON_XLATE);
}
=head2 patron_id ($email_or_id)
=cut
sub patron_id {
my $self = shift;
my $patron = $self->native_patron(@_) or return;
return $patron->{NATIVE_PATRON_ID_KEY()};
}
=head1 NATIVE METHODS
=head2 native_patron ($email_or_id)
=cut
sub native_patron {
my $self = shift;
my $cardnum_or_email = shift or croak "No patron identification";
my $url = $self->api_url("/rpc/libraries/".$self->library_id."/patrons/".uri_escape($cardnum_or_email));
return $self->get_response($url);
}
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

View File

@ -0,0 +1,103 @@
package WebService::ILS::RecordedBooks::PartnerPatron;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::RecordedBooks::PartnerPatron - RecordedBooks patner API
for an individual patron
=head1 SYNOPSIS
use WebService::ILS::RecordedBooks::PartnerPatron;
=head1 DESCRIPTION
L<WebService::ILS::RecordedBooks::PartnerPatron> - services
that use trusted partner credentials to operat on behalf of a specified patron
See L<WebService::ILS::RecordedBooks::Partner>
=cut
use Carp;
use parent qw(WebService::ILS::RecordedBooks::PartnerBase);
=head1 CONSTRUCTOR
=head2 new (%params_hash or $params_hashref)
=head3 Additional constructor params:
=over 12
=item C<user_id> => RecordedBooks user id (barcode), or email
=back
C<client_id> is either RecordedBooks id (barcode) or email
=cut
use Class::Tiny qw(
user_id
);
__PACKAGE__->_set_param_spec({
user_id => { required => 1 },
});
sub BUILD {
my $self = shift;
my $params = shift;
local $@;
my $patron_id = eval { $self->SUPER::patron_id($self->user_id) }
or croak "Invalid user_id ".$self->user_id.($@ ? "\n$@" : "");
$self->user_id($patron_id);
}
sub circulation_action_base_url {
my $self = shift;
return $self->library_action_base_url."/patrons/".$self->user_id;
}
sub patron_id {
my $self = shift;
return $self->user_id;
}
sub patron {
my $self = shift;
return {id => $self->user_id};
}
=head1 NATIVE METHODS
=head2 native_patron ()
This method cannot be called
=cut
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

View File

@ -0,0 +1,109 @@
package WebService::ILS::RecordedBooks::Patron;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::RecordedBooks::Patron - RecordedBooks patron API
=head1 SYNOPSIS
use WebService::ILS::RecordedBooks::Patron;
=cut
=head1 DESCRIPTION
L<WebService::ILS::RecordedBooks::Patron> - services
that require patron credentials
See L<WebService::ILS::RecordedBooks>
=cut
use Carp;
use parent qw(WebService::ILS::RecordedBooks);
=head1 CONSTRUCTOR
=head2 new (%params_hash or $params_hashref)
=head3 Additional constructor params:
=over 16
=item C<user_id>
=item C<password>
=back
=cut
use Class::Tiny qw(
user_id password
);
__PACKAGE__->_set_param_spec({
user_id => { required => 1 },
password => { required => 1 },
});
sub _access_auth_string {
my $self = shift;
return $self->client_secret;
}
sub _extract_token_from_response {
my $self = shift;
my $data = shift;
return ($data->{bearer}, "bearer");
}
sub make_access_token_request {
my $self = shift;
my $url = $self->api_url("/tokens");
my %params = (
UserName => $self->user_id,
Password => $self->password,
LibraryId => $self->library_id,
);
my $req = HTTP::Request::Common::POST( $url );
return $self->_json_request_content($req, \%params);
}
sub title_url {
my $self = shift;
my $isbn = shift or croak "No isbn";
return $self->api_url("/titles/$isbn");
}
sub circulation_action_base_url {
my $self = shift;
return $self->api_url("/transactions");
}
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut

184
lib/WebService/ILS/XML.pm Normal file
View File

@ -0,0 +1,184 @@
package WebService::ILS::XML;
use Modern::Perl;
=encoding utf-8
=head1 NAME
WebService::ILS::JSON - WebService::ILS module for services with XML API
=head1 DESCRIPTION
To be subclassed
See L<WebService::ILS>
=cut
use Carp;
use HTTP::Request::Common;
use URI;
use XML::LibXML;
use parent qw(WebService::ILS);
sub with_get_request {
my $self = shift;
my $callback = shift or croak "No callback";
my $url = shift or croak "No url";
my $get_params = shift; # hash ref
my $uri = URI->new($url);
$uri->query_form($get_params) if $get_params;
my $request = HTTP::Request::Common::GET( $uri );
my $response = $self->_request_with_auth($request);
return $self->process_xml_response($response, $callback);
}
sub with_delete_request {
my $self = shift;
my $callback = shift or croak "No callback";
my $error_callback = shift;
my $url = shift or croak "No url";
my $request = HTTP::Request::Common::DELETE( $url );
my $response = $self->_request_with_auth($request);
return 1 if $response->is_success;
return $self->_error_result(
sub { $self->process_invalid_xml_response($response, $error_callback); },
$request,
$response
);
}
sub with_post_request {
my $self = shift;
my $callback = shift or croak "No callback";
my $url = shift or croak "No url";
my $post_params = shift || {}; # hash ref
my $request = HTTP::Request::Common::POST( $url, $post_params );
my $response = $self->_request_with_auth($request);
return $self->process_xml_response($response, $callback);
}
sub with_xml_request {
my $self = shift;
my $callback = shift or croak "No callback";
my $error_callback = shift;
my $url = shift or croak "No url";
my $dom = shift or croak "No XML document";
my $method = shift || 'post';
my $req_builder = "HTTP::Request::Common::".uc( $method );
no strict 'refs';
my $request = $req_builder->( $url );
$request->header( 'Content-Type' => 'application/xml; charset=utf-8' );
$request->content( $dom->toeString );
$request->header( 'Content-Length' => bytes::length($request->content));
my $response = $self->_request_with_auth($request);
return $self->process_xml_response($response, $callback, $error_callback);
}
sub process_xml_response {
my $self = shift;
my $response = shift or croak "No response";
my $success_callback = shift;
my $error_callback = shift;
unless ($response->is_success) {
return $self->process_xml_error_response($response, $error_callback);
}
my $content_type = $response->header('Content-Type');
die $response->as_string
unless $content_type && $content_type =~ m!application/xml!;
my $content = $response->decoded_content
or die $self->invalid_response_exception_string($response);
local $@;
my $doc = eval { XML::LibXML->load_xml( string => $content )->documentElement() };
#XXX check XML::LibXML::Error
die "$@\nResponse:\n".$response->as_string if $@;
return $doc unless $success_callback;
my $res = eval {
$success_callback->($doc);
};
die "$@\nResponse:\n$content" if $@;
return $res;
}
sub process_xml_error_response {
my $self = shift;
my $response = shift or croak "No response";
my $error_callback = shift;
my $content_type = $response->header('Content-Type');
if ($content_type && $content_type =~ m!application/xml!) {
my $content = $response->decoded_content
or die $self->invalid_response_exception_string($response);
my $doc = eval { XML::LibXML->load_xml( string => $content )->documentElement() };
#XXX check XML::LibXML::Error
die "$@\nResponse:\n$content" if $@;
if ($error_callback) {
return $error_callback->($doc);
}
die $self->_error_from_xml($doc) || "Invalid response:\n$content";
}
die $self->invalid_response_exception_string($response);
}
sub _error_from_xml {};
sub _first_child_content {
my $self = shift;
my $parent_elt = shift or croak "No parent element";
my $tag = shift or croak "No child tag name";
my $child_elts = $parent_elt->getElementsByTagName($tag) or return;
my $child_elt = $child_elts->shift or return;
return $child_elt->textContent;
}
sub _children_content {
my $self = shift;
my $parent_elt = shift or croak "No parent element";
my $tag = shift or croak "No child tag name";
my $child_elts = $parent_elt->getElementsByTagName($tag) or return;
return [ $child_elts->map( sub { $_[0]->textContent } ) ];
}
sub _xml_to_hash {
my $self = shift;
my $parent_elt = shift or croak "No parent element";
my $tags = shift or croak "No children tag names";
return { map { $_ => $self->_first_child_content($parent_elt, $_) } @$tags };
}
1;
__END__
=head1 LICENSE
Copyright (C) Catalyst IT NZ Ltd
Copyright (C) Bywater Solutions
This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.
=head1 AUTHOR
Srdjan Janković E<lt>srdjan@catalyst.net.nzE<gt>
=cut