diff --git a/Koha/ILL/ISO18626/Request.pm b/Koha/ILL/ISO18626/Request.pm index ac8cf184aa..e11d72c8a8 100644 --- a/Koha/ILL/ISO18626/Request.pm +++ b/Koha/ILL/ISO18626/Request.pm @@ -24,6 +24,7 @@ use Koha::REST::V1; use XML::LibXML; use JSON qw( encode_json decode_json ); use File::Basename qw( dirname ); +use LWP::UserAgent; use Koha::Biblios; use Koha::ILL::ISO18626::RequestingAgency; @@ -103,43 +104,108 @@ sub messages { =head3 send_message -Send a message to the requesting agency - # $message must be a valid XML string. +Send an ISO18626 message payload to the requesting agency's callback endpoint. +Expects C<$message> as a Perl hashref, which is converted to XML before sending. =cut sub send_message { my ( $self, $type, $message ) = @_; - # my $requesting_agency = $self->_result->requesting_agency; - - # return unless $requesting_agency->callback_endpoint; - - # my $ua = LWP::UserAgent->new( agent => 'Koha ILL' ); - # $ua->agent( 'Koha/' . Koha::version() ); - - # my $response = $ua->post( - # $requesting_agency->callback_endpoint, - # Content_Type => 'application/xml', - # Content => $message, - # ); - - # if ( $response->is_success ) { - #FIXME: Better way to convert this to json other than invoking Koha::REST::V1 ? - # my $parser = XML::LibXML->new(); - # my $doc = $parser->parse_string($message); - # my $root = $doc->documentElement(); - # my $json = Koha::REST::V1::parse_xml($root); - - # $json = JSON::encode_json($json); $self->add_message( { type => $type, message => $message } ); - return 1; - # } - # else { - # $self->_result->add_message( 'Request', "Failed to send message: " . $response->status_line ); - # return; - # } + my $requesting_agency = $self->requesting_agency; + if ( !$requesting_agency ) { + warn sprintf( + "ISO18626: Cannot send message. No requesting agency linked to request %s", + $self->iso18626_request_id + ); + return 0; + } + + if ( !$requesting_agency->callback_endpoint ) { + warn sprintf( + "ISO18626: Cannot send message. Requesting agency %s has no callback_endpoint defined.", + $requesting_agency->id + ); + return 0; + } + + my $xml_payload; + eval { $xml_payload = Koha::REST::V1::to_xml($message); }; + if ($@) { + warn sprintf( "ISO18626: Failed to convert message to XML for request %s: %s", $self->iso18626_request_id, $@ ); + return 0; + } + + my $ua = LWP::UserAgent->new( + agent => 'Koha ' . $Koha::VERSION, + timeout => 10, + ); + + my $response = $ua->post( + $requesting_agency->callback_endpoint, + 'Content-Type' => 'application/xml', + Content => $xml_payload, + ); + + if ( $response->is_success ) { + my $content = $response->decoded_content; + + if ($content) { + my $parser = XML::LibXML->new(); + my $doc; + eval { $doc = $parser->parse_string($content); }; + + if ( $@ || !$doc ) { + warn sprintf( + "ISO18626: Could not parse XML confirmation from %s for request %s. Error: %s", + $requesting_agency->callback_endpoint, + $self->iso18626_request_id, + $@ + ); + } else { + my $root = $doc->documentElement(); + my $parsed_json; + eval { + my $spec_file = dirname(__FILE__) . "/../../../api/v1/swagger/swagger_bundle.json"; + if ( !-f $spec_file ) { + $spec_file = dirname(__FILE__) . "/../../../api/v1/swagger/swagger.yaml"; + } + $parsed_json = Koha::REST::V1::parse_xml( $root, $spec_file ); + }; + + if ($@) { + warn sprintf( + "ISO18626: Failed to convert XML to JSON for confirmation on request %s. Error: %s", + $self->iso18626_request_id, + $@ + ); + } else { + my $confirmation_type = $type . 'Confirmation'; + $self->add_message( { type => $confirmation_type, message => $parsed_json } ); + } + } + } else { + warn sprintf( + "ISO18626: HTTP request for %s on request %s was successful but returned an empty body.", + $type, + $self->iso18626_request_id + ); + } + + return 1; + } + + # If we reach this point, the HTTP request failed + warn sprintf( + "ISO18626: HTTP Request Failed. Could not send %s to %s. Status: %s. Body: %s", + $type, + $requesting_agency->callback_endpoint, + $response->status_line, + $response->decoded_content || 'No content provided' + ); + return 0; } =head3 requesting_agency diff --git a/Koha/REST/V1.pm b/Koha/REST/V1.pm index 22038c6cb4..b1137e84b4 100644 --- a/Koha/REST/V1.pm +++ b/Koha/REST/V1.pm @@ -76,10 +76,14 @@ sub startup { try { my $xml = $c->req->body; - my $parser = XML::LibXML->new(); - my $doc = $parser->parse_string($xml); - my $root = $doc->documentElement(); - my $json = $self->parse_xml($root); + my $parser = XML::LibXML->new(); + my $doc = $parser->parse_string($xml); + my $root = $doc->documentElement(); + my $spec_file = $self->home->rel_file("api/v1/swagger/swagger_bundle.json"); + if ( !-f $spec_file ) { + $spec_file = $self->home->rel_file("api/v1/swagger/swagger.yaml"); + } + my $json = parse_xml( $root, $spec_file ); $c->req->body( JSON::encode_json($json) ); @@ -330,38 +334,33 @@ sub validate_json_payload { =head3 parse_xml - my $hashref = $self->parse_xml( $xml_node ); + my $hashref = parse_xml( $xml_node, $spec_file ); Converts an C into a Perl hashref, using the OpenAPI schema definitions to correctly identify and cast array properties. =cut sub parse_xml { - my ( $self, $node ) = @_; + my ( $node, $spec_file ) = @_; my $hash = {}; - my $spec_file = $self->home->rel_file("api/v1/swagger/swagger_bundle.json"); - if ( !-f $spec_file ) { - $spec_file = $self->home->rel_file("api/v1/swagger/swagger.yaml"); - } - my $schema = JSON::Validator::Schema::OpenAPIv2->new($spec_file); - $self->_parse_node( $node, $hash, 0, $schema->data->{definitions} ); + _parse_node( $node, $hash, 0, $schema->data->{definitions} ); return $hash; } =head3 _parse_node - $self->_parse_node( $node, $hash, $is_array, $schema_definitions ); + _parse_node( $node, $hash, $is_array, $schema_definitions ); Internal recursive helper for C to populate a Perl hashref from XML elements. =cut sub _parse_node { - my ( $self, $node, $hash, $is_array, $schema_definitions ) = @_; + my ( $node, $hash, $is_array, $schema_definitions ) = @_; my $name = $node->localName(); my $properties = $schema_definitions->{$name}->{properties}; @@ -377,9 +376,9 @@ sub _parse_node { foreach my $child ( $node->childNodes() ) { if ( $child->nodeType() == 1 ) { # 1 is the node type for elements if ( grep { $_ eq $child->localName() } @array_properties ) { - $self->_parse_node( $child, $child_hash, 1, $schema_definitions ); + _parse_node( $child, $child_hash, 1, $schema_definitions ); } else { - $self->_parse_node( $child, $child_hash, 0, $schema_definitions ); + _parse_node( $child, $child_hash, 0, $schema_definitions ); } } } diff --git a/t/db_dependent/api/v1/iso18626/request.t b/t/db_dependent/api/v1/iso18626/request.t index 597f2db7b7..08152a3118 100755 --- a/t/db_dependent/api/v1/iso18626/request.t +++ b/t/db_dependent/api/v1/iso18626/request.t @@ -18,11 +18,12 @@ use Modern::Perl; use Test::NoWarnings; -use Test::More tests => 2; +use Test::More tests => 3; use Test::MockModule; use Test::MockObject; use Test::Mojo; +use Test::Warn; use MIME::Base64 qw(encode_base64); use JSON qw(encode_json); @@ -125,10 +126,27 @@ XML qr{AuthenticationFailed}, 'error value is authentication failed' ); + my $supplyingAgencyMessageConfirmationXml = <<'XML'; + + + 2023-01-01T00:00:00Z + + +XML + + my $mock_ua_response = Test::MockObject->new(); + $mock_ua_response->mock( 'is_success', sub { return 1; } ); + $mock_ua_response->mock( 'decoded_content', sub { return $supplyingAgencyMessageConfirmationXml; } ); + $mock_ua_response->mock( 'status_line', sub { return '200 OK'; } ); + + my $mock_ua = Test::MockModule->new('LWP::UserAgent'); + $mock_ua->mock( 'post', sub { return $mock_ua_response; } ); + my $requesting_agency = $builder->build_object( { class => 'Koha::ILL::ISO18626::RequestingAgencies', - value => { account_id => 'asd', securityCode => 'asds' } + value => + { account_id => 'asd', securityCode => 'asds', callback_endpoint => 'http://localhost/ill/callback' } } ); @@ -249,3 +267,191 @@ XML $schema->storage->txn_rollback; }; +subtest 'send_message() tests' => sub { + + plan tests => 18; + + $schema->storage->txn_begin; + + my $librarian = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 2**22 } # 22 => ill + } + ); + my $password = 'thePassword123'; + $librarian->set_password( { password => $password, skip_validation => 1 } ); + my $userid = $librarian->userid; + + # Test: no callback_endpoint defined on requesting agency + $builder->build_object( + { + class => 'Koha::ILL::ISO18626::RequestingAgencies', + value => { account_id => 'no_callback_test', securityCode => 'test_secret_1', callback_endpoint => '' } + } + ); + + my $request_no_callback_xml = <<'XML'; + +
+ + no_callback_test + test_secret_1 + + XYZ + 2023-03-15 14:30:00 + + ISIL + req_agency_value + +
+ + Test request - no callback + + + Copy + +
+XML + + $t->post_ok( + "//$userid:$password@/api/v1/public/ill/iso18626", + { 'Content-Type' => 'application/xml' }, + $request_no_callback_xml + )->status_is(201)->content_like( + qr{OK}, + 'request created successfully' + ); + + my $request_no_callback = Koha::ILL::ISO18626::Requests->search( + {}, + { order_by => { -desc => 'iso18626_request_id' }, rows => 1 } + )->single; + + warning_like { + $t->patch_ok( "//$userid:$password@/api/v1/ill/iso18626_requests/" + . $request_no_callback->iso18626_request_id => json => { status => 'Loaned' } )->status_is(200); + } + qr/ISO18626: Cannot send message/, 'warns when requesting agency has no callback_endpoint'; + + # Test: callback_endpoint defined but HTTP request fails + $builder->build_object( + { + class => 'Koha::ILL::ISO18626::RequestingAgencies', + value => { + account_id => 'bad_endpoint_test', + securityCode => 'test_secret_2', + callback_endpoint => 'http://localhost/ill/callback' + } + } + ); + + my $mock_fail_response = Test::MockObject->new(); + $mock_fail_response->mock( 'is_success', sub { return 0; } ); + $mock_fail_response->mock( 'status_line', sub { return '500 Internal Server Error'; } ); + $mock_fail_response->mock( 'decoded_content', sub { return 'Internal Server Error'; } ); + + my $mock_ua_fail = Test::MockModule->new('LWP::UserAgent'); + $mock_ua_fail->mock( 'post', sub { return $mock_fail_response; } ); + + my $request_bad_endpoint_xml = <<'XML'; + +
+ + bad_endpoint_test + test_secret_2 + + XYZ + 2023-03-15 14:30:00 + + ISIL + req_agency_value + +
+ + Test request - bad endpoint + + + Copy + +
+XML + + $t->post_ok( + "//$userid:$password@/api/v1/public/ill/iso18626", + { 'Content-Type' => 'application/xml' }, + $request_bad_endpoint_xml + )->status_is(201)->content_like( + qr{OK}, + 'request created successfully' + ); + + my $request_bad_endpoint = Koha::ILL::ISO18626::Requests->search( + {}, + { order_by => { -desc => 'iso18626_request_id' }, rows => 1 } + )->single; + + warning_like { + $t->patch_ok( "//$userid:$password@/api/v1/ill/iso18626_requests/" + . $request_bad_endpoint->iso18626_request_id => json => { status => 'Loaned' } )->status_is(200); + } + qr/ISO18626: HTTP Request Failed/, 'warns when HTTP request to callback_endpoint fails'; + + # Test: callback_endpoint is set but is not a valid URL + $builder->build_object( + { + class => 'Koha::ILL::ISO18626::RequestingAgencies', + value => { + account_id => 'invalid_url_test', + securityCode => 'test_secret_3', + callback_endpoint => 'not-a-valid-url' + } + } + ); + + my $request_invalid_url_xml = <<'XML'; + +
+ + invalid_url_test + test_secret_3 + + XYZ + 2023-03-15 14:30:00 + + ISIL + req_agency_value + +
+ + Test request - invalid url + + + Copy + +
+XML + + $t->post_ok( + "//$userid:$password@/api/v1/public/ill/iso18626", + { 'Content-Type' => 'application/xml' }, + $request_invalid_url_xml + )->status_is(201)->content_like( + qr{OK}, + 'request created successfully' + ); + + my $request_invalid_url = Koha::ILL::ISO18626::Requests->search( + {}, + { order_by => { -desc => 'iso18626_request_id' }, rows => 1 } + )->single; + + warning_like { + $t->patch_ok( "//$userid:$password@/api/v1/ill/iso18626_requests/" + . $request_invalid_url->iso18626_request_id => json => { status => 'Loaned' } )->status_is(200); + } + qr/ISO18626: HTTP Request Failed/, 'warns when callback_endpoint is not a valid URL'; + + $schema->storage->txn_rollback; +}; +