File Coverage

lib/Hetula/Client.pm
Criterion Covered Total %
statement 215 242 88.8
branch 47 84 55.9
condition 16 35 45.7
subroutine 34 35 97.1
pod 17 17 100.0
total 329 413 79.6


line stmt bran cond sub pod time code
1             package Hetula::Client;
2              
3 3     3   622330 use Modern::Perl '2015';
  3         13  
  3         25  
4              
5             our $VERSION = '0.008';
6              
7             # ABSTRACT: Interface with Hetula
8             #
9             # Copyright 2018 National Library of Finland
10              
11             =encoding utf8
12              
13             =head1 NAME
14              
15             Hetula::Client - Perl client implementation to communicate with Hetula.
16              
17             =head1 DESCRIPTION
18              
19             Perl client implementation to communicate with Hetula, the Patron data store
20              
21             =head1 SYNOPSIS
22              
23             my $hc = Hetula::Client->new({baseURL => 'https://hetula.example.com'});
24              
25             my $loginResponse = $hc->login({username => 'master', password => 'blaster', organization => 'Administratoria'});
26             die($loginResponse->{error}) if ($loginResponse->{error});
27              
28             my $loginActiveResp = $hc->loginActive();
29             ok(! $loginActiveResp->{error}, "Login active");
30              
31             my $ssnAddResp = $hc->ssnAdd({ssn => 'bad-ssn'});
32             ok($ssnAddResp->{error}, "SSN add failed - Bad SSN '$ssnAddResp->{error}'");
33              
34             my $ssnGetResp = $hc->ssnGet({id => 1});
35             ok(! $ssnGetResp->{error}, "SSN got");
36              
37             my $ssnsBatchAddResp = $hc->ssnsBatchAdd(['101010-101A', '101010-102B']);
38             is(@$ssnsBatchAddResp, 2, "SSNs batch add");
39              
40             =cut
41              
42             ##Pragmas
43 3     3   639 use Modern::Perl;
  3         6  
  3         14  
44 3     3   267 use feature qw(signatures);
  3         6  
  3         98  
45 3     3   17 no warnings qw(experimental::signatures);
  3         4  
  3         115  
46 3     3   1341 use Carp::Always;
  3         4218  
  3         19  
47 3     3   1574 use autodie;
  3         44746  
  3         14  
48 3     3   22221 use English; #Use verbose alternatives for perl's strange $0 and $\ etc.
  3         3307  
  3         18  
49              
50             ##External modules
51 3     3   2546 use Mojo::UserAgent;
  3         835462  
  3         54  
52 3     3   213 use Storable;
  3         8  
  3         199  
53 3     3   1703 use Regexp::Common;
  3         8130  
  3         13  
54 3     3   449245 use Data::Printer;
  3         116536  
  3         29  
55              
56             =head3 new
57              
58             @param1 {HASHRef} baseURL => https://hetula.example.com
59             credentials => filepath, Where to load the credentials file.
60             see slurpCredentials() for more info.
61              
62             =cut
63              
64 2     2 1 4882 sub new($class, $params) {
  2         6  
  2         13  
  2         5  
65 2 100       11 slurpCredentials($params->{credentials}, $params) if ($params->{credentials});
66 2         11 _detectKohaEnvironment($params);
67 2 50       9 die("Hetula::Client::BadParam - parameter 'baseURL' is missing") unless $params->{baseURL};
68 2 50       17 die("Hetula::Client::BadParam - parameter 'baseURL' '$params->{baseURL}' is not a valid URI") unless $params->{baseURL} =~ /$RE{URI}{HTTP}{-scheme=>qr!https?!}/;
69              
70 2         759 my $s = bless(Storable::dclone($params), $class);
71              
72 2 50       46 $s->{ua} = Mojo::UserAgent->new() unless $s->{ua};
73 2         21 return $s;
74             }
75              
76             =head2 API Access methods
77              
78             =head3 login
79              
80             See Hetula API doc for endpoint POST /api/v1/auth
81              
82             @param1 {HASHRef} username => String || undef if given via credentials during construction,
83             password => String || undef if given via credentials during construction,
84             organization => String || undef if given via credentials during construction,
85              
86             =cut
87              
88 3     3 1 5476 sub login($s, $params={}) {
  3         7  
  3         6  
  3         6  
89 3 100       11 $params->{username} = $s->{username} unless $params->{username};
90 3 100       12 $params->{password} = $s->{password} unless $params->{password};
91 3 100       12 $params->{organization} = $s->{organization} unless $params->{organization};
92              
93 3         10 my $tx = $s->ua->post( $s->baseURL().'/api/v1/auth', {Accept => '*/*'}, json => $params );
94 3         45399 my $json = _handleResponse($tx);
95 3 100       300 return $json if $json->{error};
96              
97 2         7 my $cookies = $tx->res->cookies;
98 2         418 my $sessionCookie = $cookies->[0];
99 2         9 $s->ua->cookie_jar->add($sessionCookie);
100              
101 2         69 my $csrfHeader = $tx->res->headers->header('X-CSRF-Token');
102              
103             $s->ua->on(start => sub {
104 26     26   23775 my ($ua, $tx) = @_;
105 26         87 $tx->req->headers->header('X-CSRF-Token' => $csrfHeader);
106 2         45 });
107              
108 2         25 return $json;
109             }
110              
111             =head3 loginActive
112              
113             =cut
114              
115 1     1 1 288 sub loginActive($s) {
  1         3  
  1         2  
116 1         2 my $tx = $s->ua->get( $s->baseURL().'/api/v1/auth' );
117 1         7439 return _handleResponse($tx);
118             }
119              
120             =head3 ssnAdd
121              
122             See Hetula API doc for endpoint POST /api/v1/ssns
123              
124             =cut
125              
126 1     1 1 2613 sub ssnAdd($s, $params) {
  1         2  
  1         3  
  1         2  
127 1         5 my $tx = $s->ua->post( $s->baseURL().'/api/v1/ssns', {Accept => '*/*'}, json => $params );
128 1         7098 return _handleResponse($tx);
129             }
130              
131             =head3 ssnGet
132              
133             See Hetula API doc for endpoint GET /api/v1/users/
134              
135             @param1 {HASHRef} id => ssn id to get
136              
137             =cut
138              
139 1     1 1 504 sub ssnGet($s, $params) {
  1         3  
  1         2  
  1         2  
140 1 50       10 die("Hetula::Client::BadParameter - parameter 'id' is not an integer") unless $params->{id} =~ /$RE{num}{int}/;
141 1         226 my $tx = $s->ua->get( $s->baseURL().'/api/v1/ssns/'.$params->{id} );
142 1         6857 return _handleResponse($tx);
143             }
144              
145             =head3 ssnsBatchAdd
146              
147             See Hetula API doc for endpoint GET /api/v1/ssns/batch
148              
149             @param1 {ARRAYRef} of ssns
150              
151             =cut
152              
153 19     19 1 656 sub ssnsBatchAdd($s, $ssnArray) {
  19         38  
  19         32  
  19         31  
154 19         66 my $tx = $s->ua->post( $s->baseURL().'/api/v1/ssns/batch', {Accept => '*/*'}, json => $ssnArray );
155 19         136636 return _handleResponse($tx);
156             }
157              
158             =head2 ssnsBatchAddChunked
159              
160             Invokes the ssnsBatchAdd()-method repeatedly in small chunks. Useful for
161             importing an inconveniently large amount of ssns that would otherwise timeout
162             the Hetula-server.
163              
164             @param1 {sub} Receives a feeder callback, which sends ssn-lists to the
165             ssnsBatchAdd()-method.
166             for ex.
167             sub {
168             #Keep sending ssns while there are ssns to send
169             return ['ssn1','ssn2','ssn3'] if @ssns;
170             #When ssns run out, return false to signal the end of transmission
171             return undef || [];
172             }
173              
174             @param2 {sub} Receives a digester callback, which receives the ssnsBatchAdd()-methods
175             response|return value.
176             for ex.
177             sub {
178             my ($ssnReportsFromHetula) = @_;
179             print $FH_OUT "$_->{ssn}->{id},$_->{ssn}->{ssn},$_->{error}\n" for @$ssnReportsFromHetula;
180             }
181              
182             =cut
183              
184 4     4 1 8 sub ssnsBatchAddChunked($s, $feederCallback, $digesterCallback) {
  4         7  
  4         8  
  4         6  
  4         6  
185 4         11 while (my $ssns = $feederCallback->()) {
186 21 100 66     683 last unless($ssns && @$ssns);
187 18         75 $digesterCallback->($s->ssnsBatchAdd($ssns))
188             }
189             }
190              
191             =head2 ssnsBatchAddFromFile
192              
193             Wrapper for ssnsBatchAddChunked(), where this manages the file IO as well.
194              
195             @param1 {filepath} Where to read ssns from.
196             This can be a simple .csv-file, in this case the last (or only)
197             column is expected to be one containing the ssn that is
198             intended to be migrated to Hetula.
199             If there are any extra columns, they are appended to the
200             ssn report/result .csv-file as ssn report context.
201             @param2 {filepath} Where to write the ssn migration results/reports
202              
203             =cut
204              
205 4     4 1 55817 sub ssnsBatchAddFromFile($s, $filenameIn, $filenameOut, $batchSize=500) {
  4         63  
  4         10  
  4         8  
  4         8  
  4         8  
206 4 50       24 open(my $FH_IN, "<:encoding(UTF-8)", $filenameIn) or die("Hetula::Client::File - Opening the given file '$filenameIn' for reading ssns failed: $!\n");
207 4 50       1131 open(my $FH_OUT, ">:encoding(UTF-8)", $filenameOut) or die("Hetula::Client::File - Opening the given file '$filenameOut' for writing ssns results failed: $!\n");
208              
209 4         978 print $FH_OUT "ssnId,ssn,error,context\n";
210              
211 4         11 my $i = 0; #Keep track of how many ssns are processed.
212 4         7 my $retry = 0;
213 4         10 my @ssns;
214             my @context;
215             my $feeder = sub { #Feeds ssns to the batch grinder
216 21 100   21   106 if ($retry == 0) { #Do not reset the input if retrying
217 16         48 @ssns = ();
218 16         38 @context = ();
219 16         230 while (<$FH_IN>) {
220 33         102 $i++;
221 33         56 chomp;
222 33         128 my @cols = split(',', $_);
223 33         67 push(@ssns, pop(@cols)); #The last value is expected to be the ssn
224 33         86 push(@context, \@cols); #always push the context, even if cols is empty. This makes sure the order of contexts is preserved!
225 33 100       114 last if @ssns >= $batchSize;
226             }
227             }
228 21 100       78 if (@ssns) {
229 18         3062 print __PACKAGE__."::ssnsBatchAddFromFile() :> '$i' ssns fed.\n";
230             }
231             else {
232 3         338 print __PACKAGE__."::ssnsBatchAddFromFile() :> All '$i' ssns fed.\n";
233             }
234              
235 21         195 return \@ssns;
236 4         29 };
237             my $digester = sub { #digests ssn reports from Hetula
238 18     18   5506 my ($ssnReports) = @_;
239              
240 18 100       71 if (ref($ssnReports) ne 'ARRAY') { #There is something wrong!
241 6         42 Data::Printer::p($ssnReports);
242 6         16622 $retry++;
243              
244 6   50     6000973 sleep($ENV{MOCK_BAD_CONNECTION}||10); #Wait a bit, maybe the pipi goes away.
245 6 50       202 $ENV{MOCK_BAD_CONNECTION_RETRIES} = $retry if $ENV{MOCK_BAD_CONNECTION}; #Awkwardly mix test hooks here, sorry about that.
246              
247 6 100       168 return if $retry <= 3;
248 1         28 die("Hetula::Client::Connection - Retried '".($retry-1)."' times, but still cannot succeed. Sorry... Exception from Hetula: ".Data::Printer::np($ssnReports));
249             }
250 12         24 $retry = 0; #Presumably we have succeeded in something here.
251              
252 12         39 for (my $i=0 ; $i<@$ssnReports ; $i++) {
253 30         57 my $res = $ssnReports->[$i];
254 30         48 my $ssn = $ssns[$i];
255              
256 30 50       121 die("Hetula::Client::SSN - Local ssns and Hetula ssns are out of sync at batch file row='$i', local ssn='$ssn', Hetula ssn='$res->{ssn}->{ssn}'?") unless ($res->{ssn}->{ssn} eq $ssn);
257              
258             print $FH_OUT join(",", $res->{ssn}->{id}//'', $ssn, $res->{error}//'',
259 30   100     138 @{$context[$i]} #Add what is left of the given file columns as a context for the ssn report file. This makes it easier for possible next processing steps in the migration pipeline.
  30   100     180  
260             )."\n";
261             }
262 12         1793 print __PACKAGE__."::ssnsBatchAddFromFile() :> '$i' reports digested.\n";
263 4         20 };
264 4         20 $s->ssnsBatchAddChunked($feeder, $digester);
265             }
266              
267             =head3 userAdd
268              
269             See Hetula API doc for endpoint POST /api/v1/users
270              
271             =cut
272              
273 2     2 1 3 sub userAdd($s, $params) {
  2         4  
  2         3  
  2         3  
274 2         5 my $tx = $s->ua->post( $s->baseURL().'/api/v1/users', {Accept => '*/*'}, json => $params );
275 2         13947 return _handleResponse($tx);
276             }
277              
278             =head3 userBasicAdd
279              
280             Adds a user with only the most minimum permisions needed to push records into Hetula.
281             Organization the user belongs to is implied from the currently logged in organization.
282              
283             @param {HASHRef} username => 'MattiM',
284             password => 'Secret',
285             realname => 'Matti Meikäläinen',
286              
287             =cut
288              
289 1     1 1 1182 sub userBasicAdd($s, $params) {
  1         3  
  1         2  
  1         2  
290             $params->{permissions} = [
291 1         4 'ssns-post',
292             'auth-get',
293             ];
294 1         5 return $s->userAdd($params);
295             }
296              
297             =head3 userReadAdd
298              
299             Adds a user with read access to Hetula.
300             Organization the user belongs to is implied from the currently logged in organization.
301              
302             @param {HASHRef} username => 'MattiM',
303             password => 'Secret',
304             realname => 'Matti Meikäläinen',
305              
306             =cut
307              
308 1     1 1 749 sub userReadAdd($s, $params) {
  1         24  
  1         3  
  1         2  
309             $params->{permissions} = [
310 1         5 'ssns-post',
311             'ssns-id-get',
312             'auth-get',
313             ];
314 1         4 return $s->userAdd($params);
315             }
316              
317             =head3 userMod
318              
319             See Hetula API doc for endpoint PUT /api/v1/users/
320              
321             @param {HASHRef} username or id => mandatory,
322             other patron attributes => and values,
323             ...
324              
325             =cut
326              
327 0     0 1 0 sub userMod($s, $params) {
  0         0  
  0         0  
  0         0  
328 0   0     0 my $id = $params->{id} || $params->{username};
329 0 0       0 die("Hetula::Client::BadParameter - parameter 'id' or 'username' is missing") unless ($id);
330 0         0 my $tx = $s->ua->put( $s->baseURL()."/api/v1/users/$id", {Accept => '*/*'}, json => $params );
331 0         0 return _handleResponse($tx);
332             }
333              
334             =head3 userChangePassword
335              
336             @param {HASHRef} username or id => mandatory,
337             password => mandatory - the new password,
338              
339             =cut
340              
341 1     1 1 2598 sub userChangePassword($s, $params) {
  1         4  
  1         3  
  1         2  
342 1   33     8 my $id = $params->{id} || $params->{username};
343 1 50       5 die("Hetula::Client::BadParameter - parameter 'id' or 'username' is missing") unless ($id);
344 1 50       3 die("Hetula::Client::BadParameter - parameter 'password' is missing") unless $params->{password};
345 1         3 my $tx = $s->ua->put( $s->baseURL()."/api/v1/users/$id/password", {Accept => '*/*'}, json => $params );
346 1         7639 return _handleResponse($tx);
347             }
348              
349             =head3 userDisableAccount
350              
351             To recover from a disabled account, change the password
352              
353             @param {String} username or id
354              
355             =cut
356              
357 1     1 1 743 sub userDisableAccount($s, $params) {
  1         2  
  1         2  
  1         2  
358 1   33     8 my $id = $params->{id} || $params->{username};
359 1 50       4 die("Hetula::Client::BadParameter - parameter 'id' or 'username' is missing") unless ($id);
360 1         4 my $tx = $s->ua->delete( $s->baseURL()."/api/v1/users/$id/password", {Accept => '*/*'} );
361 1         14288 return _handleResponse($tx);
362             }
363              
364             =head2 HELPERS
365              
366             =head3 slurpCredentials
367             @static
368              
369             Reads the contents of a credentials file.
370              
371             The credentials file must consist of up to 4 lines, with each line
372             specifying the following commandline argument replacements:
373             username
374             password
375             organization
376             url
377              
378             @param1 {String} Path to the credentials file
379             @param2 {HASHRef} Optional, HASHRef where to inject the found credentials
380              
381             =cut
382              
383 1     1 1 3 sub slurpCredentials($credentialsFile, $injectHere=undef) {
  1         2  
  1         2  
  1         2  
384 1 50       5 open(my $FH, '<:encoding(UTF-8)', $credentialsFile) or die("Couldn't read '$credentialsFile': $!");
385 1 50 33     3151 my $username = <$FH>; if ($username) { chomp($username); $injectHere->{username} = $username if $username && $injectHere; }
  1 50       14  
  1         3  
  1         13  
386 1 50 33     3 my $password = <$FH>; if ($password) { chomp($password); $injectHere->{password} = $password if $password && $injectHere; }
  1 50       4  
  1         2  
  1         6  
387 1 50 33     3 my $organization = <$FH>; if ($organization) { chomp($organization); $injectHere->{organization} = $organization if $organization && $injectHere; }
  1 50       4  
  1         2  
  1         7  
388 1 50 33     11 my $baseURL = <$FH>; if ($baseURL) { chomp($baseURL); $injectHere->{baseURL} = $baseURL if $baseURL && $injectHere; }
  1 50       5  
  1         3  
  1         7  
389 1         18 return ($username, $password, $organization, $baseURL);
390             }
391              
392             =head2 ATTRIBUTES
393              
394             =head3 ua
395              
396             =cut
397              
398 37     37 1 16071 sub ua { return $_[0]->{ua} }
399              
400             =head3 baseURL
401              
402             =cut
403              
404 29     29 1 278 sub baseURL { return $_[0]->{baseURL} }
405              
406             ################
407             #######################
408             ### Private methods ###
409             ####################
410              
411 29     29   66 sub _handleResponse($tx) {
  29         57  
  29         46  
412 29 100       108 if (my $res = $tx->success) {
413 21 50       36102 if ($ENV{HETULA_DEBUG}) {
414 0         0 print "Request success:\n";
415 0         0 Data::Printer::p($res->json);
416             }
417 21   100     72 return $res->json || { OK => $tx->res->code };
418             }
419             else {
420 8         21996 my $error = $tx->error;
421 8   33     127 $error->{error} = $tx->res->body || $error->{message} || $error->{code};
422 8 50       229 if ($ENV{HETULA_DEBUG}) {
423 0         0 print "Request error:\n";
424 0         0 Data::Printer::p($error);
425             }
426 8         33 return $error;
427             }
428             }
429              
430             ## @static
431             ## If you are using https://koha-community.org/
432             ## Hetula::Client tries to pick configurations from there automatically.
433             ##
434 2     2   4 sub _detectKohaEnvironment($params) {
  2         4  
  2         5  
435 2     1   192 eval "use C4::Context;"; #This way the Dist::Zilla ::Plugin::AutoPrereqs doesn't think this is a mandatory requirement
  1     1   218  
  0         0  
  0         0  
  1         176  
  0            
  0            
436 2 50       2980 unless ($@) {
437 0         0 print "Koha detected. ";
438 0 0       0 if (my $hetulaConfig = C4::Context->config('hetula')) {
439 0 0       0 if (my $url = $hetulaConfig->{url}) {
440 0 0       0 $params->{baseURL} = $url unless $params->{baseURL};
441 0         0 print "Hetula baseURL found '$url'. ";
442             }
443             else {
444 0 0       0 die "KOHA_CONF: hetula->url is missing!" unless $url;
445             }
446              
447 0 0       0 if (my $org = $hetulaConfig->{organization}) {
448 0 0       0 $params->{organization} = $org unless $params->{organization};
449 0         0 print "Hetula organization found '$org'.\n";
450             }
451             else {
452 0         0 die "KOHA_CONF: hetula->organization is missing!";
453             }
454             }
455             else {
456 0         0 die("Koha detected, but 'hetula' is not configured in the \$KOHA_CONF='$ENV{KOHA_CONF}'");
457             }
458             };
459             }
460              
461             1;