From caa52ddf911e4d73ccf195fef626d06ab6b7d56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Cohen=20Arazi?= Date: Mon, 23 Mar 2026 17:16:18 +0100 Subject: [PATCH] Bug 22639: Add GET /config/search_mappings endpoint This patch adds a new API endpoint to retrieve the search field mappings configuration (search fields and their MARC mappings, grouped by index). The endpoint supports content negotiation: - application/json (default) - application/yaml (for export/import compatibility) An optional 'type' query parameter allows filtering mappings by MARC type (marc21 or unimarc). Implementation notes: - New controller: Koha::REST::V1::Config::SearchMappings - Reuses Koha::SearchEngine::Elasticsearch::raw_elasticsearch_mappings - Registers 'application/yaml' MIME type in Koha::REST::V1 - Permission: manage_search_engine_config To test: 1. Apply this patch 2. Rebuild the API bundle: $ yarn api:bundle 3. Restart Plack 4. Run: $ prove t/db_dependent/api/v1/config_search_mappings.t => SUCCESS: Tests pass 5. Test manually: $ curl -u user:pass /api/v1/config/search_mappings => SUCCESS: Returns JSON with search field mappings $ curl -u user:pass -H 'Accept: application/yaml' \ /api/v1/config/search_mappings => SUCCESS: Returns YAML output $ curl -u user:pass /api/v1/config/search_mappings?type=marc21 => SUCCESS: Only marc21 mappings are returned 6. Sign off :-D Signed-off-by: Laurence Signed-off-by: Thomas Klausner Signed-off-by: Lucas Gass --- Koha/REST/V1.pm | 3 + Koha/REST/V1/Config/SearchMappings.pm | 61 ++++ .../swagger/paths/config_search_mappings.yaml | 45 +++ api/v1/swagger/swagger.yaml | 5 + .../api/v1/config_search_mappings.t | 299 ++++++++++++++++++ 5 files changed, 413 insertions(+) create mode 100644 Koha/REST/V1/Config/SearchMappings.pm create mode 100644 api/v1/swagger/paths/config_search_mappings.yaml create mode 100755 t/db_dependent/api/v1/config_search_mappings.t diff --git a/Koha/REST/V1.pm b/Koha/REST/V1.pm index 158ffbf122..5a3be7a578 100644 --- a/Koha/REST/V1.pm +++ b/Koha/REST/V1.pm @@ -82,6 +82,9 @@ sub startup { $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] ); diff --git a/Koha/REST/V1/Config/SearchMappings.pm b/Koha/REST/V1/Config/SearchMappings.pm new file mode 100644 index 0000000000..daa3464d6e --- /dev/null +++ b/Koha/REST/V1/Config/SearchMappings.pm @@ -0,0 +1,61 @@ +package Koha::REST::V1::Config::SearchMappings; + +# 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 . + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; + +use Koha::SearchEngine::Elasticsearch; + +use Try::Tiny qw( catch try ); +use Encode; +use YAML::XS; + +=head1 API + +=head2 Methods + +=head3 get + +Controller method to retrieve search field mappings + +=cut + +sub get { + my $c = shift->openapi->valid_input or return; + + return try { + my $type = $c->param('type'); + my $mappings = Koha::SearchEngine::Elasticsearch::raw_elasticsearch_mappings($type); + + $c->respond_to( + yaml => { + status => 200, + format => 'yaml', + text => Encode::decode_utf8( YAML::XS::Dump($mappings) ), + }, + any => { + status => 200, + openapi => $mappings, + } + ); + } catch { + $c->unhandled_exception($_); + }; +} + +1; diff --git a/api/v1/swagger/paths/config_search_mappings.yaml b/api/v1/swagger/paths/config_search_mappings.yaml new file mode 100644 index 0000000000..35b7ddb3c0 --- /dev/null +++ b/api/v1/swagger/paths/config_search_mappings.yaml @@ -0,0 +1,45 @@ +--- +/config/search_mappings: + get: + x-mojo-to: Config::SearchMappings#get + operationId: getSearchMappings + tags: + - search_mappings + summary: Get search field mappings + produces: + - application/json + - application/yaml + parameters: + - name: type + in: query + description: MARC type filter + required: false + type: string + enum: + - marc21 + - unimarc + responses: + "200": + description: Search field mappings + "400": + description: Bad request + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + parameters: manage_search_engine_config diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index 50517fe537..d7e5f35f29 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -341,6 +341,8 @@ paths: $ref: ./paths/config_file_transports.yaml#/~1config~1file_transports "/config/file_transports/{file_transport_id}": $ref: "./paths/config_file_transports.yaml#/~1config~1file_transports~1{file_transport_id}" + /config/search_mappings: + $ref: ./paths/config_search_mappings.yaml#/~1config~1search_mappings "/deleted/biblios": $ref: "./paths/deleted_biblios.yaml#/~1deleted~1biblios" "/deleted/biblios/{biblio_id}": @@ -1409,6 +1411,9 @@ tags: - description: "Manage search filters" name: search_filters x-displayName: Search filters + - description: "Manage search field mappings" + name: search_mappings + x-displayName: Search mappings - description: "Manage SIP2 accounts" name: sip2_accounts x-displayName: SIP2 Accounts diff --git a/t/db_dependent/api/v1/config_search_mappings.t b/t/db_dependent/api/v1/config_search_mappings.t new file mode 100755 index 0000000000..20efb218d2 --- /dev/null +++ b/t/db_dependent/api/v1/config_search_mappings.t @@ -0,0 +1,299 @@ +#!/usr/bin/env perl + +# 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 . + +use Modern::Perl; + +use Test::NoWarnings; +use Test::More tests => 5; +use Test::Mojo; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use YAML::XS; + +use Koha::Database; +use Koha::SearchFields; +use Koha::SearchMarcMaps; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +my $t = Test::Mojo->new('Koha::REST::V1'); +t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); + +subtest 'authorization' => sub { + + plan tests => 4; + + $schema->storage->txn_begin; + + my $authorized_patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 0 } + } + ); + $builder->build( + { + source => 'UserPermission', + value => { + borrowernumber => $authorized_patron->borrowernumber, + module_bit => 3, + code => 'manage_search_engine_config', + }, + } + ); + my $password = 'thePassword123'; + $authorized_patron->set_password( { password => $password, skip_validation => 1 } ); + + my $unauthorized_patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 0 } + } + ); + $unauthorized_patron->set_password( { password => $password, skip_validation => 1 } ); + + $t->get_ok( "//" . $unauthorized_patron->userid . ":$password@/api/v1/config/search_mappings" )->status_is(403); + + $t->get_ok( "//" . $authorized_patron->userid . ":$password@/api/v1/config/search_mappings" )->status_is(200); + + $schema->storage->txn_rollback; +}; + +subtest 'get JSON (default)' => sub { + + plan tests => 10; + + $schema->storage->txn_begin; + + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 3**2 } + } + ); + my $password = 'thePassword123'; + $patron->set_password( { password => $password, skip_validation => 1 } ); + my $userid = $patron->userid; + + # Clear existing data + Koha::SearchFields->search->delete; + Koha::SearchMarcMaps->search->delete; + $schema->resultset('SearchMarcToField')->search->delete; + + # Create test mapping + my $search_field = Koha::SearchFields->find_or_create( + { + name => 'title', + label => 'Title', + type => 'string', + weight => 17, + staff_client => 0, + opac => 1, + mandatory => 1, + }, + { key => 'name' } + ); + + my $marc_map = Koha::SearchMarcMaps->find_or_create( + { + index_name => 'biblios', + marc_type => 'marc21', + marc_field => '245', + } + ); + + $search_field->add_to_search_marc_maps( + $marc_map, + { + facet => 0, + suggestible => 0, + sort => 1, + filter => '', + } + ); + + $t->get_ok("//$userid:$password@/api/v1/config/search_mappings") + ->status_is(200) + ->content_type_like(qr/json/) + ->json_has('/biblios/title') + ->json_is( '/biblios/title/label' => 'Title' ) + ->json_is( '/biblios/title/type' => 'string' ) + ->json_is( '/biblios/title/mandatory' => 1 ) + ->json_is( '/biblios/title/opac' => 1 ); + + my $mapping = $t->tx->res->json->{biblios}{title}{mappings}[0]; + is( $mapping->{marc_field}, '245', 'marc_field is 245' ); + is( $mapping->{marc_type}, 'marc21', 'marc_type is marc21' ); + + $schema->storage->txn_rollback; +}; + +subtest 'get YAML' => sub { + + plan tests => 6; + + $schema->storage->txn_begin; + + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 3**2 } + } + ); + my $password = 'thePassword123'; + $patron->set_password( { password => $password, skip_validation => 1 } ); + my $userid = $patron->userid; + + # Clear existing data + Koha::SearchFields->search->delete; + Koha::SearchMarcMaps->search->delete; + $schema->resultset('SearchMarcToField')->search->delete; + + # Create test mapping + my $search_field = Koha::SearchFields->find_or_create( + { + name => 'author', + label => 'Author', + type => 'string', + staff_client => 1, + opac => 1, + mandatory => 0, + }, + { key => 'name' } + ); + + my $marc_map = Koha::SearchMarcMaps->find_or_create( + { + index_name => 'biblios', + marc_type => 'marc21', + marc_field => '100a', + } + ); + + $search_field->add_to_search_marc_maps( + $marc_map, + { + facet => 1, + suggestible => 0, + sort => 0, + filter => '', + } + ); + + $t->get_ok( "//$userid:$password@/api/v1/config/search_mappings" => { Accept => 'application/yaml' } ) + ->status_is(200) + ->content_type_like(qr/yaml/); + + my $body = $t->tx->res->text; + my $mappings = YAML::XS::Load($body); + + is( ref $mappings->{biblios}{author}, 'HASH', 'YAML contains author field' ); + is( $mappings->{biblios}{author}{label}, 'Author', 'YAML label is correct' ); + is( $mappings->{biblios}{author}{mappings}[0]{marc_field}, '100a', 'YAML marc_field is correct' ); + + $schema->storage->txn_rollback; +}; + +subtest 'type filter' => sub { + + plan tests => 8; + + $schema->storage->txn_begin; + + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 3**2 } + } + ); + my $password = 'thePassword123'; + $patron->set_password( { password => $password, skip_validation => 1 } ); + my $userid = $patron->userid; + + # Clear existing data + Koha::SearchFields->search->delete; + Koha::SearchMarcMaps->search->delete; + $schema->resultset('SearchMarcToField')->search->delete; + + # Create test field with both marc21 and unimarc mappings + my $search_field = Koha::SearchFields->find_or_create( + { + name => 'title', + label => 'Title', + type => 'string', + staff_client => 1, + opac => 1, + mandatory => 0, + }, + { key => 'name' } + ); + + my $marc21_map = Koha::SearchMarcMaps->find_or_create( + { + index_name => 'biblios', + marc_type => 'marc21', + marc_field => '245', + } + ); + + $search_field->add_to_search_marc_maps( + $marc21_map, + { + facet => 0, + suggestible => 0, + sort => 0, + filter => '', + } + ); + + my $unimarc_map = Koha::SearchMarcMaps->find_or_create( + { + index_name => 'biblios', + marc_type => 'unimarc', + marc_field => '200a', + } + ); + + $search_field->add_to_search_marc_maps( + $unimarc_map, + { + facet => 0, + suggestible => 1, + sort => 0, + filter => '', + } + ); + + # No filter — both mappings returned + $t->get_ok("//$userid:$password@/api/v1/config/search_mappings")->status_is(200); + + my $all_mappings = $t->tx->res->json->{biblios}{title}{mappings}; + is( scalar @$all_mappings, 2, 'Unfiltered returns both marc21 and unimarc mappings' ); + + # Filter by marc21 + $t->get_ok("//$userid:$password@/api/v1/config/search_mappings?type=marc21")->status_is(200); + + my $marc21_mappings = $t->tx->res->json->{biblios}{title}{mappings}; + is( scalar @$marc21_mappings, 1, 'Filtered to marc21 returns 1 mapping' ); + is( $marc21_mappings->[0]{marc_type}, 'marc21', 'Returned mapping is marc21' ); + is( $marc21_mappings->[0]{marc_field}, '245', 'Returned mapping field is 245' ); + + $schema->storage->txn_rollback; +};