Bug 41901: Allow duplicate check when adding authority via API

POST /api/v1/authorities currently offers only the heading-based
C4::AuthoritiesMarc::FindDuplicateAuthority check, and the only
escape hatch is the `x-koha-override` header.

Koha already ships configurable record matching rules (see
Administration > Record matching rules) and C4::Matcher already
supports `record_type = authority`. This patch lets an API caller
opt into one of those rules when creating an authority by sending
its numeric id in a new `x-koha-matchrule` header.

Behaviour:
* If no `x-koha-matchrule` header is sent the existing heading-based
  duplicate check is used. Existing clients are unaffected.
* If `x-koha-matchrule: <id>` is sent:
    * non-existent id          -> 400 `matching_rule_not_found`
    * matcher not record_type=authority
                               -> 400 `invalid_matching_rule_type`
    * matcher returns matches  -> 409 `duplicate`; the response
                                  body additionally contains a
                                  `matches: [{authid, score}, ...]`
                                  array listing every hit returned
                                  by the matcher
    * matcher returns nothing  -> record is created (201)
* `x-koha-override: any|duplicate` continues to bypass all duplicate
  checks, including the matcher.

A non-integer header value is rejected by the OpenAPI schema
validator before the controller runs.

Test plan:
 1) Apply the patch series.
 2) Run `prove t/db_dependent/api/v1/authorities.t`
    => all subtests pass.
 3) Log in to the staff interface as a user with the
    'manage_matching_rules' subpermission and go to
    Administration > Record matching rules > New record matching
    rule:
     a) Set Matching rule code e.g. `AUTH_HEADING`
     b) Set Description e.g. `Authority heading match`
     c) Set Record type to `authority`
     d) Set Threshold to e.g. `1000`
     e) Add a match point:
         * Search index:  `Heading-Main`  (or your authorities index)
         * Score:         `1000`
         * Tag:           `110` (or the heading tag used by your
                          authority type)
         * Subfields:     `a`
     f) Save. Note the numeric matching rule id in the edit URL
        (`admin/matching-rules.pl?op=edit_matching_rule&matcher_id=N`).
 4) Export an existing authority record as MARCXML (for example
    from the authority details page). Save it as `duplicate.xml`.
 5) From the command line, POST the record back to Koha using the
    new header (replace <N>, credentials and host):

    curl -u <user>:<pass> -X POST \
      -H 'Content-Type: application/marcxml+xml' \
      -H 'x-authority-type: CORPO_NAME' \
      -H 'x-koha-matchrule: <N>' \
      --data-binary @duplicate.xml \
      http://<your-koha>/api/v1/authorities

    => Expect HTTP 409. The JSON response body contains
       `error_code: "duplicate"` and a `matches` array listing the
       authid(s) that were found along with their score.
 6) Edit `duplicate.xml` so the main heading is something that is
    not already in the catalogue. POST it again with the same
    header.
    => Expect HTTP 201 and a `Location` header pointing to the new
       authority.
 7) Re-run the POST from step 5 but additionally send
    `-H 'x-koha-override: duplicate'`.
    => Expect HTTP 201 (override bypasses the matcher).
 8) Send a POST with `x-koha-matchrule: 999999`.
    => Expect HTTP 400 with `error_code: "matching_rule_not_found"`.
 9) Create a second matching rule with Record type `biblio` and
    note its id. POST with `x-koha-matchrule` set to that id.
    => Expect HTTP 400 with
       `error_code: "invalid_matching_rule_type"`.
10) POST with `x-koha-matchrule: abc`.
    => Expect HTTP 400 from the OpenAPI schema validator.
11) POST without `x-koha-matchrule`.
    => Expect the existing heading-based duplicate check to run
       exactly as before (409 for a known duplicate, 201 otherwise).
12) Sign off.

Signed-off-by: Brian J. Barr <brian.barr@churchofjesuschrist.org>
Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
(cherry picked from commit cb8f1ff9d5)
Signed-off-by: Jacob O'Mara <Jacob.omara@openfifth.co.uk>
This commit is contained in:
Martin Renvoize 2026-04-13 13:55:12 +01:00 committed by Jacob O'Mara
parent df27f4884b
commit 52c16d61c3
No known key found for this signature in database
2 changed files with 74 additions and 10 deletions

View File

@ -21,6 +21,7 @@ use Mojo::Base 'Mojolicious::Controller';
use Koha::Authorities;
use C4::AuthoritiesMarc qw( DelAuthority AddAuthority FindDuplicateAuthority ModAuthority);
use C4::Matcher;
use List::MoreUtils qw( any );
use MARC::Record::MiJ;
@ -156,17 +157,63 @@ sub add {
);
}
unless ( $overrides->{any} || $overrides->{duplicate} ) {
my ( $duplicateauthid, $duplicateauthvalue ) =
C4::AuthoritiesMarc::FindDuplicateAuthority( $record, $authtypecode );
my $matcher_id = $headers->header('x-koha-matchrule');
my $matcher;
if ( defined $matcher_id && $matcher_id ne '' ) {
$matcher = C4::Matcher->fetch($matcher_id);
unless ($matcher) {
return $c->render(
status => 400,
openapi => {
error => "Matching rule $matcher_id not found",
error_code => 'matching_rule_not_found',
}
);
}
unless ( $matcher->record_type eq 'authority' ) {
return $c->render(
status => 400,
openapi => {
error => "Matching rule $matcher_id is not an authority matcher",
error_code => 'invalid_matching_rule_type',
}
);
}
}
return $c->render(
status => 409,
openapi => {
error => "Duplicate record ($duplicateauthid)",
error_code => 'duplicate',
unless ( $overrides->{any} || $overrides->{duplicate} ) {
if ($matcher) {
my @matches = $matcher->get_matches( $record, 10 );
if (@matches) {
my $first = $matches[0]->{record_id};
return $c->render(
status => 409,
openapi => {
error => "Duplicate record ($first)",
error_code => 'duplicate',
matches => [
map {
{
authid => $_->{record_id} + 0,
score => $_->{score} + 0,
}
} @matches
],
}
);
}
) unless !$duplicateauthid;
} else {
my ( $duplicateauthid, $duplicateauthvalue ) =
C4::AuthoritiesMarc::FindDuplicateAuthority( $record, $authtypecode );
return $c->render(
status => 409,
openapi => {
error => "Duplicate record ($duplicateauthid)",
error_code => 'duplicate',
}
) unless !$duplicateauthid;
}
}
my $authid = AddAuthority( $record, undef, $authtypecode );

View File

@ -94,11 +94,24 @@
- any
- duplicate
collectionFormat: csv
- name: x-koha-matchrule
in: header
required: false
description: |
Numeric id of a Koha record matching rule (record_type=authority) to
use for duplicate detection instead of the default heading-based
check. If the id does not reference an authority matcher a 400
response is returned.
type: integer
responses:
"201":
description: An authority
"400":
description: Bad request
description: |
Bad request. Possible `error_code` attribute values:
* `matching_rule_not_found`
* `invalid_matching_rule_type`
schema:
$ref: "../swagger.yaml#/definitions/error"
"401":
@ -121,6 +134,10 @@
Conflict creating the resource. Possible `error_code` attribute values:
* `duplicate`
When a matching rule is requested via `x-koha-matchrule`, the response
body additionally contains a `matches` array of objects with
`authid` and `score` for every authority returned by the matcher.
schema:
$ref: "../swagger.yaml#/definitions/error"
"500":