Bug 37762: Send message back to requesting agency

This commit applies changes to V1.pm to be able to utilize the same XML methods.
We're also updating tests to reflect this new functionality.

Signed-off-by: Jason Robb <jrobb@sekls.org>
Signed-off-by: Lisette Scheer <lisette@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Pedro Amorim 2026-04-15 10:13:49 +00:00 committed by Lucas Gass
parent ae3b70b66b
commit f036c92ec0
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
3 changed files with 318 additions and 47 deletions

View File

@ -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

View File

@ -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<XML::LibXML::Node> 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<parse_xml> 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 );
}
}
}

View File

@ -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{<errorValue>AuthenticationFailed</errorValue>},
'error value is authentication failed'
);
my $supplyingAgencyMessageConfirmationXml = <<'XML';
<supplyingAgencyMessageConfirmation xmlns="https://example.com/ill/request">
<confirmationHeader>
<timestamp>2023-01-01T00:00:00Z</timestamp>
</confirmationHeader>
</supplyingAgencyMessageConfirmation>
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';
<request xmlns="https://example.com/ill/request">
<header>
<requestingAgencyAuthentication>
<accountId>no_callback_test</accountId>
<securityCode>test_secret_1</securityCode>
</requestingAgencyAuthentication>
<requestingAgencyRequestId>XYZ</requestingAgencyRequestId>
<timestamp>2023-03-15 14:30:00</timestamp>
<requestingAgencyId>
<agencyIdType>ISIL</agencyIdType>
<agencyIdValue>req_agency_value</agencyIdValue>
</requestingAgencyId>
</header>
<bibliographicInfo>
<title>Test request - no callback</title>
</bibliographicInfo>
<serviceInfo>
<serviceType>Copy</serviceType>
</serviceInfo>
</request>
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{<messageStatus>OK</messageStatus>},
'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';
<request xmlns="https://example.com/ill/request">
<header>
<requestingAgencyAuthentication>
<accountId>bad_endpoint_test</accountId>
<securityCode>test_secret_2</securityCode>
</requestingAgencyAuthentication>
<requestingAgencyRequestId>XYZ</requestingAgencyRequestId>
<timestamp>2023-03-15 14:30:00</timestamp>
<requestingAgencyId>
<agencyIdType>ISIL</agencyIdType>
<agencyIdValue>req_agency_value</agencyIdValue>
</requestingAgencyId>
</header>
<bibliographicInfo>
<title>Test request - bad endpoint</title>
</bibliographicInfo>
<serviceInfo>
<serviceType>Copy</serviceType>
</serviceInfo>
</request>
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{<messageStatus>OK</messageStatus>},
'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';
<request xmlns="https://example.com/ill/request">
<header>
<requestingAgencyAuthentication>
<accountId>invalid_url_test</accountId>
<securityCode>test_secret_3</securityCode>
</requestingAgencyAuthentication>
<requestingAgencyRequestId>XYZ</requestingAgencyRequestId>
<timestamp>2023-03-15 14:30:00</timestamp>
<requestingAgencyId>
<agencyIdType>ISIL</agencyIdType>
<agencyIdValue>req_agency_value</agencyIdValue>
</requestingAgencyId>
</header>
<bibliographicInfo>
<title>Test request - invalid url</title>
</bibliographicInfo>
<serviceInfo>
<serviceType>Copy</serviceType>
</serviceInfo>
</request>
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{<messageStatus>OK</messageStatus>},
'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;
};