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 <laurence.rault@biblibre.com>
Signed-off-by: Thomas Klausner <domm@plix.at>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Tomás Cohen Arazi 2026-03-23 17:16:18 +01:00 committed by Lucas Gass
parent 975f0c3508
commit caa52ddf91
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
5 changed files with 413 additions and 0 deletions

View File

@ -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] );

View File

@ -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 <https://www.gnu.org/licenses>.
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;

View File

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

View File

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

View File

@ -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 <https://www.gnu.org/licenses>.
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;
};