Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f15fd8a
LMS vendor profiles
adamdickmeiss Sep 11, 2026
a336777
disableNamespace private
adamdickmeiss Sep 11, 2026
ab4b769
Merge remote-tracking branch 'origin/main' into ILLDEV-515-lms-vendor…
adamdickmeiss Sep 15, 2026
c36a383
nonempty FOLIO temporary locations now become Holding.ShelvingLocation
adamdickmeiss Sep 15, 2026
048f05e
NULLIF(..., '') lets json_strip_nulls omit empty address and fromAgen…
adamdickmeiss Sep 15, 2026
5ba7c91
Profiles are yaml files
adamdickmeiss Sep 15, 2026
bef0608
docker: include builtin profiles
adamdickmeiss Sep 15, 2026
28d42ac
Preserve legacy precedence for parsers
adamdickmeiss Sep 15, 2026
6b9c3d4
Resolve: fix syntax defaults
adamdickmeiss Sep 15, 2026
8231a02
Merge branch 'main' into ILLDEV-515-lms-vendor-profiles
adamdickmeiss Sep 15, 2026
954dc2b
GoDoc
adamdickmeiss Sep 15, 2026
9b88073
Fix unstable holdings_test
adamdickmeiss Sep 15, 2026
d9d27d1
Add host profiles migration test
adamdickmeiss Sep 15, 2026
7f6ccb3
koha is Z39.50 + usmarc by default
adamdickmeiss Sep 15, 2026
f207d8c
catalog adapter can use contextual logger
adamdickmeiss Sep 15, 2026
7a6b8aa
Merge branch 'main' into ILLDEV-515-lms-vendor-profiles
adamdickmeiss Sep 15, 2026
2aad3e4
Merge branch 'main' into ILLDEV-515-lms-vendor-profiles
adamdickmeiss Sep 15, 2026
95f80a2
Merge branch 'main' into ILLDEV-515-lms-vendor-profiles
jakub-id Sep 16, 2026
f06ed42
PATCH containing holdingsFormat: {} preserves stored info
adamdickmeiss Sep 16, 2026
cdfe6b3
Validate: sru or zoom. HoldingsParserConfig only one
adamdickmeiss Sep 16, 2026
635915a
Only one place to check for holdingsFormat props
adamdickmeiss Sep 16, 2026
ccea1c3
Merge remote-tracking branch 'origin/ILLDEV-515-lms-vendor-profiles' …
adamdickmeiss Sep 16, 2026
fbe6e6e
migration moved to 009
adamdickmeiss Sep 16, 2026
b854ae2
empty holdingsFormat now inherits defaults for Generic too
adamdickmeiss Sep 16, 2026
32f98a8
Update import for the new profiles
adamdickmeiss Sep 16, 2026
c975b48
empty holdings now uses MARC defaults
adamdickmeiss Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions broker/Dockerfile.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ broker/**/*_test.go
# Include OpenAPI specs
!broker/oapi/*.yaml

# Include embedded built-in profiles
!broker/profiles/builtin/*.yaml

# Include statemodels
!broker/patron_request/service/statemodels

Expand Down
6 changes: 5 additions & 1 deletion broker/catalog/adapter_metaproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ type MetaproxyLookupAdapter struct {
}

func NewMetaproxyLookupAdapter(config dirapi.ZoomConfig, metaproxyUrl string, queryBuilder LookupQueryBuilder, holdingsParser HoldingsParser, metadataParser MetadataParser) (LookupAdapter, error) {
schema := "marcxml"
if config.Options != nil && (*config.Options)["preferredRecordSyntax"] == "opac" {
schema = "opac"
}
a := &MetaproxyLookupAdapter{
holdingsLookupAdapter: CreateSruLookupAdapter(http.DefaultClient, []string{metaproxyUrl}, config.Address, queryBuilder, holdingsParser, metadataParser, "marcxml"),
holdingsLookupAdapter: CreateSruLookupAdapter(http.DefaultClient, []string{metaproxyUrl}, config.Address, queryBuilder, holdingsParser, metadataParser, schema),
}
return a, nil
}
Expand Down
3 changes: 2 additions & 1 deletion broker/catalog/creator.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package catalog

import (
"github.com/indexdata/crosslink/broker/common"
"github.com/indexdata/crosslink/broker/ill_db"
)

type LookupAdapterCreator interface {
GetAdapter(peer ill_db.Peer) (LookupAdapter, error)
GetAdapter(ctx common.ExtendedContext, peer ill_db.Peer) (LookupAdapter, error)
}
12 changes: 10 additions & 2 deletions broker/catalog/creator_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package catalog
import (
"fmt"

"github.com/indexdata/crosslink/broker/common"
"github.com/indexdata/crosslink/broker/ill_db"
"github.com/indexdata/crosslink/broker/profiles"
dirapi "github.com/indexdata/crosslink/directory/api"
)

Expand Down Expand Up @@ -54,9 +56,15 @@ func getHoldingsParser(config *dirapi.HoldingsParserConfig) (HoldingsParser, err
return nil, fmt.Errorf("catalogConfig.holdingsFormat must set marc, opac, reservoir, or marc21plus1 properties")
}

func (c *LookupAdapterCreatorImpl) GetAdapter(peer ill_db.Peer) (LookupAdapter, error) {
func (c *LookupAdapterCreatorImpl) GetAdapter(ctx common.ExtendedContext, peer ill_db.Peer) (LookupAdapter, error) {
entry := peer.CustomData
config := entry.CatalogConfig
effective, err := profiles.Resolve(entry)
if err != nil {
return nil, err
}
// Diagnostics excludes endpoints, credentials, and other sensitive settings.
ctx.Logger().Debug("resolved host profiles", "configuration", effective.Diagnostics())
config := effective.Catalog
// CatalogConfig also contains settings unrelated to availability, such as
// metadataUpdateMode. Only an SRU or ZOOM definition enables the check.
if config == nil || (config.Sru == nil && config.Zoom == nil) {
Expand Down
48 changes: 36 additions & 12 deletions broker/catalog/creator_test.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
package catalog

import (
"context"
"testing"

"github.com/indexdata/crosslink/broker/common"
"github.com/indexdata/crosslink/broker/ill_db"
dirapi "github.com/indexdata/crosslink/directory/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGetAdapterEmpty(t *testing.T) {
creator := NewLookupAdapterCreator(LookupAdapterZoom, "")
peer := ill_db.Peer{}
aa, err := creator.GetAdapter(peer)
aa, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
assert.NoError(t, err)
assert.Nil(t, aa)
}

func TestGetAdapterOtherNoConfig(t *testing.T) {
creator := NewLookupAdapterCreator("other", "")
peer := ill_db.Peer{}
aa, err := creator.GetAdapter(peer)
aa, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
assert.NoError(t, err)
assert.Nil(t, aa)
}
Expand Down Expand Up @@ -63,13 +66,34 @@ func TestGetAdapterBadParser(t *testing.T) {
Zoom: &dirapi.ZoomConfig{
Address: "a",
},
HoldingsFormat: &dirapi.HoldingsParserConfig{
Marc: &dirapi.MarcHoldingsParserConfig{},
Opac: &dirapi.OpacHoldingsParserConfig{},
},
},
},
}
adapter, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
require.ErrorContains(t, err, "exactly one parser")
require.Nil(t, adapter)
}

func TestGetAdapterEmptyHoldingsUsesMarcDefaults(t *testing.T) {
creator := NewLookupAdapterCreator(LookupAdapterZoom, "")
peer := ill_db.Peer{
CustomData: dirapi.Entry{
CatalogConfig: &dirapi.CatalogConfig{
Sru: &dirapi.SruConfig{Address: "https://catalog.example/sru"},
HoldingsFormat: &dirapi.HoldingsParserConfig{},
},
},
}
_, err := creator.GetAdapter(peer)
assert.Error(t, err)
assert.Contains(t, err.Error(), "must set marc")
adapter, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
require.NoError(t, err)
require.IsType(t, &SruLookupAdapter{}, adapter)
sru := adapter.(*SruLookupAdapter)
require.Equal(t, NewMarcHoldingsParser(dirapi.MarcHoldingsParserConfig{}), sru.holdingsParser)
require.Equal(t, "marcxml", sru.recordSchema)
}

func TestGetAdapterOtherWithConfig(t *testing.T) {
Expand All @@ -83,7 +107,7 @@ func TestGetAdapterOtherWithConfig(t *testing.T) {
},
},
}
_, err := creator.GetAdapter(peer)
_, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported lookup adapter type: other")
}
Expand All @@ -96,7 +120,7 @@ func TestGetAdapterMetadataOnly(t *testing.T) {
CatalogConfig: &dirapi.CatalogConfig{MetadataUpdateMode: &mode},
},
}
aa, err := creator.GetAdapter(peer)
aa, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
assert.NoError(t, err)
assert.Nil(t, aa)
}
Expand All @@ -112,7 +136,7 @@ func TestGetAdapterMock(t *testing.T) {
},
}
creator := NewLookupAdapterCreator(LookupAdapterMock, "")
aa, err := creator.GetAdapter(peer)
aa, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
assert.NoError(t, err)
assert.IsType(t, &MockLookupAdapter{}, aa)
}
Expand All @@ -128,7 +152,7 @@ func TestGetAdapterZoom(t *testing.T) {
},
}
creator := NewLookupAdapterCreator(LookupAdapterZoom, "")
aa, err := creator.GetAdapter(peer)
aa, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
if !cgoEnabled() {
assert.Error(t, err)
assert.Contains(t, err.Error(), "requires cgo")
Expand All @@ -150,7 +174,7 @@ func TestGetAdapterMetaproxy(t *testing.T) {
},
}
creator := NewLookupAdapterCreator(LookupAdapterMetaproxy, "http://metaproxy.indexdata.com")
aa, err := creator.GetAdapter(peer)
aa, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
assert.NoError(t, err)
assert.IsType(t, &MetaproxyLookupAdapter{}, aa)
}
Expand All @@ -166,7 +190,7 @@ func TestGetAdapterMetaproxyMissingProxy(t *testing.T) {
},
}
creator := NewLookupAdapterCreator(LookupAdapterMetaproxy, "")
_, err := creator.GetAdapter(peer)
_, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
assert.Error(t, err)
assert.Contains(t, err.Error(), "METAPROXY_URL")
}
Expand All @@ -182,7 +206,7 @@ func TestGetAdapterSRU(t *testing.T) {
},
}
creator := NewLookupAdapterCreator(LookupAdapterZoom, "")
aa, err := creator.GetAdapter(peer)
aa, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
assert.NoError(t, err)
assert.IsType(t, &SruLookupAdapter{}, aa)
}
4 changes: 3 additions & 1 deletion broker/catalog/gvi_holdings_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package catalog

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/indexdata/crosslink/broker/common"
"github.com/indexdata/crosslink/broker/ill_db"
dirapi "github.com/indexdata/crosslink/directory/api"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -416,7 +418,7 @@ func TestGviHoldings(t *testing.T) {
},
}

aa, err := creator.GetAdapter(peer)
aa, err := creator.GetAdapter(common.CreateExtCtxWithArgs(context.Background(), nil), peer)
if cgoEnabled() {
assert.NoError(t, err)
assert.NotNil(t, aa)
Expand Down
18 changes: 17 additions & 1 deletion broker/catalog/holdings_parser_marc.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type MarcHoldingsParser struct {
}

func NewMarcHoldingsParser(config dirapi.MarcHoldingsParserConfig) HoldingsParser {
if config.MainField == nil && config.LocationSubField == nil && config.ShelvingLocationSubField == nil && config.CallNumberSubField == nil && config.ItemIdSubField == nil && config.RestrictedSubField == nil {
if config.MainField == nil && config.LocationSubField == nil && config.ShelvingLocationSubField == nil && config.CallNumberSubField == nil && config.ItemIdSubField == nil && config.RestrictedSubField == nil && config.Availability == nil {
config.MainField = NewString("852")
config.LocationSubField = NewString("b")
config.ShelvingLocationSubField = NewString("c")
Expand Down Expand Up @@ -66,6 +66,22 @@ func (p *MarcHoldingsParser) Parse(record []byte, params LookupParams) ([]Holdin
restricted = true
}
}
if p.config.Availability != nil {
for _, rule := range *p.config.Availability {
found, matches := false, false
for _, sub := range field.Subfield {
if sub.Code == rule.SubField {
found = true
if rule.Value != nil && string(sub.Text) == *rule.Value {
matches = true
}
}
}
if (rule.Operator == "absent" && found) || (rule.Operator == "equals" && !matches) {
restricted = true
}
}
}
if !restricted && location != "" {
holdings = append(holdings, Holding{
Location: location,
Expand Down
67 changes: 46 additions & 21 deletions broker/catalog/holdings_parser_opac.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,72 @@ package catalog
import (
"encoding/xml"
"fmt"
"slices"
"strings"

dirapi "github.com/indexdata/crosslink/directory/api"
"github.com/indexdata/crosslink/marcxml"
)

type OpacHoldingsParser struct{}
type OpacHoldingsParser struct {
config dirapi.OpacHoldingsParserConfig
}

func NewOpacHoldingsParser(config dirapi.OpacHoldingsParserConfig) HoldingsParser {
return &OpacHoldingsParser{}
return &OpacHoldingsParser{config: config}
}
func enabled(value *bool, fallback bool) bool {
if value != nil {
return *value
}
return fallback
}

func (p *OpacHoldingsParser) Parse(record []byte, params LookupParams) ([]Holding, error) {
var opacRecord marcxml.OpacRecord
err := xml.Unmarshal(record, &opacRecord)
if err != nil {
if err := xml.Unmarshal(record, &opacRecord); err != nil {
return nil, fmt.Errorf("failed to unmarshal OPAC XML: %w", err)
}
var result []Holding
for _, holding := range opacRecord.Holdings.Holding {
availableNow := false
itemId := ""
itemLoanPolicy := ""
if enabled(p.config.RequireLocalLocation, false) && strings.TrimSpace(holding.LocalLocation) == "" {
continue
}
base := Holding{Location: holding.LocalLocation, ShelvingLocation: holding.ShelvingLocation, CallNumber: holding.CallNumber}
if p.config.ShelvingLocationSource != nil && *p.config.ShelvingLocationSource == "localLocation" {
base.ShelvingLocation = holding.LocalLocation
}
if p.config.AvailabilityRule != nil && *p.config.AvailabilityRule == "publicNote" {
if p.config.AvailablePublicNotes != nil && slices.Contains(*p.config.AvailablePublicNotes, holding.PublicNote) {
result = append(result, base)
}
continue
}
if holding.Circulations == nil {
continue
}
for _, circ := range holding.Circulations.Circulation {
// regrettably, YAZ uses 0 or 1 to indicate availability, instead of a boolean value
if circ.AvailableNow.Value == "1" {
itemId = circ.ItemId
itemLoanPolicy = strings.TrimSpace(circ.AvailableThru)
availableNow = true
if circ.AvailableNow.Value != "1" {
continue
}
h := base
if enabled(p.config.IncludeItemId, true) {
h.ItemId = circ.ItemId
}
if enabled(p.config.IncludeItemLoanPolicy, true) {
h.ItemLoanPolicy = strings.TrimSpace(circ.AvailableThru)
}
if enabled(p.config.IncludeTemporaryLocation, false) {
// Supplier policy and ordering consume the effective shelving location.
if temporaryLocation := strings.TrimSpace(circ.TemporaryLocation); temporaryLocation != "" {
h.ShelvingLocation = temporaryLocation
}
}
result = append(result, h)
if !enabled(p.config.AllCirculations, false) {
break
Comment thread
adamdickmeiss marked this conversation as resolved.
}
}
if availableNow {
result = append(result, Holding{
Location: holding.LocalLocation,
ShelvingLocation: holding.ShelvingLocation,
CallNumber: holding.CallNumber,
ItemId: itemId,
ItemLoanPolicy: itemLoanPolicy,
})
}
}
return result, nil
}
Loading