File Coverage

lib/Hetula/Client.pm
Criterion Covered Total %
statement 212 239 88.7
branch 47 84 55.9
condition 16 35 45.7
subroutine 33 34 97.0
pod 17 17 100.0
total 325 409 79.4


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