Bug 13937: Fix issues found in QA
[koha.git] / Koha / Z3950Responder / Session.pm
1 #!/usr/bin/perl
2
3 package Koha::Z3950Responder::Session;
4
5 # Copyright ByWater Solutions 2016
6 #
7 # This file is part of Koha.
8 #
9 # Koha is free software; you can redistribute it and/or modify it under the
10 # terms of the GNU General Public License as published by the Free Software
11 # Foundation; either version 3 of the License, or (at your option) any later
12 # version.
13 #
14 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
15 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
16 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License along
19 # with Koha; if not, write to the Free Software Foundation, Inc.,
20 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
21
22 use Modern::Perl;
23
24 use C4::Circulation qw( GetTransfers );
25 use C4::Context;
26 use C4::Items qw( GetItem );
27 use C4::Reserves qw( GetReserveStatus );
28 use C4::Search qw();
29 use Koha::Logger;
30
31 use ZOOM;
32
33 use constant {
34     UNIMARC_OID => '1.2.840.10003.5.1',
35     USMARC_OID => '1.2.840.10003.5.10',
36     MARCXML_OID => '1.2.840.10003.5.109.10'
37 };
38
39 use constant {
40     ERR_TEMPORARY_ERROR => 2,
41     ERR_PRESENT_OUT_OF_RANGE => 13,
42     ERR_RECORD_TOO_LARGE => 16,
43     ERR_NO_SUCH_RESULTSET => 30,
44     ERR_SYNTAX_UNSUPPORTED => 230,
45     ERR_DB_DOES_NOT_EXIST => 235,
46 };
47
48 sub new {
49     my ( $class, $args ) = @_;
50
51     my $self = bless( {
52         %$args,
53         logger => Koha::Logger->get({ interface => 'z3950' }),
54         resultsets => {},
55     }, $class );
56
57     if ( $self->{server}->{debug} ) {
58         $self->{logger}->debug_to_screen();
59     }
60
61     $self->_log_info("connected");
62
63     return $self;
64 }
65
66 sub _log_debug {
67     my ( $self, $msg ) = @_;
68     $self->{logger}->debug("[$self->{peer}] $msg");
69 }
70
71 sub _log_info {
72     my ( $self, $msg ) = @_;
73     $self->{logger}->info("[$self->{peer}] $msg");
74 }
75
76 sub _log_error {
77     my ( $self, $msg ) = @_;
78     $self->{logger}->error("[$self->{peer}] $msg");
79 }
80
81 sub _set_error {
82     my ( $self, $args, $code, $msg ) = @_;
83
84     ( $args->{ERR_CODE}, $args->{ERR_STR} ) = ( $code, $msg );
85
86     $self->_log_error("    returning error $code: $msg");
87 }
88
89 sub _set_error_from_zoom {
90     my ( $self, $args, $exception ) = @_;
91
92     $self->_set_error( $args, ERR_TEMPORARY_ERROR, 'Cannot connect to upstream server' );
93     $self->_log_error(
94         "Zebra upstream error: " .
95         $exception->message() . " (" .
96         $exception->code() . ") " .
97         ( $exception->addinfo() // '' ) . " " .
98         $exception->diagset()
99     );
100 }
101
102 # This code originally went through C4::Search::getRecords, but had to use so many escape hatches
103 # that it was easier to directly connect to Zebra.
104 sub _start_search {
105     my ( $self, $args, $in_retry ) = @_;
106
107     my $database = $args->{DATABASES}->[0];
108     my ( $connection, $results );
109
110     eval {
111         $connection = C4::Context->Zconn(
112             # We're depending on the caller to have done some validation.
113             $database eq 'biblios' ? 'biblioserver' : 'authorityserver',
114             0 # No, no async, doesn't really help much for single-server searching
115         );
116
117         $results = $connection->search_pqf( $args->{QUERY} );
118
119         $self->_log_debug('    retry successful') if ($in_retry);
120     };
121     if ($@) {
122         die $@ if ( ref($@) ne 'ZOOM::Exception' );
123
124         if ( $@->diagset() eq 'ZOOM' && $@->code() == 10004 && !$in_retry ) {
125             $self->_log_debug('    upstream server lost connection, retrying');
126             return $self->_start_search( $args, 1 );
127         }
128
129         $self->_set_error_from_zoom( $args, $@ );
130         $connection = undef;
131     }
132
133     return ( $connection, $results, $results ? $results->size() : -1 );
134 }
135
136 sub _check_fetch {
137     my ( $self, $resultset, $args, $offset, $num_records ) = @_;
138
139     if ( !defined( $resultset ) ) {
140         $self->_set_error( $args, ERR_NO_SUCH_RESULTSET, 'No such resultset' );
141         return 0;
142     }
143
144     if ( $offset < 0 || $offset + $num_records > $resultset->{hits} )  {
145         $self->_set_error( $args, ERR_PRESENT_OUT_OF_RANGE, 'Present request out of range' );
146         return 0;
147     }
148
149     return 1;
150 }
151
152 sub _fetch_record {
153     my ( $self, $resultset, $args, $index, $num_to_prefetch ) = @_;
154
155     my $record;
156
157     eval {
158         if ( !$resultset->{results}->record_immediate( $index ) ) {
159             my $start = $num_to_prefetch ? int( $index / $num_to_prefetch ) * $num_to_prefetch : $index;
160
161             if ( $start + $num_to_prefetch >= $resultset->{results}->size() ) {
162                 $num_to_prefetch = $resultset->{results}->size() - $start;
163             }
164
165             $self->_log_debug("    fetch uncached, fetching $num_to_prefetch records starting at $start");
166
167             $resultset->{results}->records( $start, $num_to_prefetch, 0 );
168         }
169
170         $record = $resultset->{results}->record_immediate( $index )->raw();
171     };
172     if ($@) {
173         die $@ if ( ref($@) ne 'ZOOM::Exception' );
174         $self->_set_error_from_zoom( $args, $@ );
175         return;
176     } else {
177         return $record;
178     }
179 }
180
181 sub search_handler {
182     # Called when search is first sent.
183     my ( $self, $args ) = @_;
184
185     my $database = $args->{DATABASES}->[0];
186
187     if ( $database !~ /^(biblios|authorities)$/ ) {
188         $self->_set_error( $args, ERR_DB_DOES_NOT_EXIST, 'No such database' );
189         return;
190     }
191
192     my $query = $args->{QUERY};
193     $self->_log_info("received search for '$query', (RS $args->{SETNAME})");
194
195     my ( $connection, $results, $num_hits ) = $self->_start_search( $args );
196     return unless $connection;
197
198     $args->{HITS} = $num_hits;
199     my $resultset = $self->{resultsets}->{ $args->{SETNAME} } = {
200         database => $database,
201         connection => $connection,
202         results => $results,
203         query => $args->{QUERY},
204         hits => $args->{HITS},
205     };
206 }
207
208 sub present_handler {
209     # Called when a set of records is requested.
210     my ( $self, $args ) = @_;
211
212     $self->_log_debug("received present for $args->{SETNAME}, $args->{START}+$args->{NUMBER}");
213
214     my $resultset = $self->{resultsets}->{ $args->{SETNAME} };
215     # The offset comes across 1-indexed.
216     my $offset = $args->{START} - 1;
217
218     return unless $self->_check_fetch( $resultset, $args, $offset, $args->{NUMBER} );
219
220 }
221
222 sub fetch_handler {
223     # Called when a given record is requested.
224     my ( $self, $args ) = @_;
225     my $session = $args->{HANDLE};
226     my $server = $self->{server};
227
228     $self->_log_debug("received fetch for $args->{SETNAME}, record $args->{OFFSET}");
229     my $form_oid = $args->{REQ_FORM} // '';
230     my $composition = $args->{COMP} // '';
231     $self->_log_debug("    form OID $form_oid, composition $composition");
232
233     my $resultset = $session->{resultsets}->{ $args->{SETNAME} };
234     # The offset comes across 1-indexed.
235     my $offset = $args->{OFFSET} - 1;
236
237     return unless $self->_check_fetch( $resultset, $args, $offset, 1 );
238
239     $args->{LAST} = 1 if ( $offset == $resultset->{hits} - 1 );
240
241     my $record = $self->_fetch_record( $resultset, $args, $offset, $server->{num_to_prefetch} );
242     return unless $record;
243
244     $record = C4::Search::new_record_from_zebra(
245         $resultset->{database} eq 'biblios' ? 'biblioserver' : 'authorityserver',
246         $record
247     );
248
249     if ( $server->{add_item_status_subfield} ) {
250         my $tag = $server->{item_tag};
251
252         foreach my $field ( $record->field($tag) ) {
253             $self->add_item_status( $field );
254         }
255     }
256
257     if ( $form_oid eq MARCXML_OID && $composition eq 'marcxml' ) {
258         $args->{RECORD} = $record->as_xml_record();
259     } elsif ( ( $form_oid eq USMARC_OID || $form_oid eq UNIMARC_OID ) && ( !$composition || $composition eq 'F' ) ) {
260         $args->{RECORD} = $record->as_usmarc();
261     } else {
262         $self->_set_error( $args, ERR_SYNTAX_UNSUPPORTED, "Unsupported syntax/composition $form_oid/$composition" );
263         return;
264     }
265 }
266
267 sub add_item_status {
268     my ( $self, $field ) = @_;
269
270     my $server = $self->{server};
271
272     my $itemnumber_subfield = $server->{itemnumber_subfield};
273     my $add_subfield = $server->{add_item_status_subfield};
274     my $status_strings = $server->{status_strings};
275
276     my $itemnumber = $field->subfield($itemnumber_subfield);
277     next unless $itemnumber;
278
279     my $item = GetItem( $itemnumber );
280     return unless $item;
281
282     my @statuses;
283
284     if ( $item->{onloan} ) {
285         push @statuses, $status_strings->{CHECKED_OUT};
286     }
287
288     if ( $item->{itemlost} ) {
289         push @statuses, $status_strings->{LOST};
290     }
291
292     if ( $item->{notforloan} ) {
293         push @statuses, $status_strings->{NOT_FOR_LOAN};
294     }
295
296     if ( $item->{damaged} ) {
297         push @statuses, $status_strings->{DAMAGED};
298     }
299
300     if ( $item->{withdrawn} ) {
301         push @statuses, $status_strings->{WITHDRAWN};
302     }
303
304     if ( scalar( GetTransfers( $itemnumber ) ) ) {
305         push @statuses, $status_strings->{IN_TRANSIT};
306     }
307
308     if ( GetReserveStatus( $itemnumber ) ne '' ) {
309         push @statuses, $status_strings->{ON_HOLD};
310     }
311
312     $field->delete_subfield( code => $itemnumber_subfield );
313
314     if ( $server->{add_status_multi_subfield} ) {
315         $field->add_subfields( map { ( $add_subfield, $_ ) } ( @statuses ? @statuses : $status_strings->{AVAILABLE} ) );
316     } else {
317         $field->add_subfields( $add_subfield, @statuses ? join( ', ', @statuses ) : $status_strings->{AVAILABLE} );
318     }
319 }
320
321 sub close_handler {
322     my ( $self, $args ) = @_;
323
324     foreach my $resultset ( values %{ $self->{resultsets} } ) {
325         $resultset->{results}->destroy();
326     }
327 }
328
329 1;