File Coverage

lib/Hetula/Client.pm
Criterion Covered Total %
statement 212 239 88.7
branch 47 84 55.9
condition 14 33 42.4
subroutine 33 34 97.0
pod 17 17 100.0
total 323 407 79.3


line stmt bran cond sub pod time code
1             package Hetula::Client;
2              
3             our $VERSION = '0.006';
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   589881 use Modern::Perl;
  3         19650  
  3         25  
42 3     3   515 use feature qw(signatures);
  3         7  
  3         99  
43 3     3   15 no warnings qw(experimental::signatures);
  3         5  
  3         135  
44 3     3   1504 use Carp::Always;
  3         4398  
  3         22  
45 3     3   1766 use autodie;
  3         47735  
  3         16  
46 3     3   22855 use English; #Use verbose alternatives for perl's strange $0 and $\ etc.
  3         5617  
  3         18  
47              
48             ##External modules
49 3     3   2542 use Mojo::UserAgent;
  3         795603  
  3         42  
50 3     3   238 use Storable;
  3         9  
  3         196  
51 3     3   1772 use Regexp::Common;
  3         8569  
  3         13  
52 3     3   457650 use Data::Printer;
  3         120766  
  3         29  
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 4814 sub new($class, $params) {
  2         6  
  2         7  
  2         4  
63 2 100       16 slurpCredentials($params->{credentials}, $params) if ($params->{credentials});
64 2         24 _detectKohaEnvironment($params);
65 2 50       11 die("Hetula::Client::BadParam - parameter 'baseURL' is missing") unless $params->{baseURL};
66 2 50       25 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         861 my $s = bless(Storable::dclone($params), $class);
69              
70 2 50       56 $s->{ua} = Mojo::UserAgent->new() unless $s->{ua};
71 2         24 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 5737 sub login($s, $params={}) {
  3         9  
  3         9  
  3         6  
87 3 100       15 $params->{username} = $s->{username} unless $params->{username};
88 3 100       15 $params->{password} = $s->{password} unless $params->{password};
89 3 100       12 $params->{organization} = $s->{organization} unless $params->{organization};
90              
91 3         10 my $tx = $s->ua->post( $s->baseURL().'/api/v1/auth', {Accept => '*/*'}, json => $params );
92 3         47036 my $json = _handleResponse($tx);
93 3 100       310 return $json if $json->{error};
94              
95 2         10 my $cookies = $tx->res->cookies;
96 2         416 my $sessionCookie = $cookies->[0];
97 2         10 $s->ua->cookie_jar->add($sessionCookie);
98              
99 2         71 my $csrfHeader = $tx->res->headers->header('X-CSRF-Token');
100              
101             $s->ua->on(start => sub {
102 26     26   22994 my ($ua, $tx) = @_;
103 26         83 $tx->req->headers->header('X-CSRF-Token' => $csrfHeader);
104 2         46 });
105              
106 2         26 return $json;
107             }
108              
109             =head3 loginActive
110              
111             =cut
112              
113 1     1 1 383 sub loginActive($s) {
  1         4  
  1         2  
114 1         4 my $tx = $s->ua->get( $s->baseURL().'/api/v1/auth' );
115 1         7792 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 2878 sub ssnAdd($s, $params) {
  1         3  
  1         4  
  1         2  
125 1         5 my $tx = $s->ua->post( $s->baseURL().'/api/v1/ssns', {Accept => '*/*'}, json => $params );
126 1         7117 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 352 sub ssnGet($s, $params) {
  1         3  
  1         2  
  1         2  
138 1 50       12 die("Hetula::Client::BadParameter - parameter 'id' is not an integer") unless $params->{id} =~ /$RE{num}{int}/;
139 1         269 my $tx = $s->ua->get( $s->baseURL().'/api/v1/ssns/'.$params->{id} );
140 1         6677 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 19     19 1 558 sub ssnsBatchAdd($s, $ssnArray) {
  19         37  
  19         36  
  19         28  
152 19         70 my $tx = $s->ua->post( $s->baseURL().'/api/v1/ssns/batch', {Accept => '*/*'}, json => $ssnArray );
153 19         134444 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 10 sub ssnsBatchAddChunked($s, $feederCallback, $digesterCallback) {
  4         8  
  4         8  
  4         6  
  4         7  
183 4         9 while (my $ssns = $feederCallback->()) {
184 21 100 66     848 last unless($ssns && @$ssns);
185 18         103 $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 47174 sub ssnsBatchAddFromFile($s, $filenameIn, $filenameOut, $batchSize=500) {
  4         13  
  4         8  
  4         12  
  4         65  
  4         7  
204 4 50       26 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       1448 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         11 my $i = 0; #Keep track of how many ssns are processed.
210 4         8 my $retry = 0;
211 4         12 my @ssns;
212             my @context;
213             my $feeder = sub { #Feeds ssns to the batch grinder
214 21 100   21   102 if ($retry == 0) { #Do not reset the input if retrying
215 16         45 @ssns = ();
216 16         35 @context = ();
217 16         234 while (<$FH_IN>) {
218 33         103 $i++;
219 33         55 chomp;
220 33         117 my @cols = split(',', $_);
221 33         62 push(@ssns, pop(@cols)); #The last value is expected to be the ssn
222 33         50 push(@context, \@cols); #always push the context, even if cols is empty. This makes sure the order of contexts is preserved!
223 33 100       110 last if @ssns >= $batchSize;
224             }
225             }
226 21 100       80 if (@ssns) {
227 18         525 print __PACKAGE__."::ssnsBatchAddFromFile() :> '$i' ssns fed.\n";
228             }
229             else {
230 3         34 print __PACKAGE__."::ssnsBatchAddFromFile() :> All '$i' ssns fed.\n";
231             }
232              
233 21         158 return \@ssns;
234 4         29 };
235             my $digester = sub { #digests ssn reports from Hetula
236 18     18   5070 my ($ssnReports) = @_;
237              
238 18 100       69 if (ref($ssnReports) ne 'ARRAY') { #There is something wrong!
239 6         50 Data::Printer::p($ssnReports);
240 6         14801 $retry++;
241              
242 6   50     6001235 sleep($ENV{MOCK_BAD_CONNECTION}||10); #Wait a bit, maybe the pipi goes away.
243 6 50       280 $ENV{MOCK_BAD_CONNECTION_RETRIES} = $retry if $ENV{MOCK_BAD_CONNECTION}; #Awkwardly mix test hooks here, sorry about that.
244              
245 6 100       230 return if $retry <= 3;
246 1         23 die("Hetula::Client::Connection - Retried '".($retry-1)."' times, but still cannot succeed. Sorry... Exception from Hetula: ".Data::Printer::np($ssnReports));
247             }
248 12         22 $retry = 0; #Presumably we have succeeded in something here.
249              
250 12         34 for (my $i=0 ; $i<@$ssnReports ; $i++) {
251 30         47 my $res = $ssnReports->[$i];
252 30         51 my $ssn = $ssns[$i];
253              
254 30 50       78 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     129 @{$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     172  
258             )."\n";
259             }
260 12         564 print __PACKAGE__."::ssnsBatchAddFromFile() :> '$i' reports digested.\n";
261 4         25 };
262 4         20 $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         4  
272 2         7 my $tx = $s->ua->post( $s->baseURL().'/api/v1/users', {Accept => '*/*'}, json => $params );
273 2         13292 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 1066 sub userBasicAdd($s, $params) {
  1         2  
  1         3  
  1         2  
288             $params->{permissions} = [
289 1         4 'ssns-post',
290             'auth-get',
291             ];
292 1         7 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 655 sub userReadAdd($s, $params) {
  1         3  
  1         2  
  1         3  
307             $params->{permissions} = [
308 1         30 'ssns-post',
309             'ssns-id-get',
310             'auth-get',
311             ];
312 1         5 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 2701 sub userChangePassword($s, $params) {
  1         3  
  1         3  
  1         3  
340 1   33     9 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         6 my $tx = $s->ua->put( $s->baseURL()."/api/v1/users/$id/password", {Accept => '*/*'}, json => $params );
344 1         8289 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 629 sub userDisableAccount($s, $params) {
  1         2  
  1         3  
  1         2  
356 1   33     10 my $id = $params->{id} || $params->{username};
357 1 50       4 die("Hetula::Client::BadParameter - parameter 'id' or 'username' is missing") unless ($id);
358 1         3 my $tx = $s->ua->delete( $s->baseURL()."/api/v1/users/$id/password", {Accept => '*/*'} );
359 1         15754 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 3 sub slurpCredentials($credentialsFile, $injectHere=undef) {
  1         3  
  1         3  
  1         2  
382 1 50       7 open(my $FH, '<:encoding(UTF-8)', $credentialsFile) or die("Couldn't read '$credentialsFile': $!");
383 1 50 33     3686 my $username = <$FH>; if ($username) { chomp($username); $injectHere->{username} = $username if $username && $injectHere; }
  1 50       13  
  1         5  
  1         15  
384 1 50 33     4 my $password = <$FH>; if ($password) { chomp($password); $injectHere->{password} = $password if $password && $injectHere; }
  1 50       5  
  1         3  
  1         7  
385 1 50 33     3 my $organization = <$FH>; if ($organization) { chomp($organization); $injectHere->{organization} = $organization if $organization && $injectHere; }
  1 50       4  
  1         3  
  1         8  
386 1 50 33     28 my $baseURL = <$FH>; if ($baseURL) { chomp($baseURL); $injectHere->{baseURL} = $baseURL if $baseURL && $injectHere; }
  1 50       7  
  1         3  
  1         10  
387 1         20 return ($username, $password, $organization, $baseURL);
388             }
389              
390             =head2 ATTRIBUTES
391              
392             =head3 ua
393              
394             =cut
395              
396 37     37 1 16493 sub ua { return $_[0]->{ua} }
397              
398             =head3 baseURL
399              
400             =cut
401              
402 29     29 1 307 sub baseURL { return $_[0]->{baseURL} }
403              
404             ################
405             #######################
406             ### Private methods ###
407             ####################
408              
409 29     29   67 sub _handleResponse($tx) {
  29         58  
  29         42  
410 29 100       115 if (my $res = $tx->success) {
411 21 50       501 if ($ENV{HETULA_DEBUG}) {
412 0         0 print "Request success:\n";
413 0         0 Data::Printer::p($res->json);
414             }
415 21         73 return $res->json;
416             }
417             else {
418 8         175 my $error = $tx->error;
419 8   33     113 $error->{error} = $tx->res->body || $error->{message} || $error->{code};
420 8 50       225 if ($ENV{HETULA_DEBUG}) {
421 0         0 print "Request error:\n";
422 0         0 Data::Printer::p($error);
423             }
424 8         33 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   4 sub _detectKohaEnvironment($params) {
  2         5  
  2         4  
433 2     1   202 eval "use C4::Context;"; #This way the Dist::Zilla ::Plugin::AutoPrereqs doesn't think this is a mandatory requirement
  1     1   290  
  0         0  
  0         0  
  1         147  
  0            
  0            
434 2 50       3111 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;