Koha-clms/Koha/REST/V1.pm

418 lines
13 KiB
Perl

package Koha::REST::V1;
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <https://www.gnu.org/licenses>.
use Modern::Perl;
use Mojo::Base 'Mojolicious';
use C4::Context;
use Koha::Logger;
use Koha::Auth::Identity::Providers;
use Mojolicious::Plugin::OAuth2;
use JSON::Validator::Schema::OpenAPIv2;
use Try::Tiny qw( catch try );
use JSON qw( encode_json decode_json );
use XML::LibXML;
=head1 NAME
Koha::REST::V1 - Main v.1 REST api class
=head1 API
=head2 Class Methods
=head3 startup
Overloaded Mojolicious->startup method. It is called at application startup.
=cut
sub startup {
my $self = shift;
my $logger = Koha::Logger->get( { interface => 'api' } );
$self->log($logger);
$self->hook(
before_dispatch => sub {
my $c = shift;
# Remove /api/v1/app.pl/ from the path
$c->req->url->base->path('/');
# Handle CORS
$c->res->headers->header(
'Access-Control-Allow-Origin' => C4::Context->preference('AccessControlAllowOrigin') )
if C4::Context->preference('AccessControlAllowOrigin');
}
);
$self->hook(
around_action => sub {
my ( $next, $c, $action, $last ) = @_;
# Flush memory caches before every request
Koha::Caches->flush_L1_caches();
Koha::Cache::Memory::Lite->flush();
# Convert XML payload to JSON and validate against schema
if ( $c->req->headers->content_type && $c->req->headers->content_type =~ /application\/xml/ ) {
try {
my $xml = $c->req->body;
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) );
unless ( $self->validate_json_payload( $c, $json ) ) {
return $c->render( status => 400, json => { error => 'Invalid JSON payload' } );
}
} catch {
# If the request body is already JSON, don't try to parse it as XML
my $json = $c->req->body;
unless ( $self->validate_json_payload( $c, $json ) ) {
return $c->render( status => 400, json => { error => 'Invalid JSON payload' } );
}
}
}
return $next->();
}
);
$self->hook(
after_dispatch => sub {
my ($c) = @_;
# Check if the response header content_type is application/xml
if ( $c->res->headers->content_type && $c->res->headers->content_type eq 'application/xml' ) {
# Convert JSON to XML
my $xml = to_xml( decode_json( $c->res->body ) );
$c->res->body($xml);
}
return;
}
);
# Force charset=utf8 in Content-Type header for JSON responses
$self->types->type( json => 'application/json; charset=utf8' );
$self->types->type( xml => 'application/xml' );
# MARC-related types
$self->types->type( marcxml => 'application/marcxml+xml' );
$self->types->type( mij => 'application/marc-in-json' );
$self->types->type( marc => 'application/marc' );
# YAML type
$self->types->type( yaml => 'application/yaml' );
my $secret_passphrase = C4::Context->config('api_secret_passphrase');
if ($secret_passphrase) {
$self->secrets( [$secret_passphrase] );
}
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");
}
push @{ $self->routes->namespaces }, 'Koha::Plugin';
# Try to load and merge all schemas first and validate the result just once.
try {
my $schema = JSON::Validator::Schema::OpenAPIv2->new;
$schema->resolve($spec_file);
my $spec = $schema->bundle->data;
$self->plugin(
'Koha::REST::Plugin::PluginRoutes' => {
spec => $spec,
validate => 0,
}
) unless C4::Context->needs_install; # load only if Koha is installed
my $route = $self->config('route') // '/api/v1';
$self->plugin(
OpenAPI => {
spec => $spec,
route => $self->routes->under($route)->to('Auth#under'),
}
);
$self->plugin('RenderFile');
} catch {
# Validation of the complete spec failed. Resort to validation one-by-one
# to catch bad ones.
# JSON::Validator uses confess, so trim call stack from the message.
$self->app->log->error( "Warning: Could not load REST API spec bundle: " . $_ );
try {
my $schema = JSON::Validator::Schema::OpenAPIv2->new;
$schema->resolve($spec_file);
my $spec = $schema->bundle->data;
$self->plugin(
'Koha::REST::Plugin::PluginRoutes' => {
spec => $spec,
validate => 1
}
) unless C4::Context->needs_install; # load only if Koha is installed
$self->plugin(
OpenAPI => {
spec => $spec,
route => $self->routes->under('/api/v1')->to('Auth#under'),
}
);
} catch {
# JSON::Validator uses confess, so trim call stack from the message.
$self->app->log->error( "Warning: Could not load REST API spec bundle: " . $_ );
};
};
my $oauth_configuration = {};
try {
my $search_options = { protocol => [ "OIDC", "OAuth" ] };
my $providers = Koha::Auth::Identity::Providers->search($search_options);
while ( my $provider = $providers->next ) {
$oauth_configuration->{ $provider->code } = decode_json( $provider->config );
}
} catch {
$self->app->log->warn( "Warning: Failed to fetch oauth configuration: " . $_ );
};
$self->plugin('Koha::App::Plugin::Language');
$self->plugin('Koha::REST::Plugin::Pagination');
$self->plugin('Koha::REST::Plugin::Query');
$self->plugin('Koha::REST::Plugin::Objects');
$self->plugin('Koha::REST::Plugin::Exceptions');
$self->plugin('Koha::REST::Plugin::Responses');
$self->plugin('Koha::REST::Plugin::Auth::IdP');
$self->plugin('Koha::REST::Plugin::Auth::PublicRoutes');
$self->plugin( 'Mojolicious::Plugin::OAuth2' => $oauth_configuration );
}
=head3 to_xml
my $xml_string = to_xml( $json_hashref );
Converts a JSON-like hashref into an XML string.
=cut
sub to_xml {
my $json = shift;
my $xml = XML::LibXML::Document->new( '1.0', 'UTF-8' );
my $root_key = ( keys %$json )[0];
my $root = $xml->createElement($root_key);
$xml->setDocumentElement($root); # Set the root element
_json_to_xml( $json->{$root_key}, $root, $xml );
return $xml->toString;
}
=head3 _json_to_xml
_json_to_xml( $data, $parent_node, $xml_doc );
Internal recursive helper for C<to_xml> to build XML elements from nested Perl structures.
=cut
sub _json_to_xml {
my ( $json, $parent, $doc ) = @_;
if ( ref $json eq 'HASH' ) {
foreach my $key ( keys %$json ) {
my $value = $json->{$key};
if ( ref $value eq 'HASH' ) {
my $elem = $doc->createElement($key);
$parent->appendChild($elem);
_json_to_xml( $value, $elem, $doc );
} elsif ( ref $value eq 'ARRAY' ) {
foreach my $item (@$value) {
my $elem = $doc->createElement($key);
$parent->appendChild($elem);
_json_to_xml( $item, $elem, $doc );
}
} else {
my $elem = $doc->createElement($key);
$elem->appendText($value);
$parent->appendChild($elem);
}
}
} elsif ( ref $json eq 'ARRAY' ) {
foreach my $item (@$json) {
my $elem = $doc->createElement('item');
$parent->appendChild($elem);
_json_to_xml( $item, $elem, $doc );
}
} else {
my $elem = $doc->createElement('value');
$elem->appendText($json);
$parent->appendChild($elem);
}
}
=head3 validate_json_payload
my $is_valid = $self->validate_json_payload( $c, $payload );
Validates a JSON payload against the REST API OpenAPI/Swagger schema.
Returns C<1> if valid, C<0> if there are errors.
=cut
sub validate_json_payload {
my ( $self, $c, $payload ) = @_;
# Load the OpenAPI schema
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;
$schema->resolve($spec_file);
my $spec = $schema->bundle->data;
# Validate the JSON payload against the schema
my $validator = JSON::Validator->new;
$validator->schema($spec);
my $errors = $validator->validate($payload);
# Print the validation errors
if ($errors) {
#TODO: Do something here?
}
# Return 1 if the payload is valid, 0 otherwise
return $errors ? 0 : 1;
}
=head3 parse_xml
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 ( $node, $spec_file ) = @_;
my $hash = {};
my $schema = JSON::Validator::Schema::OpenAPIv2->new($spec_file);
_parse_node( $node, $hash, 0, $schema->data->{definitions} );
return $hash;
}
=head3 _parse_node
_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 ( $node, $hash, $is_array, $schema_definitions ) = @_;
my $name = $node->localName();
my $properties = $schema_definitions->{$name}->{properties};
my @array_properties;
foreach my $key ( keys %$properties ) {
my $property = $properties->{$key};
push @array_properties, $key if $property->{type} && $property->{type} eq 'array';
}
if ( $node->hasChildNodes() ) {
my $child_hash = {};
foreach my $child ( $node->childNodes() ) {
if ( $child->nodeType() == 1 ) { # 1 is the node type for elements
if ( grep { $_ eq $child->localName() } @array_properties ) {
_parse_node( $child, $child_hash, 1, $schema_definitions );
} else {
_parse_node( $child, $child_hash, 0, $schema_definitions );
}
}
}
if (%$child_hash) {
if ( $is_array || exists $hash->{$name} ) {
if ( ref( $hash->{$name} ) eq 'ARRAY' ) {
push @{ $hash->{$name} }, $child_hash;
} else {
$hash->{$name} = [$child_hash];
}
} else {
$hash->{$name} = $child_hash;
}
} else {
my $text = $node->textContent();
$text =~ s/^\s+//; # remove leading whitespace
$text =~ s/\s+$//; # remove trailing whitespace
if ( $is_array || exists $hash->{$name} ) {
if ( ref( $hash->{$name} ) eq 'ARRAY' ) {
push @{ $hash->{$name} }, $text;
} else {
$hash->{$name} = [$text];
}
} else {
$hash->{$name} = $text;
}
}
} else {
my $text = $node->textContent();
$text =~ s/^\s+//; # remove leading whitespace
$text =~ s/\s+$//; # remove trailing whitespace
$hash->{$name} = $text;
}
}
1;