ILLDEV-423 metadata lookup#648
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces configurable “metadata lookup” behavior that can update ISO18626 bibliographic metadata using holdings lookup results, controlled via new Directory configuration fields.
Changes:
- Extends the Directory API schema with
metadataUpdateMode(replace/merge/none/auto) andmetadataFormat(marc21). - Adds a new
broker/metadataupdatepackage and integrates metadata-update behavior into supplier location, patron-request creation, and outbound ISO18626 request construction. - Adds end-to-end and unit tests plus XML testdata to validate replace vs merge behavior.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| directory/directory_api.yaml | Adds schema enums/fields for metadata update mode and format. |
| broker/test/testdata/request-metadata-update.xml | New ISO18626 request fixture used by E2E tests. |
| broker/test/service/e2e_test.go | Adds E2E coverage for metadata update replace/merge modes and hardens event formatting. |
| broker/service/supplierlocator.go | Applies holdings-derived metadata updates to stored transaction data (when enabled). |
| broker/patron_request/api/api-handler.go | Optionally applies holdings-derived metadata updates during patron request creation. |
| broker/metadataupdate/metadataupdate.go | New implementation for applying replace/merge/none bibliographic metadata updates. |
| broker/metadataupdate/metadataupdate_test.go | Unit tests for metadata update behavior. |
| broker/holdings/creator.go | Adds a MetadataSettings struct used for metadata update configuration. |
| broker/holdings/creator_test.go | Adds tests for metadata settings defaults/config and update-mode/format resolution helpers. |
| broker/holdings/creator_impl.go | Implements metadata settings parsing and mode/format/hint resolution helpers. |
| broker/client/client.go | Alters outbound request message creation to honor metadata update mode. |
| broker/app/app.go | Wires new dependencies into the patron request API handler. |
| bibliographicInfo := trCtx.transaction.IllTransactionData.BibliographicInfo | ||
| if resolvedMode == directory.None { | ||
| // Backward compatibility: preserve historical request-message behavior when metadata updates are disabled. | ||
| bibliographicInfo.SupplierUniqueRecordId = trCtx.selectedSupplier.LocalID.String | ||
| } else { | ||
| bibliographicInfo = metadataupdate.ApplyBibliographicUpdate( | ||
| bibliographicInfo, | ||
| metadataupdate.MetadataFields{LocalIdentifier: trCtx.selectedSupplier.LocalID.String}, | ||
| resolvedMode, | ||
| ) | ||
| } |
| func appendCrosslinkMetadata(existing []iso18626.BibliographicRecordId, metadata MetadataFields, mergeOnly bool) []iso18626.BibliographicRecordId { | ||
| values := map[string]string{ | ||
| "location": metadata.Location, | ||
| "shelvingLocation": metadata.ShelvingLocation, | ||
| "callNumber": metadata.CallNumber, | ||
| "itemId": metadata.ItemId, | ||
| } | ||
| for code, value := range values { | ||
| trimmed := strings.TrimSpace(value) | ||
| if trimmed == "" { | ||
| continue | ||
| } | ||
| if mergeOnly && hasCrosslinkCode(existing, code) { | ||
| continue | ||
| } | ||
| scheme := "crosslink" | ||
| existing = append(existing, iso18626.BibliographicRecordId{ | ||
| BibliographicRecordIdentifierCode: iso18626.TypeSchemeValuePair{Text: code, Scheme: &scheme}, | ||
| BibliographicRecordIdentifier: trimmed, | ||
| }) | ||
| } | ||
| return existing | ||
| } |
| func stripCrosslinkMetadataCodes(existing []iso18626.BibliographicRecordId) []iso18626.BibliographicRecordId { | ||
| result := make([]iso18626.BibliographicRecordId, 0, len(existing)) | ||
| for _, entry := range existing { | ||
| if isCrosslinkMetadataCode(entry.BibliographicRecordIdentifierCode.Text) { | ||
| continue | ||
| } | ||
| result = append(result, entry) | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| func hasCrosslinkCode(existing []iso18626.BibliographicRecordId, code string) bool { | ||
| for _, entry := range existing { | ||
| if strings.EqualFold(strings.TrimSpace(entry.BibliographicRecordIdentifierCode.Text), strings.TrimSpace(code)) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } |
| func appendCrosslinkMetadata(existing []iso18626.BibliographicRecordId, metadata MetadataFields, mergeOnly bool) []iso18626.BibliographicRecordId { | ||
| values := map[string]string{ | ||
| "location": metadata.Location, | ||
| "shelvingLocation": metadata.ShelvingLocation, | ||
| "callNumber": metadata.CallNumber, | ||
| "itemId": metadata.ItemId, | ||
| } | ||
| for code, value := range values { | ||
| trimmed := strings.TrimSpace(value) | ||
| if trimmed == "" { | ||
| continue | ||
| } | ||
| if mergeOnly && hasCrosslinkCode(existing, code) { | ||
| continue | ||
| } | ||
| scheme := "crosslink" | ||
| existing = append(existing, iso18626.BibliographicRecordId{ | ||
| BibliographicRecordIdentifierCode: iso18626.TypeSchemeValuePair{Text: code, Scheme: &scheme}, | ||
| BibliographicRecordIdentifier: trimmed, | ||
| }) | ||
| } | ||
| return existing | ||
| } |
| metadataSettings := holdings.GetMetadataSettings(trCtx.requester.CustomData) | ||
| lookupHint := holdings.LookupHintFromParams(holdings.LookupParamsFromBibliographicInfo( | ||
| trCtx.transaction.IllTransactionData.BibliographicInfo, | ||
| "", | ||
| )) | ||
| resolvedMode := holdings.ResolveMetadataUpdateMode(string(metadataSettings.Mode), lookupHint) | ||
| bibliographicInfo := trCtx.transaction.IllTransactionData.BibliographicInfo | ||
| if resolvedMode == directory.None { | ||
| // Backward compatibility: preserve historical request-message behavior when metadata updates are disabled. | ||
| bibliographicInfo.SupplierUniqueRecordId = trCtx.selectedSupplier.LocalID.String | ||
| } else { | ||
| bibliographicInfo = metadataupdate.ApplyBibliographicUpdate( | ||
| bibliographicInfo, | ||
| metadataupdate.MetadataFields{LocalIdentifier: trCtx.selectedSupplier.LocalID.String}, | ||
| resolvedMode, | ||
| ) | ||
| } |
| metadataSettings := holdings.GetMetadataSettings(trCtx.requester.CustomData) | ||
| lookupHint := holdings.LookupHintFromParams(holdings.LookupParamsFromBibliographicInfo( | ||
| trCtx.transaction.IllTransactionData.BibliographicInfo, | ||
| "", | ||
| )) | ||
| resolvedMode := holdings.ResolveMetadataUpdateMode(string(metadataSettings.Mode), lookupHint) | ||
| bibliographicInfo := trCtx.transaction.IllTransactionData.BibliographicInfo | ||
| if resolvedMode == directory.None { | ||
| // Backward compatibility: preserve historical request-message behavior when metadata updates are disabled. | ||
| bibliographicInfo.SupplierUniqueRecordId = trCtx.selectedSupplier.LocalID.String | ||
| } else { | ||
| bibliographicInfo = metadataupdate.ApplyBibliographicUpdate( | ||
| bibliographicInfo, | ||
| metadataupdate.MetadataFields{LocalIdentifier: trCtx.selectedSupplier.LocalID.String}, | ||
| resolvedMode, | ||
| ) | ||
| } |
| func stripCrosslinkMetadataCodes(existing []iso18626.BibliographicRecordId) []iso18626.BibliographicRecordId { | ||
| result := make([]iso18626.BibliographicRecordId, 0, len(existing)) | ||
| for _, entry := range existing { | ||
| if isCrosslinkMetadataCode(entry.BibliographicRecordIdentifierCode.Text) { | ||
| continue | ||
| } | ||
| result = append(result, entry) | ||
| } | ||
| return result | ||
| } |
| func hasCrosslinkCode(existing []iso18626.BibliographicRecordId, code string) bool { | ||
| for _, entry := range existing { | ||
| if strings.EqualFold(strings.TrimSpace(entry.BibliographicRecordIdentifierCode.Text), strings.TrimSpace(code)) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } |
| if mode == directory.Replace { | ||
| updated.SupplierUniqueRecordId = metadata.LocalIdentifier | ||
| updated.BibliographicRecordId = stripCrosslinkMetadataCodes(updated.BibliographicRecordId) | ||
| updated.BibliographicRecordId = appendCrosslinkMetadata(updated.BibliographicRecordId, metadata, false) | ||
| return updated | ||
| } |
| firstHolding := holdingsResult[0] | ||
|
|
||
| if requester.Vendor != string(directory.CrossLink) { | ||
| holdingsConfigSource := requester.CustomData | ||
| if len(consortiumPeers) > 0 && consortiumPeers[0].CustomData.HoldingsConfig != nil { | ||
| holdingsConfigSource = consortiumPeers[0].CustomData | ||
| } | ||
| metadataSettings := holdings.GetMetadataSettings(holdingsConfigSource) | ||
| lookupHint := holdings.LookupHintFromParams(holdingsParams) | ||
| resolvedMode := holdings.ResolveMetadataUpdateMode(string(metadataSettings.Mode), lookupHint) | ||
| if resolvedMode != directory.None { | ||
| if !metadataupdate.SupportsFormat(metadataSettings.Format) { | ||
| return events.LogErrorAndReturnResult(ctx, "unsupported metadata format for metadata update", fmt.Errorf("unsupported metadata format: %s", metadataSettings.Format)) | ||
| } | ||
| illTrans.IllTransactionData.BibliographicInfo = metadataupdate.ApplyBibliographicUpdate( | ||
| illTrans.IllTransactionData.BibliographicInfo, | ||
| metadataupdate.MetadataFields{LocalIdentifier: firstHolding.LocalIdentifier, Location: firstHolding.Location, ShelvingLocation: firstHolding.ShelvingLocation, CallNumber: firstHolding.CallNumber, ItemId: firstHolding.ItemId}, | ||
| resolvedMode, | ||
| ) |
| test.WaitForPredicateToBeTrue(func() bool { | ||
| illTrans, err = illRepo.GetIllTransactionByRequesterRequestId(appCtx, getPgText(reqId)) | ||
| if err != nil { | ||
| t.Errorf("failed to find ill transaction by requester request id %v", reqId) | ||
| } | ||
| return illTrans.LastSupplierStatus.String == string(iso18626.TypeStatusUnfilled) && | ||
| illTrans.LastRequesterAction.String == "Request" | ||
| }) |
| // The outgoing SupplierUniqueRecordId after merge will still be "return-ISIL:META-MRG-SUP::WILLSUPPLY" | ||
| // (original preserved), which doesn't match a known supplier scenario, so the mock supplier | ||
| // responds with Unfilled. We wait for that terminal state before asserting the DB value. | ||
| stringData := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(string(data), |
| if !metadataupdate.SupportsFormat(settings.Format) { | ||
| return fmt.Errorf("unsupported metadata format: %s", settings.Format) | ||
| } | ||
| params := holdings.LookupParamsFromBibliographicInfo(illRequest.BibliographicInfo, illRequest.ServiceInfo) | ||
| resolvedMode := holdings.ResolveMetadataUpdateMode(string(settings.Mode), holdings.LookupHintFromParams(params)) | ||
| if resolvedMode == directory.None { | ||
| return nil | ||
| } |
| GetAdapter(peer ill_db.Peer) (LookupAdapter, error) | ||
| } | ||
|
|
||
| type MetadataSettings struct { |
There was a problem hiding this comment.
@adamdickmeiss can we call this and related classes something like MetadataLookup...
|
|
||
| const DefaultMetadataFormat = directory.Marc21 | ||
|
|
||
| type MetadataFields struct { |
There was a problem hiding this comment.
@adamdickmeiss the purpose of the metadata update is to fill out the BibliographicInformation fields: title, author, isbn/issn, etc. Those fields will be different from the holdings fields which are filled in during availability/holdings lookup.
| if mode == directory.Replace { | ||
| updated.SupplierUniqueRecordId = metadata.LocalIdentifier | ||
| updated.BibliographicRecordId = stripCrosslinkMetadataCodes(updated.BibliographicRecordId) | ||
| updated.BibliographicRecordId = appendCrosslinkMetadata(updated.BibliographicRecordId, metadata, false) | ||
| return updated | ||
| } |
| // GetLookupAdapter returns the holdings lookup adapter for the requester. | ||
| // If a holdingsAdapter was directly configured it is returned as-is; otherwise the adapter | ||
| // is derived from the resolved config peer (consortium peer or requester). | ||
| // Returns nil without error when the resolved peer has no holdings adapter configured. | ||
| func (s *HoldingsAdapterFactory) GetLookupAdapter(ctx common.ExtendedContext, requester ill_db.Peer) (holdings.LookupAdapter, error) { |
| if len(peers) == 0 { | ||
| api.AddBadRequestError(ctx, w, errors.New("no cached peers found")) | ||
| return | ||
| } |
|
replaced with #653 |
https://index-data.atlassian.net/browse/ILLDEV-423