Bug 34587: Improve SUSHI COUNTER error handling
[koha.git] / Koha / ERM / EUsage / UsageDataProvider.pm
1 package Koha::ERM::EUsage::UsageDataProvider;
2
3 # Copyright 2023 PTFS Europe
4
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use Modern::Perl;
21
22 use HTTP::Request;
23 use JSON qw( decode_json );
24 use LWP::UserAgent;
25
26 use Koha::Exceptions;
27
28 use base qw(Koha::Object);
29
30 use Koha::ERM::EUsage::CounterFile;
31 use Koha::ERM::EUsage::CounterFiles;
32 use Koha::ERM::EUsage::UsageTitles;
33 use Koha::ERM::EUsage::UsageItems;
34 use Koha::ERM::EUsage::UsagePlatforms;
35 use Koha::ERM::EUsage::UsageDatabases;
36 use Koha::ERM::EUsage::MonthlyUsages;
37 use Koha::ERM::EUsage::SushiCounter;
38 use Koha::BackgroundJob::ErmSushiHarvester;
39
40 =head1 NAME
41
42 Koha::ERM::EUsage::UsageDataProvider - Koha ErmUsageDataProvider Object class
43
44 =head1 API
45
46 =head2 Class Methods
47
48 =head3 counter_files
49
50 Getter/setter for counter_files for this usage data provider
51
52 =cut
53
54 sub counter_files {
55     my ( $self, $counter_files ) = @_;
56
57     if ($counter_files) {
58         for my $counter_file (@$counter_files) {
59             Koha::ERM::EUsage::CounterFile->new($counter_file)->store( $self->{job_callbacks} );
60         }
61     }
62     my $counter_files_rs = $self->_result->erm_counter_files;
63     return Koha::ERM::EUsage::CounterFiles->_new_from_dbic($counter_files_rs);
64 }
65
66 =head3 enqueue_counter_file_processing_job
67
68 Enqueues a background job to process a COUNTER file that has been uploaded
69
70 =cut
71
72 sub enqueue_counter_file_processing_job {
73     my ( $self, $args ) = @_;
74
75     my @jobs;
76     my $job_id = Koha::BackgroundJob::ErmSushiHarvester->new->enqueue(
77         {
78             ud_provider_id => $self->erm_usage_data_provider_id,
79             file_content   => $args->{file_content},
80         }
81     );
82
83     push(
84         @jobs,
85         { job_id => $job_id }
86     );
87
88     return \@jobs;
89 }
90
91 =head3 enqueue_sushi_harvest_jobs
92
93 Enqueues one harvest background job for each report type in this usage data provider
94
95 =cut
96
97 sub enqueue_sushi_harvest_jobs {
98     my ( $self, $args ) = @_;
99
100     my @report_types = split( /;/, $self->report_types );
101
102     my @jobs;
103     foreach my $report_type (@report_types) {
104
105         my $job_id = Koha::BackgroundJob::ErmSushiHarvester->new->enqueue(
106             {
107                 ud_provider_id       => $self->erm_usage_data_provider_id,
108                 report_type          => $report_type,
109                 begin_date           => $args->{begin_date},
110                 end_date             => $args->{end_date},
111                 ud_provider_name     => $self->name,
112             }
113         );
114
115         push(
116             @jobs,
117             {
118                 report_type => $report_type,
119                 job_id      => $job_id
120             }
121         );
122     }
123
124     return \@jobs;
125 }
126
127 =head3 harvest_sushi
128
129     $ud_provider->harvest_sushi(
130         {
131             begin_date  => $args->{begin_date},
132             end_date    => $args->{end_date},
133             report_type => $args->{report_type}
134         }
135     );
136
137 Runs this usage data provider's SUSHI harvester
138 Builds the URL query and requests the COUNTER 5 SUSHI service
139
140 COUNTER SUSHI api spec:
141 https://app.swaggerhub.com/apis/COUNTER/counter-sushi_5_0_api/5.0.2
142
143 =over
144
145 =item begin_date
146
147 Begin date of the SUSHI harvest
148
149 =back
150
151 =over
152
153 =item end_date
154
155 End date of the SUSHI harvest
156
157 =back
158
159 =over
160
161 =item report_type
162
163 Report type to run this harvest on
164
165 =back
166
167 =cut
168
169 sub harvest_sushi {
170     my ( $self, $args ) = @_;
171
172     # Set class wide vars
173     $self->{report_type} = $args->{report_type};
174     $self->{begin_date}  = $args->{begin_date};
175     $self->{end_date}    = $args->{end_date};
176     my $url      = $self->_build_url_query;
177     my $request  = HTTP::Request->new( 'GET' => $url );
178     my $ua       = LWP::UserAgent->new;
179     my $response = $ua->simple_request($request);
180
181     if ( $response->code >= 400 ) {
182         my $result = decode_json( $response->decoded_content );
183
184         my $message;
185         if ( ref($result) eq 'ARRAY' ) {
186             for my $r (@$result) {
187                 $message .= $r->{message};
188             }
189         } else {
190
191             #TODO: May want to check $result->{Report_Header}->{Exceptions} here
192             $message = $result->{message} || $result->{Message} || q{};
193             if ( $result->{errors} ) {
194                 for my $e ( @{ $result->{errors} } ) {
195                     $message .= $e->{message};
196                 }
197             }
198         }
199
200         #TODO: May want to add a job error message here?
201         warn sprintf "ERROR - SUSHI service %s returned %s - %s\n", $url,
202             $response->code, $message;
203         if ( $response->code == 404 ) {
204             Koha::Exceptions::ObjectNotFound->throw($message);
205         } elsif ( $response->code == 401 ) {
206             Koha::Exceptions::Authorization::Unauthorized->throw($message);
207         } else {
208
209             #TODO: May want to add a job error message here?
210             die sprintf "ERROR requesting SUSHI service\n%s\ncode %s: %s\n",
211                 $url, $response->code,
212                 $message;
213         }
214     } elsif ( $response->code == 204 ) {    # No content
215         return;
216     }
217
218     my $decoded_response = decode_json( $response->decoded_content );
219
220     return if $self->_sushi_errors($decoded_response);
221
222     # Parse the SUSHI response
223     my $sushi_counter =
224         Koha::ERM::EUsage::SushiCounter->new( { response => $decoded_response } );
225     my $counter_file = $sushi_counter->get_COUNTER_from_SUSHI;
226
227     return if $self->_counter_file_size_too_large($counter_file);
228
229     $self->counter_files( 
230         [
231             { 
232                 usage_data_provider_id => $self->erm_usage_data_provider_id,
233                 file_content           => $counter_file,
234                 date_uploaded          => POSIX::strftime( "%Y%m%d%H%M%S", localtime ),
235
236                 #TODO: add ".csv" to end of filename here
237                 filename => $self->name . "_" . $self->{report_type},
238             }
239         ]
240     );
241
242 }
243
244 =head3 set_background_job_callbacks
245
246     $self->set_background_job_callbacks($background_job_callbacks);
247
248 Sets the background job callbacks
249
250 =over
251
252 =item background_job_callbacks
253
254 Background job callbacks
255
256 =back
257
258 =cut
259
260 sub set_background_job_callbacks {
261     my ( $self, $background_job_callbacks ) = @_;
262
263     $self->{job_callbacks} = $background_job_callbacks;
264 }
265
266 =head3 test_connection
267
268 Tests the connection of the harvester to the SUSHI service and returns any alerts of planned SUSHI outages
269
270 =cut
271
272 sub test_connection {
273     my ($self) = @_;
274
275     my $url = _validate_url($self->service_url, 'status');
276     $url .= 'status';
277     $url .= '?customer_id=' . $self->customer_id;
278     $url .= '&requestor_id=' . $self->requestor_id if $self->requestor_id;
279     $url .= '&api_key=' . $self->api_key           if $self->api_key;
280
281     my $request  = HTTP::Request->new( 'GET' => $url );
282     my $ua       = LWP::UserAgent->new;
283     my $response = $ua->simple_request($request);
284
285     if ( $response->{_rc} >= 400 ) {
286         my $message = $response->{_msg};
287         if ( $response->{_rc} == 404 ) {
288             Koha::Exceptions::ObjectNotFound->throw($message);
289         } elsif ( $response->{_rc} == 401 ) {
290             Koha::Exceptions::Authorization::Unauthorized->throw($message);
291         } else {
292             die sprintf "ERROR testing SUSHI service\n%s\ncode %s: %s\n",
293                 $url, $response->{_rc},
294                 $message;
295         }
296     }
297
298     my $result = decode_json( $response->decoded_content );
299     my $status;
300     if ( ref($result) eq 'ARRAY' ) {
301         for my $r (@$result) {
302             $status = $r->{Service_Active};
303         }
304     } else {
305         $status = $result->{Service_Active};
306     }
307
308     if ($status) {
309         return 1;
310     } else {
311         return 0;
312     }
313 }
314
315 =head3 erm_usage_titles
316
317 Method to embed erm_usage_titles to titles for report formatting
318
319 =cut
320
321 sub erm_usage_titles {
322     my ($self) = @_;
323     my $usage_title_rs = $self->_result->erm_usage_titles;
324     return Koha::ERM::EUsage::UsageTitles->_new_from_dbic($usage_title_rs);
325 }
326
327 =head3 erm_usage_muses
328
329 Method to embed erm_usage_muses to titles for report formatting
330
331 =cut
332
333 sub erm_usage_muses {
334     my ($self) = @_;
335     my $usage_mus_rs = $self->_result->erm_usage_muses;
336     return Koha::ERM::EUsage::MonthlyUsages->_new_from_dbic($usage_mus_rs);
337 }
338
339 =head3 erm_usage_platforms
340
341 Method to embed erm_usage_platforms to platforms for report formatting
342
343 =cut
344
345 sub erm_usage_platforms {
346     my ( $self ) = @_;
347     my $usage_platform_rs = $self->_result->erm_usage_platforms;
348     return Koha::ERM::EUsage::UsagePlatforms->_new_from_dbic($usage_platform_rs);
349 }
350
351 =head3 erm_usage_items
352
353 Method to embed erm_usage_items to items for report formatting
354
355 =cut
356
357 sub erm_usage_items {
358     my ( $self ) = @_;
359     my $usage_item_rs = $self->_result->erm_usage_items;
360     return Koha::ERM::EUsage::UsageItems->_new_from_dbic($usage_item_rs);
361 }
362
363 =head3 erm_usage_databases
364
365 Method to embed erm_usage_databases to databases for report formatting
366
367 =cut
368
369 sub erm_usage_databases {
370     my ( $self ) = @_;
371     my $usage_database_rs = $self->_result->erm_usage_databases;
372     return Koha::ERM::EUsage::UsageDatabases->_new_from_dbic($usage_database_rs);
373 }
374
375 =head2 Internal methods
376
377 =head3 _build_url_query
378
379 Build the URL query params for COUNTER 5 SUSHI request
380
381 =cut
382
383 sub _build_url_query {
384     my ($self) = @_;
385
386     unless ( $self->service_url && $self->customer_id ) {
387         die sprintf
388             "SUSHI Harvesting config for usage data provider %d is missing service_url or customer_id\n",
389             $self->erm_usage_data_provider_id;
390     }
391
392     my $url = _validate_url($self->service_url, 'harvest');
393
394     $url .= lc $self->{report_type};
395     $url .= '?customer_id=' . $self->customer_id;
396     $url .= '&requestor_id=' . $self->requestor_id if $self->requestor_id;
397     $url .= '&api_key=' . $self->api_key           if $self->api_key;
398     $url .= '&begin_date=' . substr $self->{begin_date}, 0, 7   if $self->{begin_date};
399     $url .= '&end_date=' . substr $self->{end_date}, 0, 7       if $self->{end_date};
400
401     return $url;
402 }
403
404
405 =head3 _validate_url
406
407 Checks whether the url ends in a trailing "/" and adds one if not
408
409 my $url = _validate_url($url, 'harvest')
410
411 $caller is either the harvest_sushi function ("harvest") or the test_connection function ("status")
412
413 =cut
414
415 sub _validate_url {
416     my ( $url, $caller ) = @_;
417
418     if($caller eq 'harvest') {
419         # Not all urls will end in "/" - add one so they are standardised
420         $url = _check_trailing_character($url);
421         # All SUSHI report requests should be to the "/reports" endpoint
422         # Not all providers in the counter registry include this in their data so we need to check and add it
423         my $reports_param = substr $url, -8;
424         $url .= 'reports/' if $reports_param ne 'reports/';
425     } else {
426         $url = _check_trailing_character($url);
427     }
428
429     return $url;
430 }
431
432 =head3 _check_trailing_character
433
434 Checks whether a url string ends in a "/" before we concatenate further params to the end of the url
435
436 =cut
437
438 sub _check_trailing_character {
439     my ( $url ) = @_;
440
441     my $trailing_char = substr $url, -1;
442     if ( $trailing_char ne '/' ) {
443         $url .= '/';
444     }
445
446     return $url;
447 }
448
449 =head3 _sushi_errors
450
451 Checks and handles possible errors in the SUSHI response
452 Additionally, adds background job report message(s) if that is the case
453
454 =cut
455
456 sub _sushi_errors {
457     my ( $self, $decoded_response ) = @_;
458
459     if ( $decoded_response->{Severity} ) {
460         $self->{job_callbacks}->{add_message_callback}->(
461             {
462                 type    => 'error',
463                 code    => $decoded_response->{Code},
464                 message => $decoded_response->{Severity} . ' - ' . $decoded_response->{Message},
465             }
466         ) if $self->{job_callbacks};
467         return 1;
468     }
469
470     if ( $decoded_response->{Report_Header}->{Exceptions} ) {
471         foreach my $exception ( @{ $decoded_response->{Report_Header}->{Exceptions} } ) {
472             $self->{job_callbacks}->{add_message_callback}->(
473                 {
474                     type    => 'error',
475                     code    => $exception->{Code},
476                     message => $exception->{Message} . ' - ' . $exception->{Data},
477                 }
478             ) if $self->{job_callbacks};
479         }
480         return 1;
481     }
482
483     if ( scalar @{ $decoded_response->{Report_Items} } == 0 ) {
484         $self->{job_callbacks}->{add_message_callback}->(
485             {
486                 type => 'error',
487                 code => 'no_items',
488             }
489         ) if $self->{job_callbacks};
490         return 1;
491     }
492
493     return 0;
494 }
495
496 =head3 _counter_file_size_too_large
497
498 Checks whether a counter file size exceeds the size allowed by the database or not
499 Additionally, adds a background job report message if that is the case
500
501 =cut
502
503 sub _counter_file_size_too_large {
504     my ( $self, $counter_file ) = @_;
505
506     my $max_allowed_packet = C4::Context->dbh->selectrow_array(q{SELECT @@max_allowed_packet});
507     if ( length($counter_file) > $max_allowed_packet ) {
508         $self->{job_callbacks}->{add_message_callback}->(
509             {
510                 type    => 'error',
511                 code    => 'payload_too_large',
512                 message => $max_allowed_packet / 1024 / 1024,
513             }
514         ) if $self->{job_callbacks};
515         return 1;
516     }
517     return 0;
518 }
519
520 =head3 _type
521
522 =cut
523
524 sub _type {
525     return 'ErmUsageDataProvider';
526 }
527
528 1;