From cb8f1ff9d5a8204cf05b3bf2c286696d380f8fdb Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Mon, 13 Apr 2026 13:55:12 +0100 Subject: [PATCH] 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: ` 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 , credentials and host): curl -u : -X POST \ -H 'Content-Type: application/marcxml+xml' \ -H 'x-authority-type: CORPO_NAME' \ -H 'x-koha-matchrule: ' \ --data-binary @duplicate.xml \ http:///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 Signed-off-by: Andrew Fuerste Henry Signed-off-by: Lucas Gass --- Koha/REST/V1/Authorities.pm | 65 +++++++++++++++++++++++---- api/v1/swagger/paths/authorities.yaml | 19 +++++++- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/Koha/REST/V1/Authorities.pm b/Koha/REST/V1/Authorities.pm index 98b54cee5e..8374497552 100644 --- a/Koha/REST/V1/Authorities.pm +++ b/Koha/REST/V1/Authorities.pm @@ -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 ); diff --git a/api/v1/swagger/paths/authorities.yaml b/api/v1/swagger/paths/authorities.yaml index 05d1e39330..b67c0990cf 100644 --- a/api/v1/swagger/paths/authorities.yaml +++ b/api/v1/swagger/paths/authorities.yaml @@ -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":