diff --git a/broker/README.md b/broker/README.md index e1acbf50..16d954bc 100644 --- a/broker/README.md +++ b/broker/README.md @@ -101,9 +101,12 @@ Configuration is provided via environment variables: | `CLIENT_DELAY` | Delay duration for outgoing ISO18626 messages | `0ms` | | `SHUTDOWN_DELAY` | Delay duration for graceful shutdown (in-flight connections) | `15s` | | `MAX_MESSAGE_SIZE` | Max accepted ISO18626 message size | `100KB` | -| `HOLDINGS_ADAPTER` | Holdings lookup method: `mock` or `sru` | `mock` | +| `HOLDINGS_ADAPTER` | Holdings lookup method: `mock`, `sru` or `consortia` | `mock` | | `HOLDINGS_SRU_URL` | Comma separated list of URLs when `HOLDINGS_ADAPTER` is `sru` | `http://localhost:8081/sru` | | `HOLDINGS_ISXN_LOOKUP` | Whether to use ISBN/ISSN lookup for `sru` method | `false` | +| `HOLDINGS_FORMAT` | Parser for SRU holdings: `reservoir`, `marc`, `opac` or `MARC-21plus-1` | `reservoir` | +| `CONSORTIA_SYMBOL` | Designates peer for which configuration is used for consortia. At this time, it is | (empty value) | +| | used when `HOLDINGS_ADAPTER` = `consortia`. | | | `DIRECTORY_ADAPTER` | Directory lookup method: `mock` or `api` | `mock` | | `DIRECTORY_API_URL` | Comma separated list of URLs when `DIRECTORY_ADAPTER` is `api` | `http://localhost:8081/directory/entries` | | `AVAILABILITY_ADAPTER` | Availability adapter: `mock` , `zoom`, `metaproxy`. | `zoom` | diff --git a/broker/adapter/create_holdings.go b/broker/adapter/create_holdings.go deleted file mode 100644 index 25468ab3..00000000 --- a/broker/adapter/create_holdings.go +++ /dev/null @@ -1,42 +0,0 @@ -package adapter - -import ( - "fmt" - "net/http" - "strings" -) - -const ( - HoldingsAdapter string = "HOLDINGS_ADAPTER" - HoldingsSruURL string = "HOLDINGS_SRU_URL" - HoldingsIsxnLookup string = "HOLDINGS_ISXN_LOOKUP" -) - -func CreateHoldingsLookupAdapter(cfg map[string]any) (LookupAdapter, error) { - holdingsAdapterVal, ok := cfg[HoldingsAdapter].(string) - if !ok { - return nil, fmt.Errorf("missing value for %s", HoldingsAdapter) - } - if holdingsAdapterVal == "sru" { - sruURLVal, ok := cfg[HoldingsSruURL].(string) - if !ok { - return nil, fmt.Errorf("missing value for %s", HoldingsSruURL) - } - _, ok = cfg[HoldingsIsxnLookup] - if !ok { - return nil, fmt.Errorf("missing value for %s", HoldingsIsxnLookup) - } - // ideally this should be per-SRU server and not for all - isxnLookup, ok := cfg[HoldingsIsxnLookup].(bool) - if !ok { - return nil, fmt.Errorf("invalid value for %s: %v", HoldingsIsxnLookup, isxnLookup) - } - queryBuilder := QueryBuilderIsxn{isxn: isxnLookup} - parser := &ReservoirHoldingsParser{} - return CreateSruHoldingsLookupAdapter(http.DefaultClient, strings.Split(sruURLVal, ","), "", &queryBuilder, parser, "marcxml"), nil - } - if holdingsAdapterVal == "mock" { - return &MockHoldingsLookupAdapter{}, nil - } - return nil, fmt.Errorf("bad value for %s", HoldingsAdapter) -} diff --git a/broker/adapter/query_builder_pqf.go b/broker/adapter/query_builder_pqf.go deleted file mode 100644 index 5bb97538..00000000 --- a/broker/adapter/query_builder_pqf.go +++ /dev/null @@ -1,106 +0,0 @@ -package adapter - -import ( - "errors" - "strings" - - "github.com/indexdata/crosslink/directory" -) - -type QueryBuilderPqf struct { - mappings directory.QueryConfig - defaultMap directory.QueryConfig -} - -func NewString(s string) *string { - if len(s) > 0 { - return &s - } - return nil -} - -func NewQueryBuilderPqf(queryConfig *directory.QueryConfig) *QueryBuilderPqf { - if queryConfig == nil { - queryConfig = &directory.QueryConfig{} - } - return &QueryBuilderPqf{mappings: *queryConfig, defaultMap: directory.QueryConfig{ - Identifier: NewString("@attr 1=12 {term}"), - Isbn: NewString("@attr 1=7 {term}"), - Issn: NewString("@attr 1=8 {term}"), - Title: NewString("@attr 1=4 {term}"), - }} -} - -func NewQueryBuilderCql(queryConfig *directory.QueryConfig) *QueryBuilderPqf { - if queryConfig == nil { - queryConfig = &directory.QueryConfig{} - } - return &QueryBuilderPqf{mappings: *queryConfig, defaultMap: directory.QueryConfig{ - Identifier: NewString("rec.id = {term}"), - Isbn: NewString("isbn = {term}"), - Issn: NewString("issn = {term}"), - Title: NewString("title = {term}"), - }} -} - -func pqfEncode(value string) string { - // escape backslashes and double quotes - escaped := "\"" - for _, r := range value { - if r == '\\' || r == '"' { - escaped += "\\" - } - escaped += string(r) - } - escaped += "\"" - return escaped -} - -func cqlEncode(value string) string { - // escape backslashes and double quotes - escaped := "\"" - for _, r := range value { - if r == '\\' || r == '"' { - escaped += "\\" - } - escaped += string(r) - } - escaped += "\"" - return escaped -} - -func (s *QueryBuilderPqf) Build(params LookupParams) (cql []string, pqf []string, err error) { - type paramMapping struct { - value string - mapping *string - dir string - } - - paramMappings := []paramMapping{ - {params.Identifier, s.mappings.Identifier, *s.defaultMap.Identifier}, - {params.Isbn, s.mappings.Isbn, *s.defaultMap.Isbn}, - {params.Issn, s.mappings.Issn, *s.defaultMap.Issn}, - {params.Title, s.mappings.Title, *s.defaultMap.Title}, - } - var pqfList []string - var cqlList []string - for _, pm := range paramMappings { - if pm.value != "" { - mapping := pm.dir - if pm.mapping != nil { - mapping = *pm.mapping - } - if strings.HasPrefix(mapping, "@") { - pqf := strings.ReplaceAll(mapping, "{term}", pqfEncode(pm.value)) - pqfList = append(pqfList, pqf) - } else { - cql := strings.ReplaceAll(mapping, "{term}", cqlEncode(pm.value)) - cqlList = append(cqlList, cql) - } - } - } - if len(cqlList) == 0 && len(pqfList) == 0 { - return nil, nil, errors.New("no search parameters provided for PQF/CQL query") - } - return cqlList, pqfList, nil -} diff --git a/broker/app/app.go b/broker/app/app.go index 0a16166f..b454e4d8 100644 --- a/broker/app/app.go +++ b/broker/app/app.go @@ -13,7 +13,7 @@ import ( "syscall" "time" - "github.com/indexdata/crosslink/broker/availability" + "github.com/indexdata/crosslink/broker/holdings" prapi "github.com/indexdata/crosslink/broker/patron_request/api" pr_db "github.com/indexdata/crosslink/broker/patron_request/db" "github.com/indexdata/crosslink/broker/patron_request/proapi" @@ -64,6 +64,8 @@ var LOG_LEVEL = utils.GetEnv("LOG_LEVEL", "INFO") var HOLDINGS_ADAPTER = utils.GetEnv("HOLDINGS_ADAPTER", "mock") var HOLDINGS_SRU_URL = common.GetEnvWithDeprecated("HOLDINGS_SRU_URL", "SRU_URL", "http://localhost:8081/sru") var HOLDINGS_ISXN_LOOKUP, _ = utils.GetEnvBool("HOLDINGS_ISXN_LOOKUP", false) +var HOLDINGS_FORMAT = utils.GetEnv("HOLDINGS_FORMAT", "reservoir") +var CONSORTIA_SYMBOL = utils.GetEnv("CONSORTIA_SYMBOL", "") var DIRECTORY_ADAPTER = utils.GetEnv("DIRECTORY_ADAPTER", "mock") var AVAILABILITY_ADAPTER = utils.GetEnv("AVAILABILITY_ADAPTER", "zoom") var DIRECTORY_API_URL = utils.GetEnv("DIRECTORY_API_URL", "http://localhost:8081/directory/entries") @@ -132,10 +134,11 @@ func configLog() slog.Handler { func Init(ctx context.Context) (Context, error) { appCtx.Logger().Info("starting " + vcs.GetSignature()) - holdingsAdapter, err := adapter.CreateHoldingsLookupAdapter(map[string]any{ - adapter.HoldingsAdapter: HOLDINGS_ADAPTER, - adapter.HoldingsSruURL: HOLDINGS_SRU_URL, - adapter.HoldingsIsxnLookup: HOLDINGS_ISXN_LOOKUP, + holdingsAdapter, err := holdings.CreateHoldingsLookupShared(map[string]any{ + holdings.HoldingsAdapter: HOLDINGS_ADAPTER, + holdings.HoldingsSruURL: HOLDINGS_SRU_URL, + holdings.HoldingsIsxnLookup: HOLDINGS_ISXN_LOOKUP, + holdings.HoldingsFormat: HOLDINGS_FORMAT, }) if err != nil { return Context{}, err @@ -174,11 +177,11 @@ func Init(ctx context.Context) (Context, error) { prMessageHandler := prservice.CreatePatronRequestMessageHandler(prRepo, eventRepo, illRepo, eventBus) iso18626Handler := handler.CreateIso18626Handler(eventBus, eventRepo, illRepo, dirAdapter) lmsCreator := lms.NewLmsCreator(illRepo, dirAdapter) - availabilityCreator := availability.NewAvailabilityCreator(AVAILABILITY_ADAPTER, METAPROXY_URL) + availabilityCreator := holdings.NewAvailabilityCreator(AVAILABILITY_ADAPTER, METAPROXY_URL) prActionService := prservice.CreatePatronRequestActionService(prRepo, eventBus, &iso18626Handler, lmsCreator) prMessageHandler.SetAutoActionRunner(prActionService) iso18626Client := client.CreateIso18626Client(eventBus, illRepo, prMessageHandler, MAX_MESSAGE_SIZE, delay) - supplierLocator := service.CreateSupplierLocator(eventBus, illRepo, dirAdapter, holdingsAdapter, availabilityCreator) + supplierLocator := service.CreateSupplierLocator(eventBus, illRepo, dirAdapter, holdingsAdapter, availabilityCreator, CONSORTIA_SYMBOL) workflowManager := service.CreateWorkflowManager(eventBus, illRepo, service.WorkflowConfig{}) tenantResolver := tenant.NewResolver().WithIllRepo(illRepo).WithLookupAdapter(dirAdapter).WithTenantToSymbol(TENANT_TO_SYMBOL) apiHandler := api.NewApiHandler(eventRepo, illRepo, *tenantResolver, API_PAGE_SIZE) diff --git a/broker/availability/availability_creator.go b/broker/availability/availability_creator.go deleted file mode 100644 index e04bcf71..00000000 --- a/broker/availability/availability_creator.go +++ /dev/null @@ -1,11 +0,0 @@ -package availability - -import ( - "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/common" - "github.com/indexdata/crosslink/broker/ill_db" -) - -type AvailabilityCreator interface { - GetAdapter(ctx common.ExtendedContext, peer ill_db.Peer) (adapter.LookupAdapter, error) -} diff --git a/broker/availability/availability_metaproxy.go b/broker/availability/availability_metaproxy.go deleted file mode 100644 index e6506334..00000000 --- a/broker/availability/availability_metaproxy.go +++ /dev/null @@ -1,24 +0,0 @@ -package availability - -import ( - "net/http" - - "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/common" - "github.com/indexdata/crosslink/directory" -) - -type MetaproxyAvailabilityAdapter struct { - holdingsLookupAdapter adapter.LookupAdapter -} - -func NewMetaproxyAvailabilityAdapter(ctx common.ExtendedContext, config directory.Z3950Config, metaproxyUrl string, queryBuilder adapter.LookupQueryBuilder, holdingsParser adapter.HoldingsParser) (adapter.LookupAdapter, error) { - a := &MetaproxyAvailabilityAdapter{ - holdingsLookupAdapter: adapter.CreateSruHoldingsLookupAdapter(http.DefaultClient, []string{metaproxyUrl}, config.Address, queryBuilder, holdingsParser, "marcxml"), - } - return a, nil -} - -func (a *MetaproxyAvailabilityAdapter) Lookup(params adapter.LookupParams) ([]adapter.Holding, string, error) { - return a.holdingsLookupAdapter.Lookup(params) -} diff --git a/broker/availability/availability_sru.go b/broker/availability/availability_sru.go deleted file mode 100644 index b61cb874..00000000 --- a/broker/availability/availability_sru.go +++ /dev/null @@ -1,31 +0,0 @@ -package availability - -import ( - "net/http" - - "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/common" - "github.com/indexdata/crosslink/directory" -) - -type SruAvailabilityAdapter struct { - holdingsLookupAdapter adapter.LookupAdapter -} - -func NewSruAvailabilityAdapter(ctx common.ExtendedContext, config directory.SruConfig, queryBuilder adapter.LookupQueryBuilder, holdingsParser adapter.HoldingsParser) (adapter.LookupAdapter, error) { - var recordSchema string - if config.RecordSchema != nil { - recordSchema = *config.RecordSchema - } - if recordSchema == "" { - recordSchema = "marcxml" // default to marcxml if not specified - } - a := &SruAvailabilityAdapter{ - holdingsLookupAdapter: adapter.CreateSruHoldingsLookupAdapter(http.DefaultClient, []string{config.Address}, "", queryBuilder, holdingsParser, recordSchema), - } - return a, nil -} - -func (a *SruAvailabilityAdapter) Lookup(params adapter.LookupParams) ([]adapter.Holding, string, error) { - return a.holdingsLookupAdapter.Lookup(params) -} diff --git a/broker/availability/availability_zoom_nocgo.go b/broker/availability/availability_zoom_nocgo.go deleted file mode 100644 index ae9855db..00000000 --- a/broker/availability/availability_zoom_nocgo.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build !cgo - -package availability - -import ( - "fmt" - - "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/common" - "github.com/indexdata/crosslink/directory" -) - -func cgoEnabled() bool { return false } - -type ZoomAvailabilityAdapter struct{} - -func NewZoomAvailabilityAdapter(ctx common.ExtendedContext, config directory.Z3950Config, queryBuilder adapter.LookupQueryBuilder, holdingsParser adapter.HoldingsParser) (adapter.LookupAdapter, error) { - return nil, fmt.Errorf("ZOOM availability adapter requires cgo, but cgo is not enabled") -} diff --git a/broker/holdings/adapter_metaproxy.go b/broker/holdings/adapter_metaproxy.go new file mode 100644 index 00000000..e856c835 --- /dev/null +++ b/broker/holdings/adapter_metaproxy.go @@ -0,0 +1,22 @@ +package holdings + +import ( + "net/http" + + "github.com/indexdata/crosslink/directory" +) + +type MetaproxyAvailabilityAdapter struct { + holdingsLookupAdapter LookupAdapter +} + +func NewMetaproxyAvailabilityAdapter(config directory.ZoomConfig, metaproxyUrl string, queryBuilder LookupQueryBuilder, holdingsParser HoldingsParser) (LookupAdapter, error) { + a := &MetaproxyAvailabilityAdapter{ + holdingsLookupAdapter: CreateSruHoldingsLookupAdapter(http.DefaultClient, []string{metaproxyUrl}, config.Address, queryBuilder, holdingsParser, "marcxml"), + } + return a, nil +} + +func (a *MetaproxyAvailabilityAdapter) Lookup(params LookupParams) ([]Holding, string, error) { + return a.holdingsLookupAdapter.Lookup(params) +} diff --git a/broker/availability/availability_mock.go b/broker/holdings/adapter_mock.go similarity index 73% rename from broker/availability/availability_mock.go rename to broker/holdings/adapter_mock.go index ebf4e331..ce30f11a 100644 --- a/broker/availability/availability_mock.go +++ b/broker/holdings/adapter_mock.go @@ -1,21 +1,20 @@ -package availability +package holdings import ( "fmt" "strings" - "github.com/indexdata/crosslink/broker/adapter" "github.com/indexdata/crosslink/directory" ) type MockAvailabilityAdapter struct { Err error - Holdings []adapter.Holding + Holdings []Holding } -func NewMockAvailabilityAdapter(config directory.AvailabilityConfig) (adapter.LookupAdapter, error) { - if config.Z3950 != nil && config.Z3950.Options != nil { - options := *config.Z3950.Options +func NewMockAvailabilityAdapter(config directory.AvailabilityConfig) (LookupAdapter, error) { + if config.Zoom != nil && config.Zoom.Options != nil { + options := *config.Zoom.Options // For testing purposes, we can use the presence of "adapter-error" in options to trigger an error response if val, ok := options["adapter-error"]; ok && strings.ToLower(val) == "true" { return nil, fmt.Errorf("mock error triggered by config") @@ -28,7 +27,7 @@ func NewMockAvailabilityAdapter(config directory.AvailabilityConfig) (adapter.Lo } if val, ok := options["location"]; ok { return &MockAvailabilityAdapter{ - Holdings: []adapter.Holding{ + Holdings: []Holding{ { Location: val, }, @@ -39,7 +38,7 @@ func NewMockAvailabilityAdapter(config directory.AvailabilityConfig) (adapter.Lo return &MockAvailabilityAdapter{}, nil } -func (a *MockAvailabilityAdapter) Lookup(params adapter.LookupParams) ([]adapter.Holding, string, error) { +func (a *MockAvailabilityAdapter) Lookup(params LookupParams) ([]Holding, string, error) { if a.Err != nil { return nil, "", a.Err } diff --git a/broker/adapter/mock_holdings.go b/broker/holdings/adapter_mock_shared.go similarity index 92% rename from broker/adapter/mock_holdings.go rename to broker/holdings/adapter_mock_shared.go index ad95f34e..ab1d28ea 100644 --- a/broker/adapter/mock_holdings.go +++ b/broker/holdings/adapter_mock_shared.go @@ -1,4 +1,4 @@ -package adapter +package holdings import ( "errors" @@ -10,6 +10,7 @@ import ( type MockHoldingsLookupAdapter struct { } +// the original mock holdings adapter that we used for shared index testing func (m *MockHoldingsLookupAdapter) Lookup(params LookupParams) ([]Holding, string, error) { ids := strings.Split(params.Identifier, ";") i := 1 diff --git a/broker/adapter/sru_holdings.go b/broker/holdings/adapter_sru.go similarity index 81% rename from broker/adapter/sru_holdings.go rename to broker/holdings/adapter_sru.go index 8008f1a8..3a29de20 100644 --- a/broker/adapter/sru_holdings.go +++ b/broker/holdings/adapter_sru.go @@ -1,4 +1,4 @@ -package adapter +package holdings import ( "encoding/xml" @@ -8,6 +8,7 @@ import ( "net/url" "github.com/indexdata/cql-go/cqlbuilder" + "github.com/indexdata/crosslink/directory" "github.com/indexdata/crosslink/httpclient" "github.com/indexdata/crosslink/sru" "github.com/indexdata/crosslink/sru/diag" @@ -22,13 +23,22 @@ type SruHoldingsLookupAdapter struct { recordSchema string } -const isilPrefix = "ISIL:" - func CreateSruHoldingsLookupAdapter(client *http.Client, sruUrl []string, xTarget string, queryBuilder LookupQueryBuilder, parser HoldingsParser, recordSchema string) LookupAdapter { return &SruHoldingsLookupAdapter{client: client, sruUrl: sruUrl, queryBuilder: queryBuilder, holdingsParser: parser, xTarget: xTarget, recordSchema: recordSchema} } -func (s *SruHoldingsLookupAdapter) parseRecord(record *sru.RecordDefinition, holdings *[]Holding) error { +func NewSruAvailabilityAdapter(config directory.SruConfig, queryBuilder LookupQueryBuilder, holdingsParser HoldingsParser) (LookupAdapter, error) { + var recordSchema string + if config.RecordSchema != nil { + recordSchema = *config.RecordSchema + } + if recordSchema == "" { + recordSchema = "marcxml" // default to marcxml if not specified + } + return CreateSruHoldingsLookupAdapter(http.DefaultClient, []string{config.Address}, "", queryBuilder, holdingsParser, recordSchema), nil +} + +func (s *SruHoldingsLookupAdapter) parseRecord(record *sru.RecordDefinition, params LookupParams, holdings *[]Holding) error { if record.RecordXMLEscaping != nil && *record.RecordXMLEscaping != sru.RecordXMLEscapingDefinitionXml { return fmt.Errorf("unsupported RecordXMLEscaping: %s", *record.RecordXMLEscaping) } @@ -48,7 +58,7 @@ func (s *SruHoldingsLookupAdapter) parseRecord(record *sru.RecordDefinition, hol return fmt.Errorf("unsupported RecordSchema: %s", record.RecordSchema) } - ret, err := s.holdingsParser.Parse(record.RecordData.XMLContent) + ret, err := s.holdingsParser.Parse(record.RecordData.XMLContent, params) if err != nil { return fmt.Errorf("parsing holdings failed: %s", err.Error()) } @@ -64,7 +74,7 @@ func encodeCqlSearchClause(field string, value string) (string, error) { return cqlQuery.String(), nil } -func (s *SruHoldingsLookupAdapter) search(sruUrl string, query string) ([]Holding, string, error) { +func (s *SruHoldingsLookupAdapter) search(sruUrl string, params LookupParams, query string) ([]Holding, string, error) { var sruResponse sru.SearchRetrieveResponse query = "?maximumRecords=1000&recordSchema=" + url.QueryEscape(s.recordSchema) + "&" + query if s.xTarget != "" { @@ -85,7 +95,7 @@ func (s *SruHoldingsLookupAdapter) search(sruUrl string, query string) ([]Holdin var holdings []Holding if sruResponse.Records != nil { for _, record := range sruResponse.Records.Record { - err := s.parseRecord(&record, &holdings) + err := s.parseRecord(&record, params, &holdings) if err != nil { return nil, query, err } @@ -103,7 +113,7 @@ func (s *SruHoldingsLookupAdapter) getHoldings(sruUrl string, params LookupParam var queryParams string for _, cql := range cqlList { sruQuery := "query=" + url.QueryEscape(cql) - holdings, queryParams, err = s.search(sruUrl, sruQuery) + holdings, queryParams, err = s.search(sruUrl, params, sruQuery) if err != nil { return nil, queryParams, err } @@ -113,7 +123,7 @@ func (s *SruHoldingsLookupAdapter) getHoldings(sruUrl string, params LookupParam } for _, pqf := range pqfList { sruQuery := "x-pquery=" + url.QueryEscape(pqf) - holdings, queryParams, err = s.search(sruUrl, sruQuery) + holdings, queryParams, err = s.search(sruUrl, params, sruQuery) if err != nil { return nil, queryParams, err } diff --git a/broker/availability/availability_zoom.go b/broker/holdings/adapter_zoom.go similarity index 52% rename from broker/availability/availability_zoom.go rename to broker/holdings/adapter_zoom.go index 71ca801b..c55c71cc 100644 --- a/broker/availability/availability_zoom.go +++ b/broker/holdings/adapter_zoom.go @@ -1,12 +1,10 @@ //go:build cgo -package availability +package holdings import ( "fmt" - "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/common" "github.com/indexdata/crosslink/directory" "github.com/indexdata/crosslink/zoom" ) @@ -16,15 +14,16 @@ func cgoEnabled() bool { return true } type ZoomAvailabilityAdapter struct { zurl string options zoom.Options - holdingsParser adapter.HoldingsParser - queryBuilder adapter.LookupQueryBuilder + holdingsParser HoldingsParser + queryBuilder LookupQueryBuilder } -func NewZoomAvailabilityAdapter(ctx common.ExtendedContext, config directory.Z3950Config, queryBuilder adapter.LookupQueryBuilder, holdingsParser adapter.HoldingsParser) (adapter.LookupAdapter, error) { +func NewZoomAvailabilityAdapter(config directory.ZoomConfig, queryBuilder LookupQueryBuilder, holdingsParser HoldingsParser) (LookupAdapter, error) { a := &ZoomAvailabilityAdapter{ // default options, can be overridden by config.Options options: zoom.Options{ "count": "10", + "presentChunks": "10", "preferredRecordSyntax": "usmarc", }, zurl: config.Address, @@ -39,14 +38,15 @@ func NewZoomAvailabilityAdapter(ctx common.ExtendedContext, config directory.Z39 return a, nil } -func (a *ZoomAvailabilityAdapter) searchRetrieve(conn *zoom.Connection, query string) ([]adapter.Holding, error) { +func (a *ZoomAvailabilityAdapter) searchRetrieve(params LookupParams, conn *zoom.Connection, query *zoom.Query) ([]Holding, error) { set, err := conn.Search(query) if err != nil { return nil, err } defer set.Close() - var avail []adapter.Holding - for i := 0; i < set.Count(); i++ { + var avail []Holding + limit := min(set.Count(), 100) // safety limit to avoid processing too many records + for i := 0; i < limit; i++ { rec, err := set.GetRecord(i) if err != nil { return nil, err @@ -59,7 +59,7 @@ func (a *ZoomAvailabilityAdapter) searchRetrieve(conn *zoom.Connection, query st if xmlBuffer == nil { continue } - holdings, err := a.holdingsParser.Parse(xmlBuffer) + holdings, err := a.holdingsParser.Parse(xmlBuffer, params) if err != nil { return nil, fmt.Errorf("failed to parse holdings from Z39.50 record: %w", err) } @@ -68,7 +68,7 @@ func (a *ZoomAvailabilityAdapter) searchRetrieve(conn *zoom.Connection, query st return avail, nil } -func (a *ZoomAvailabilityAdapter) Lookup(params adapter.LookupParams) ([]adapter.Holding, string, error) { +func (a *ZoomAvailabilityAdapter) Lookup(params LookupParams) ([]Holding, string, error) { conn := zoom.NewConnection(a.options) defer conn.Close() err := conn.Connect(a.zurl) @@ -79,20 +79,39 @@ func (a *ZoomAvailabilityAdapter) Lookup(params adapter.LookupParams) ([]adapter if err != nil { return nil, "", fmt.Errorf("failed to build query: %w", err) } - if len(cqlList) > 0 { - return nil, "", fmt.Errorf("Z39.50 server does not support CQL queries: %v", cqlList) - } - if len(pqfList) == 0 { + if len(pqfList) == 0 && len(cqlList) == 0 { return nil, "", fmt.Errorf("no valid query parameters provided") } for _, pqf := range pqfList { - avail, err := a.searchRetrieve(conn, pqf) + query, err := zoom.NewPqfQuery(pqf) + if err != nil { + return nil, pqf, fmt.Errorf("failed to create PQF query: %w", err) + } + avail, err := a.searchRetrieve(params, conn, query) + query.Close() if err != nil { - return nil, pqf, fmt.Errorf("failed to search Z39.50 server query: %s err %w", pqf, err) + return nil, pqf, fmt.Errorf("failed to search server with PQF: %s err %w", pqf, err) } if len(avail) > 0 { return avail, pqf, nil } } - return nil, pqfList[0], nil + for _, cql := range cqlList { + query, err := zoom.NewCqlQuery(cql) + if err != nil { + return nil, cql, fmt.Errorf("failed to create CQL query: %w", err) + } + avail, err := a.searchRetrieve(params, conn, query) + query.Close() + if err != nil { + return nil, cql, fmt.Errorf("failed to search server with CQL: %s err %w", cql, err) + } + if len(avail) > 0 { + return avail, cql, nil + } + } + if len(pqfList) > 0 { + return nil, pqfList[0], nil + } + return nil, cqlList[0], nil } diff --git a/broker/holdings/adapter_zoom_nocgo.go b/broker/holdings/adapter_zoom_nocgo.go new file mode 100644 index 00000000..476eec14 --- /dev/null +++ b/broker/holdings/adapter_zoom_nocgo.go @@ -0,0 +1,17 @@ +//go:build !cgo + +package holdings + +import ( + "fmt" + + "github.com/indexdata/crosslink/directory" +) + +func cgoEnabled() bool { return false } + +type ZoomAvailabilityAdapter struct{} + +func NewZoomAvailabilityAdapter(config directory.ZoomConfig, queryBuilder LookupQueryBuilder, holdingsParser HoldingsParser) (LookupAdapter, error) { + return nil, fmt.Errorf("ZOOM availability adapter requires cgo, but cgo is not enabled") +} diff --git a/broker/availability/availability_zoom_test.go b/broker/holdings/adapter_zoom_test.go similarity index 54% rename from broker/availability/availability_zoom_test.go rename to broker/holdings/adapter_zoom_test.go index f6f44fcc..46534cba 100644 --- a/broker/availability/availability_zoom_test.go +++ b/broker/holdings/adapter_zoom_test.go @@ -1,14 +1,12 @@ //go:build cgo -package availability +package holdings import ( "context" "os" "testing" - "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/common" "github.com/indexdata/crosslink/directory" zoomtestutil "github.com/indexdata/crosslink/testutil" "github.com/stretchr/testify/assert" @@ -36,18 +34,20 @@ func TestMain(m *testing.M) { } func TestLookupFoundMarc(t *testing.T) { - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) // target does not return holdings, we just use 010$a as fake location to verify that the record was parsed correctly config := directory.MarcParserConfig{ - MainField: adapter.NewString("010"), - LocationSubField: adapter.NewString("a"), + MainField: NewString("010"), + LocationSubField: NewString("a"), } - queryBuilder := adapter.NewQueryBuilderPqf(&directory.QueryConfig{ - Title: adapter.NewString("@attr 1=1016 {term}"), + pqfType := directory.Pqf + queryBuilder, err := NewQueryBuilderGen(&directory.QueryConfig{ + Title: NewString("@attr 1=1016 {term}"), + Type: &pqfType, }) - holdingsParser := adapter.NewMarcHoldingsParser(config) - aa, err := NewZoomAvailabilityAdapter(ctx, - directory.Z3950Config{ + assert.NoError(t, err) + holdingsParser := NewMarcHoldingsParser(config) + aa, err := NewZoomAvailabilityAdapter( + directory.ZoomConfig{ Address: containerHost + ":" + mappedPort + "/marc", Options: &map[string]string{ "count": "20", @@ -61,7 +61,7 @@ func TestLookupFoundMarc(t *testing.T) { assert.Equal(t, containerHost+":"+mappedPort+"/marc", aa.(*ZoomAvailabilityAdapter).zurl) assert.Equal(t, "20", aa.(*ZoomAvailabilityAdapter).options["count"]) - params := adapter.LookupParams{ + params := LookupParams{ Title: "Computer", } results, pqf, err := aa.Lookup(params) @@ -72,11 +72,14 @@ func TestLookupFoundMarc(t *testing.T) { } func TestLookupFoundOpac(t *testing.T) { - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) - queryBuilder := adapter.NewQueryBuilderPqf(nil) - holdingsParser := adapter.NewOpacHoldingsParser(directory.OpacParserConfig{}) - aa, err := NewZoomAvailabilityAdapter(ctx, - directory.Z3950Config{ + cqlType := directory.Cql + queryBuilder, err := NewQueryBuilderGen(&directory.QueryConfig{ + Type: &cqlType, + }) + assert.NoError(t, err) + holdingsParser := NewOpacHoldingsParser(directory.OpacParserConfig{}) + aa, err := NewZoomAvailabilityAdapter( + directory.ZoomConfig{ Address: containerHost + ":" + mappedPort + "/marc", Options: &map[string]string{ "preferredRecordSyntax": "opac", @@ -89,23 +92,23 @@ func TestLookupFoundOpac(t *testing.T) { assert.Equal(t, containerHost+":"+mappedPort+"/marc", aa.(*ZoomAvailabilityAdapter).zurl) assert.Equal(t, "10", aa.(*ZoomAvailabilityAdapter).options["count"]) - params := adapter.LookupParams{ + params := LookupParams{ Title: "Computer", } - results, pqf, err := aa.Lookup(params) + results, cql, err := aa.Lookup(params) assert.NoError(t, err) assert.Len(t, results, 42) assert.Contains(t, results[0].ItemId, "test__000000001_") assert.Contains(t, results[1].ItemId, "test__000000002_") - assert.Equal(t, "@attr 1=4 \"Computer\"", pqf) + assert.Equal(t, "title = \"Computer\"", cql) } -func TestLookupDiagnostics(t *testing.T) { - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) - queryBuilder := adapter.NewQueryBuilderPqf(nil) - holdingsParser := adapter.NewMarcHoldingsParser(directory.MarcParserConfig{}) - aa, err := NewZoomAvailabilityAdapter(ctx, - directory.Z3950Config{ +func TestLookupDiagnosticPQF(t *testing.T) { + queryBuilder, err := NewQueryBuilderGen(nil) + assert.NoError(t, err) + holdingsParser := NewMarcHoldingsParser(directory.MarcParserConfig{}) + aa, err := NewZoomAvailabilityAdapter( + directory.ZoomConfig{ Address: containerHost + ":" + mappedPort + "/marc", Options: &map[string]string{ "preferredRecordSyntax": "danmarc", @@ -118,26 +121,56 @@ func TestLookupDiagnostics(t *testing.T) { assert.Equal(t, "localhost:"+mappedPort+"/marc", aa.(*ZoomAvailabilityAdapter).zurl) assert.Equal(t, "danmarc", aa.(*ZoomAvailabilityAdapter).options["preferredRecordSyntax"]) - params := adapter.LookupParams{Identifier: "1234"} + params := LookupParams{Identifier: "1234"} _, pqf, err := aa.Lookup(params) assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to search server with PQF") assert.Contains(t, err.Error(), "Record syntax not supported") assert.Equal(t, "@attr 1=12 \"1234\"", pqf) } +func TestLookupDiagnosticCql(t *testing.T) { + cqlType := directory.Cql + queryBuilder, err := NewQueryBuilderGen(&directory.QueryConfig{ + Type: &cqlType, + }) + assert.NoError(t, err) + holdingsParser := NewMarcHoldingsParser(directory.MarcParserConfig{}) + aa, err := NewZoomAvailabilityAdapter( + directory.ZoomConfig{ + Address: containerHost + ":" + mappedPort + "/marc", + Options: &map[string]string{ + "preferredRecordSyntax": "danmarc", + }, + }, + queryBuilder, + holdingsParser, + ) + assert.NoError(t, err) + assert.Equal(t, "localhost:"+mappedPort+"/marc", aa.(*ZoomAvailabilityAdapter).zurl) + assert.Equal(t, "danmarc", aa.(*ZoomAvailabilityAdapter).options["preferredRecordSyntax"]) + + params := LookupParams{Identifier: "1234"} + _, cql, err := aa.Lookup(params) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to search server with CQL") + assert.Contains(t, err.Error(), "Record syntax not supported") + assert.Equal(t, "rec.id = \"1234\"", cql) +} + func TestConnectFailure(t *testing.T) { - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) - queryBuilder := adapter.NewQueryBuilderPqf(nil) - holdingsParser := adapter.NewMarcHoldingsParser(directory.MarcParserConfig{}) - aa, err := NewZoomAvailabilityAdapter(ctx, - directory.Z3950Config{ + queryBuilder, err := NewQueryBuilderGen(nil) + assert.NoError(t, err) + holdingsParser := NewMarcHoldingsParser(directory.MarcParserConfig{}) + aa, err := NewZoomAvailabilityAdapter( + directory.ZoomConfig{ Address: "", }, queryBuilder, holdingsParser, ) assert.NoError(t, err) - params := adapter.LookupParams{} + params := LookupParams{} _, _, err = aa.Lookup(params) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to connect to Z39.50 server") diff --git a/broker/holdings/create_holdings.go b/broker/holdings/create_holdings.go new file mode 100644 index 00000000..a25f0c2d --- /dev/null +++ b/broker/holdings/create_holdings.go @@ -0,0 +1,75 @@ +package holdings + +import ( + "fmt" + "net/http" + "strings" + + "github.com/indexdata/crosslink/directory" +) + +const ( + HoldingsAdapter string = "HOLDINGS_ADAPTER" + HoldingsSruURL string = "HOLDINGS_SRU_URL" + HoldingsIsxnLookup string = "HOLDINGS_ISXN_LOOKUP" + HoldingsFormat string = "HOLDINGS_FORMAT" + HoldingsFormatReservoir string = "reservoir" + HoldingsFormatMarc21Plus1 string = "MARC-21plus-1" + HoldingsFormatMarc string = "marc" + HoldingsFormatOpac string = "opac" +) + +func getParserFormat(format string) (HoldingsParser, error) { + switch format { + case HoldingsFormatReservoir: + return NewReservoirHoldingsParser(), nil + case HoldingsFormatMarc21Plus1: + return NewMarc21Plus1HoldingsParser(), nil + case HoldingsFormatMarc: + return NewMarcHoldingsParser(directory.MarcParserConfig{}), nil + case HoldingsFormatOpac: + return NewOpacHoldingsParser(directory.OpacParserConfig{}), nil + default: + return nil, fmt.Errorf("bad value for %s: %s", HoldingsFormat, format) + } +} + +func CreateHoldingsLookupShared(cfg map[string]any) (LookupAdapter, error) { + holdingsAdapterVal, ok := cfg[HoldingsAdapter].(string) + if !ok { + return nil, fmt.Errorf("missing value for %s", HoldingsAdapter) + } + if holdingsAdapterVal == "consortia" { + // consortia must be determined per-peer, so we can't create a single adapter for all peers + return nil, nil + } + if holdingsAdapterVal == "sru" { + sruURLVal, ok := cfg[HoldingsSruURL].(string) + if !ok { + return nil, fmt.Errorf("missing value for %s", HoldingsSruURL) + } + _, ok = cfg[HoldingsIsxnLookup] + if !ok { + return nil, fmt.Errorf("missing value for %s", HoldingsIsxnLookup) + } + // ideally this should be per-SRU server and not for all + isxnLookup, ok := cfg[HoldingsIsxnLookup].(bool) + if !ok { + return nil, fmt.Errorf("invalid value for %s: %v", HoldingsIsxnLookup, cfg[HoldingsIsxnLookup]) + } + queryBuilder := QueryBuilderIsxn{isxn: isxnLookup} + format, ok := cfg[HoldingsFormat].(string) + if !ok { + return nil, fmt.Errorf("missing value for %s", HoldingsFormat) + } + parser, err := getParserFormat(format) + if err != nil { + return nil, err + } + return CreateSruHoldingsLookupAdapter(http.DefaultClient, strings.Split(sruURLVal, ","), "", &queryBuilder, parser, "marcxml"), nil + } + if holdingsAdapterVal == "mock" { + return &MockHoldingsLookupAdapter{}, nil + } + return nil, fmt.Errorf("bad value for %s", HoldingsAdapter) +} diff --git a/broker/holdings/create_holdings_test.go b/broker/holdings/create_holdings_test.go new file mode 100644 index 00000000..9c074d20 --- /dev/null +++ b/broker/holdings/create_holdings_test.go @@ -0,0 +1,63 @@ +package holdings + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateHoldings(t *testing.T) { + m := make(map[string]any) + + _, err := CreateHoldingsLookupShared(m) + assert.Error(t, err) + assert.ErrorContains(t, err, "missing value for HOLDINGS_ADAPTER") + + m[HoldingsAdapter] = "sru" + + _, err = CreateHoldingsLookupShared(m) + assert.ErrorContains(t, err, "missing value for HOLDINGS_SRU_URL") + + m[HoldingsSruURL] = "http://example.com" + _, err = CreateHoldingsLookupShared(m) + assert.Error(t, err) + assert.ErrorContains(t, err, "missing value for HOLDINGS_ISXN_LOOKUP") + + m[HoldingsIsxnLookup] = "fake" + _, err = CreateHoldingsLookupShared(m) + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid value for HOLDINGS_ISXN_LOOKUP") + + m[HoldingsIsxnLookup] = true + m[HoldingsFormat] = "reservoir" + _, err = CreateHoldingsLookupShared(m) + assert.NoError(t, err) + + m[HoldingsFormat] = "MARC-21plus-1" + _, err = CreateHoldingsLookupShared(m) + assert.NoError(t, err) + + m[HoldingsFormat] = "marc" + _, err = CreateHoldingsLookupShared(m) + assert.NoError(t, err) + + m[HoldingsFormat] = "opac" + _, err = CreateHoldingsLookupShared(m) + assert.NoError(t, err) + + m[HoldingsFormat] = "other" + _, err = CreateHoldingsLookupShared(m) + assert.ErrorContains(t, err, "bad value for HOLDINGS_FORMAT: other") + + m[HoldingsFormat] = true + _, err = CreateHoldingsLookupShared(m) + assert.ErrorContains(t, err, "missing value for HOLDINGS_FORMAT") + + m[HoldingsAdapter] = "mock" + _, err = CreateHoldingsLookupShared(m) + assert.NoError(t, err) + + m[HoldingsAdapter] = "other" + _, err = CreateHoldingsLookupShared(m) + assert.ErrorContains(t, err, "bad value for HOLDINGS_ADAPTER") +} diff --git a/broker/holdings/creator.go b/broker/holdings/creator.go new file mode 100644 index 00000000..4f857412 --- /dev/null +++ b/broker/holdings/creator.go @@ -0,0 +1,9 @@ +package holdings + +import ( + "github.com/indexdata/crosslink/broker/ill_db" +) + +type AvailabilityCreator interface { + GetAdapter(peer ill_db.Peer) (LookupAdapter, error) +} diff --git a/broker/availability/availability_creator_impl.go b/broker/holdings/creator_impl.go similarity index 57% rename from broker/availability/availability_creator_impl.go rename to broker/holdings/creator_impl.go index 4735decb..e28c7987 100644 --- a/broker/availability/availability_creator_impl.go +++ b/broker/holdings/creator_impl.go @@ -1,10 +1,8 @@ -package availability +package holdings import ( "fmt" - "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/common" "github.com/indexdata/crosslink/broker/ill_db" "github.com/indexdata/crosslink/directory" ) @@ -27,20 +25,26 @@ func NewAvailabilityCreator(mode string, metaproxyUrl string) AvailabilityCreato } } -func getParser(config *directory.ParserConfig) (adapter.HoldingsParser, error) { +func getParser(config *directory.ParserConfig) (HoldingsParser, error) { if config == nil { - return adapter.NewMarcHoldingsParser(directory.MarcParserConfig{}), nil // default to marc parser + return NewMarcHoldingsParser(directory.MarcParserConfig{}), nil // default to marc parser } if config.Marc != nil { - return adapter.NewMarcHoldingsParser(*config.Marc), nil + return NewMarcHoldingsParser(*config.Marc), nil } if config.Opac != nil { - return adapter.NewOpacHoldingsParser(*config.Opac), nil + return NewOpacHoldingsParser(*config.Opac), nil } - return nil, fmt.Errorf("availabilityConfig.parserConfig must set marc or opac properties") + if config.Reservoir != nil { + return NewReservoirHoldingsParser(), nil + } + if config.Marc21plus1 != nil { + return NewMarc21Plus1HoldingsParser(), nil + } + return nil, fmt.Errorf("availabilityConfig.parserConfig must set marc, opac, reservoir, or marc21plus1 properties") } -func (c *AvailabilityCreatorImpl) GetAdapter(ctx common.ExtendedContext, peer ill_db.Peer) (adapter.LookupAdapter, error) { +func (c *AvailabilityCreatorImpl) GetAdapter(peer ill_db.Peer) (LookupAdapter, error) { entry := peer.CustomData config := entry.AvailabilityConfig if config == nil { @@ -53,23 +57,25 @@ func (c *AvailabilityCreatorImpl) GetAdapter(ctx common.ExtendedContext, peer il if err != nil { return nil, err } + queryBuilder, err := NewQueryBuilderGen(config.QueryConfig) + if err != nil { + return nil, err + } if config.Sru != nil { - queryBuilder := adapter.NewQueryBuilderCql(config.QueryConfig) - return NewSruAvailabilityAdapter(ctx, *config.Sru, queryBuilder, holdingsParser) + return NewSruAvailabilityAdapter(*config.Sru, queryBuilder, holdingsParser) } - if config.Z3950 != nil { - queryBuilder := adapter.NewQueryBuilderPqf(config.QueryConfig) + if config.Zoom != nil { switch c.mode { case AvailabilityAdapterMetaproxy: if c.metaproxyUrl == "" { return nil, fmt.Errorf("when using %s availability adapter, %s environment variable must be set", AvailabilityAdapterMetaproxy, "METAPROXY_URL") } - return NewMetaproxyAvailabilityAdapter(ctx, *config.Z3950, c.metaproxyUrl, queryBuilder, holdingsParser) + return NewMetaproxyAvailabilityAdapter(*config.Zoom, c.metaproxyUrl, queryBuilder, holdingsParser) case AvailabilityAdapterZoom: - return NewZoomAvailabilityAdapter(ctx, *config.Z3950, queryBuilder, holdingsParser) + return NewZoomAvailabilityAdapter(*config.Zoom, queryBuilder, holdingsParser) default: return nil, fmt.Errorf("unsupported availability adapter type: %s", c.mode) } } - return nil, fmt.Errorf("must specify either sru or z3950 properties for availability adapter type") + return nil, fmt.Errorf("must specify either sru or zoom properties for availability adapter type") } diff --git a/broker/availability/availability_creator_test.go b/broker/holdings/creator_test.go similarity index 67% rename from broker/availability/availability_creator_test.go rename to broker/holdings/creator_test.go index 3916a303..aeb40b1a 100644 --- a/broker/availability/availability_creator_test.go +++ b/broker/holdings/creator_test.go @@ -1,11 +1,8 @@ -package availability +package holdings import ( - "context" "testing" - "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/common" "github.com/indexdata/crosslink/broker/ill_db" "github.com/indexdata/crosslink/directory" "github.com/stretchr/testify/assert" @@ -13,18 +10,16 @@ import ( func TestGetAdapterEmpty(t *testing.T) { creator := NewAvailabilityCreator(AvailabilityAdapterZoom, "") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) peer := ill_db.Peer{} - aa, err := creator.GetAdapter(ctx, peer) + aa, err := creator.GetAdapter(peer) assert.NoError(t, err) assert.Nil(t, aa) } func TestGetAdapterOtherNoConfig(t *testing.T) { creator := NewAvailabilityCreator("other", "") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) peer := ill_db.Peer{} - aa, err := creator.GetAdapter(ctx, peer) + aa, err := creator.GetAdapter(peer) assert.NoError(t, err) assert.Nil(t, aa) } @@ -32,14 +27,14 @@ func TestGetAdapterOtherNoConfig(t *testing.T) { func TestParserNil(t *testing.T) { parser, err := getParser(nil) assert.NoError(t, err) - assert.IsType(t, &adapter.MarcHoldingsParser{}, parser) + assert.IsType(t, &MarcHoldingsParser{}, parser) } func TestParserMissing(t *testing.T) { parserConfig := &directory.ParserConfig{} _, err := getParser(parserConfig) assert.Error(t, err) - assert.Contains(t, err.Error(), "must set marc or opac properties") + assert.Contains(t, err.Error(), "must set marc") } func TestParserMarc(t *testing.T) { @@ -48,7 +43,7 @@ func TestParserMarc(t *testing.T) { } parser, err := getParser(parserConfig) assert.NoError(t, err) - assert.IsType(t, &adapter.MarcHoldingsParser{}, parser) + assert.IsType(t, &MarcHoldingsParser{}, parser) } func TestParserOpac(t *testing.T) { @@ -57,70 +52,66 @@ func TestParserOpac(t *testing.T) { } parser, err := getParser(parserConfig) assert.NoError(t, err) - assert.IsType(t, &adapter.OpacHoldingsParser{}, parser) + assert.IsType(t, &OpacHoldingsParser{}, parser) } func TestGetAdapterBadParser(t *testing.T) { creator := NewAvailabilityCreator(AvailabilityAdapterZoom, "") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) peer := ill_db.Peer{ CustomData: directory.Entry{ AvailabilityConfig: &directory.AvailabilityConfig{ - Z3950: &directory.Z3950Config{ + Zoom: &directory.ZoomConfig{ Address: "a", }, ParserConfig: &directory.ParserConfig{}, }, }, } - _, err := creator.GetAdapter(ctx, peer) + _, err := creator.GetAdapter(peer) assert.Error(t, err) - assert.Contains(t, err.Error(), "must set marc or opac properties") + assert.Contains(t, err.Error(), "must set marc") } func TestGetAdapterOtherWithConfig(t *testing.T) { creator := NewAvailabilityCreator("other", "") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) peer := ill_db.Peer{ CustomData: directory.Entry{ AvailabilityConfig: &directory.AvailabilityConfig{ - Z3950: &directory.Z3950Config{ + Zoom: &directory.ZoomConfig{ Address: "a", }, }, }, } - _, err := creator.GetAdapter(ctx, peer) + _, err := creator.GetAdapter(peer) assert.Error(t, err) assert.Contains(t, err.Error(), "unsupported availability adapter type: other") } func TestGetAdapterMissingProperties(t *testing.T) { creator := NewAvailabilityCreator("zoom", "") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) peer := ill_db.Peer{ CustomData: directory.Entry{ AvailabilityConfig: &directory.AvailabilityConfig{}, }, } - _, err := creator.GetAdapter(ctx, peer) + _, err := creator.GetAdapter(peer) assert.Error(t, err) - assert.Contains(t, err.Error(), "must specify either sru or z3950 properties") + assert.Contains(t, err.Error(), "must specify either sru or zoom properties") } func TestGetAdapterMock(t *testing.T) { peer := ill_db.Peer{ CustomData: directory.Entry{ AvailabilityConfig: &directory.AvailabilityConfig{ - Z3950: &directory.Z3950Config{ + Zoom: &directory.ZoomConfig{ Address: "a", }, }, }, } creator := NewAvailabilityCreator(AvailabilityAdapterMock, "") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) - aa, err := creator.GetAdapter(ctx, peer) + aa, err := creator.GetAdapter(peer) assert.NoError(t, err) assert.IsType(t, &MockAvailabilityAdapter{}, aa) } @@ -129,15 +120,14 @@ func TestGetAdapterZoom(t *testing.T) { peer := ill_db.Peer{ CustomData: directory.Entry{ AvailabilityConfig: &directory.AvailabilityConfig{ - Z3950: &directory.Z3950Config{ + Zoom: &directory.ZoomConfig{ Address: "a", }, }, }, } creator := NewAvailabilityCreator(AvailabilityAdapterZoom, "") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) - aa, err := creator.GetAdapter(ctx, peer) + aa, err := creator.GetAdapter(peer) if !cgoEnabled() { assert.Error(t, err) assert.Contains(t, err.Error(), "requires cgo") @@ -152,15 +142,14 @@ func TestGetAdapterMetaproxy(t *testing.T) { peer := ill_db.Peer{ CustomData: directory.Entry{ AvailabilityConfig: &directory.AvailabilityConfig{ - Z3950: &directory.Z3950Config{ + Zoom: &directory.ZoomConfig{ Address: "a", }, }, }, } creator := NewAvailabilityCreator(AvailabilityAdapterMetaproxy, "http://metaproxy.indexdata.com") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) - aa, err := creator.GetAdapter(ctx, peer) + aa, err := creator.GetAdapter(peer) assert.NoError(t, err) assert.IsType(t, &MetaproxyAvailabilityAdapter{}, aa) } @@ -169,15 +158,14 @@ func TestGetAdapterMetaproxyMissingProxy(t *testing.T) { peer := ill_db.Peer{ CustomData: directory.Entry{ AvailabilityConfig: &directory.AvailabilityConfig{ - Z3950: &directory.Z3950Config{ + Zoom: &directory.ZoomConfig{ Address: "a", }, }, }, } creator := NewAvailabilityCreator(AvailabilityAdapterMetaproxy, "") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) - _, err := creator.GetAdapter(ctx, peer) + _, err := creator.GetAdapter(peer) assert.Error(t, err) assert.Contains(t, err.Error(), "METAPROXY_URL") } @@ -193,8 +181,7 @@ func TestGetAdapterSRU(t *testing.T) { }, } creator := NewAvailabilityCreator(AvailabilityAdapterZoom, "") - ctx := common.CreateExtCtxWithArgs(context.Background(), nil) - aa, err := creator.GetAdapter(ctx, peer) + aa, err := creator.GetAdapter(peer) assert.NoError(t, err) - assert.IsType(t, &SruAvailabilityAdapter{}, aa) + assert.IsType(t, &SruHoldingsLookupAdapter{}, aa) } diff --git a/broker/holdings/gvi_holdings_test.go b/broker/holdings/gvi_holdings_test.go new file mode 100644 index 00000000..f347a4db --- /dev/null +++ b/broker/holdings/gvi_holdings_test.go @@ -0,0 +1,436 @@ +package holdings + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/indexdata/crosslink/broker/ill_db" + "github.com/indexdata/crosslink/directory" + "github.com/stretchr/testify/assert" +) + +const GviResponse = ` + + 1.1 + 1 + + + marcxml + xml + + + 06947cam a2200961 c 4500 + 1795329181 + DE-627 + 20251226201436.0 + cr uuu---uuuuu + 220311s2022 gw |||||o 00| ||fre c + + 9783428585014 + 978-3-428-58501-4 + + + 10.3790/978-3-428-58501-4 + doi + + + (DE-627)1795329181 + + + (DE-599)KEP076913368 + + + (OCoLC)1304799781 + + + (DUH)9783428585014 + + + (DE-627-1)076913368 + + + DE-627 + ger + DE-627 + rda + + + fre + ger + + + XA-DE-BE + + + BF531 + + + BF + lcco + + + B + lcco + + + 128.37 + SEPA + + + 152.4 + SEPA + + + CV 2500 + DE-Ofb1/22 + rvk + (DE-625)rvk/19153: + + + CP 3200 + DE-Ofb1/22 + rvk + (DE-625)rvk/18977: + + + Les émotions créatives + sous la direction de Damien Ehrhardt, Hélène Fleury, Soraya Nour Sckell + + + Berlin + Duncker & Humblot + [2022] + + + © 2022 + + + 1 Online-Ressource (225 Seiten) + + + Text + txt + rdacontent + + + Computermedien + c + rdamedia + + + Online-Ressource + cr + rdacarrier + + + Beiträge zur politischen Wissenschaft + Band 199 + + + Online resource; title from title screen (viewed March 9, 2022) + + + L’importance du rôle des émotions dans la connaissance conduit à voir en elles bien davantage qu’un facteur perturbateur. Leur pertinence cognitive, de plus en plus reconnue par les sciences (naturelles, sociales, humaines…), consacre l’importance d’un tournant émotionnel (emotional turn). Les émotions constituent aussi de puissants moteurs de créativité et d’innovation, cruciaux dans la construction des formations socioculturelles. Les textes rassemblés dans le présent volume, dans une perspective résolument interdisciplinaire, traitent d’émotions puissamment agissantes dans l’existence, à la convergence des échelles individuelle et collective. Les deux premières parties s’interrogent sur la spécificité des émotions humainement vécues dans leurs interactions expérimentées via le corps et la raison. Les deux dernières parties abordent les émotions à une plus large échelle : celle des champs culturel et politique. / »Creative Emotions«: The scientific recognition of the cognitive significance of emotions confirms the importance of the emotional turn. Beyond this cognitive dimension, emotions are also motors of creativity, and crucial in the construction of socio-cultural configurations. The interdisciplinary texts gathered in this volume analyse how emotions act in the existence between individual and collective scales. They question the emotions in their interactions via the body and the reason, as well as in the cultural and political fields. + + + Emotions + Congresses + DLC + + + Emotions (Philosophy) + Congresses + DLC + + + Emotions and cognition + Congresses + DLC + + + Creation (Literary, artistic, etc.) + Congresses + DLC + + + Émotions (Philosophie) - Congrès + + + Émotions et cognition - Congrès + + + Creation (Literary, artistic, etc.) + + + Emotions + + + Emotions and cognition + + + Emotions (Philosophy) + + + Conference papers and proceedings + + + Emotionen + + + Kreativität + + + Wissensbildung + + + s + (DE-588)4138031-9 + (DE-627)105645575 + (DE-576)209682647 + Ideengeschichte + gnd + + + s + (DE-588)4019702-5 + (DE-627)106320602 + (DE-576)208930418 + Gefühl + gnd + + + s + (DE-588)4032903-3 + (DE-627)106259733 + (DE-576)208999248 + Kreativität + gnd + + + s + (DE-588)4073586-2 + (DE-627)106092022 + (DE-576)209190922 + Kognitive Psychologie + gnd + + + (DE-627) + + + Ehrhardt, Damien + 1969- + HerausgeberIn + (DE-588)136878849 + (DE-627)588319880 + (DE-576)301326533 + edt + + + Fleury, Hélène + HerausgeberIn + (DE-588)1252713592 + (DE-627)1794174060 + edt + + + Nour, Soraya + HerausgeberIn + (DE-588)1018682007 + (DE-627)682873136 + (DE-576)356209547 + edt + + + 9783428185016 + + + Erscheint auch als + Druck-Ausgabe + Les émotions créatives + Berlin : Duncker & Humblot, 2022 + 225 Seiten + (DE-627)1795172681 + 9783428185016 + 3428185013 + + + Beiträge zur politischen Wissenschaft + Band 199 + 199 + (DE-627)670631469 + (DE-576)47762409X + (DE-600)2633572-4 + am + + + https://elibrary.duncker-humblot.com/9783428585014 + X:DUH + Verlag + lizenzpflichtig + 1 + + + https://doi.org/10.3790/978-3-428-58501-4 + X:DUH + Resolving-System + lizenzpflichtig + 1 + + + ZDB-54-DHE + 2022 + + + ZDB-54-DHP + 2022 + + + ZDB-54-DKPW + + + CV 2500 + Soziale Kognition + Psychologie + Sozialpsychologie + Soziale Kognition + (DE-627)1437673430 + (DE-625)rvk/19153: + (DE-576)367673436 + + + CP 3200 + Gefühl + Psychologie + Allgemeine Psychologie + Gefühl + (DE-627)1271512165 + (DE-625)rvk/18977: + (DE-576)201512165 + + + BO + + + 4088716612 + DE-705 + 705 + GBV + b + p + https://doi.org/10.3790/978-3-428-58501-4 + + + 4087786013 + DE-21 + 21 + BSZ + b + p + https://doi.org/10.3790/978-3-428-58501-4 + Zugang für die Universität Tübingen + + + 4142515608 + DE-24 + 24 + BSZ + c + http://han.wlb-stuttgart.de/han/dunckerhumblot-eB/elibrary.duncker-humblot.com/9783428585014/U1 + ZDB-54-DHP + + + 4252867134 + DE-180 + 180 + BSZ + b + http://primo-49man.hosted.exlibrisgroup.com/openurl/MAN/MAN_UB_service_page?u.ignore_date_coverage=true&rft.mms_id=9919356436502561 + BSO + + + 4117933825 + DE-Ofb1 + Ofb 1 + BSZ + b + E-Book Duncker & Humblot + https://doi.org/10.3790/978-3-428-58501-4 + Zum Online-Dokument + Zugang im Hochschulnetz der HS Offenburg / extern via VPN oder Shibboleth (Login über Institution) + + + + + 1 + + + + 1.1 + rec.id="(DE-627)1795329181" + 1 + 1 + xml + marcxml + + +` + +func TestGviHoldings(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(http.StatusOK) + output := []byte(GviResponse) + _, err := w.Write(output) + assert.NoError(t, err) + }) + server := httptest.NewServer(handler) + defer server.Close() + + creator := NewAvailabilityCreator(AvailabilityAdapterZoom, "") + + qtype := directory.Cql + peer := ill_db.Peer{ + CustomData: directory.Entry{ + AvailabilityConfig: &directory.AvailabilityConfig{ + Zoom: &directory.ZoomConfig{ + Address: server.URL, + Options: &map[string]string{ + "sru": "get", + "sru_version": "1.1", + }, + }, + QueryConfig: &directory.QueryConfig{ + Type: &qtype, + Identifier: NewString("rec.id = {term}"), + }, + ParserConfig: &directory.ParserConfig{ + Marc21plus1: &map[string]interface{}{}, + }, + }, + }, + } + + aa, err := creator.GetAdapter(peer) + if cgoEnabled() { + assert.NoError(t, err) + assert.NotNil(t, aa) + + params := LookupParams{ + ServiceType: "Loan", + Identifier: "(DE-627)1795329181", + } + holdingsList, _, err := aa.Lookup(params) + assert.NoError(t, err) + assert.NotNil(t, holdingsList) + assert.Len(t, holdingsList, 1) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), "cgo is not enabled") + } +} diff --git a/broker/adapter/holdings.go b/broker/holdings/holdings.go similarity index 72% rename from broker/adapter/holdings.go rename to broker/holdings/holdings.go index 93cc545f..839b4383 100644 --- a/broker/adapter/holdings.go +++ b/broker/holdings/holdings.go @@ -1,14 +1,15 @@ -package adapter +package holdings type LookupAdapter interface { Lookup(params LookupParams) ([]Holding, string, error) } type LookupParams struct { - Identifier string - Isbn string - Issn string - Title string + Identifier string + Isbn string + Issn string + Title string + ServiceType string } type Holding struct { @@ -21,7 +22,7 @@ type Holding struct { } type HoldingsParser interface { - Parse(record []byte) ([]Holding, error) + Parse(record []byte, params LookupParams) ([]Holding, error) } type LookupQueryBuilder interface { diff --git a/broker/adapter/marc_holdings_parser.go b/broker/holdings/parser_marc.go similarity index 95% rename from broker/adapter/marc_holdings_parser.go rename to broker/holdings/parser_marc.go index bb54792b..5e0958aa 100644 --- a/broker/adapter/marc_holdings_parser.go +++ b/broker/holdings/parser_marc.go @@ -1,4 +1,4 @@ -package adapter +package holdings import ( "encoding/xml" @@ -28,7 +28,7 @@ func NewMarcHoldingsParser(config directory.MarcParserConfig) HoldingsParser { } } -func (p *MarcHoldingsParser) Parse(record []byte) ([]Holding, error) { +func (p *MarcHoldingsParser) Parse(record []byte, params LookupParams) ([]Holding, error) { var marcRecord marcxml.Record err := xml.Unmarshal(record, &marcRecord) // TODO : consider OPAC record as well diff --git a/broker/holdings/parser_marc21_plus.go b/broker/holdings/parser_marc21_plus.go new file mode 100644 index 00000000..7cf72cac --- /dev/null +++ b/broker/holdings/parser_marc21_plus.go @@ -0,0 +1,104 @@ +package holdings + +import ( + "encoding/xml" + "strings" + + "github.com/indexdata/crosslink/iso18626" + "github.com/indexdata/crosslink/marcxml" +) + +// Holding Information to be used for routing is part of +// repeatable MARC 924 fields (one for each holding library). + +// First Indicator Resource Type + +// "0" Non-electronic (= default) + +// "1" Electronic + +// $a (NR) Local IDN of the holding record +// $b (NR) ISIL as an identifier of the owning institution +// $c (NR) Interlibrary loan region +// $d (NR) Interlibrary loan indicator +// "a" - Loan of volumes possible, no copies +// "b" - No loan of volumes, only paper copies are sent +// "c" - Unrestricted interlibrary loan, copying and loan +// "d" - No interlibrary loan +// "e" - No loan of volumes, the end user receives an +// electronic copy +// $k (R) Electronic address (URL) for a remotely accessed file +// $1 (R) Identification "Produktsigel" for national licenses +// and digital collections, so called "ProduktSigel" +// (it is an ISIL according to the German ISIL-Agency) + +// Full documentation Result format is MARC21, see from Deutsche +// Nationalbibliothek (DNB), https://d-nb.info/1282570226/34 + +type Marc21Plus1HoldingsParser struct { +} + +func NewMarc21Plus1HoldingsParser() HoldingsParser { + return &Marc21Plus1HoldingsParser{} +} + +func (p *Marc21Plus1HoldingsParser) Parse(record []byte, params LookupParams) ([]Holding, error) { + var marcRecord marcxml.Record + err := xml.Unmarshal(record, &marcRecord) + if err != nil { + // GVI marc does not have the MARC21 slim namespace, so we try again without it + var noNamespaceRecord marcxml.RecordType + err := xml.Unmarshal(record, &noNamespaceRecord) + if err != nil { + return nil, err + } + marcRecord.RecordType = noNamespaceRecord + } + loanOk := params.ServiceType == string(iso18626.TypeServiceTypeLoan) || + params.ServiceType == string(iso18626.TypeServiceTypeCopyOrLoan) || params.ServiceType == "" + copyOk := params.ServiceType == string(iso18626.TypeServiceTypeCopy) || + params.ServiceType == string(iso18626.TypeServiceTypeCopyOrLoan) || params.ServiceType == "" + + var holdings []Holding + for _, field := range marcRecord.Datafield { + if field.Tag == "924" { + var localIdentifier string + var symbol string + ok := false + for _, subfield := range field.Subfield { + if subfield.Code == "a" { + localIdentifier = strings.TrimSpace(string(subfield.Text)) + } + if subfield.Code == "b" { + symbol = strings.TrimSpace(string(subfield.Text)) + scheme, _, found := strings.Cut(symbol, ":") + if !found || strings.TrimSpace(scheme) == "" { + symbol = isilPrefix + symbol + } + } + if subfield.Code == "d" { // loan indicator + indicator := strings.TrimSpace(string(subfield.Text)) + if indicator == "a" { + ok = loanOk + } + if indicator == "b" { + ok = copyOk + } + if indicator == "c" { // unrestricted interlibrary loan, so we can treat it as available + ok = true + } + if indicator == "e" { + ok = copyOk + } + } + } + if ok && localIdentifier != "" && symbol != "" { + holdings = append(holdings, Holding{ + LocalIdentifier: localIdentifier, + Symbol: symbol, + }) + } + } + } + return holdings, nil +} diff --git a/broker/holdings/parser_marc21_plus_test.go b/broker/holdings/parser_marc21_plus_test.go new file mode 100644 index 00000000..8ac335e0 --- /dev/null +++ b/broker/holdings/parser_marc21_plus_test.go @@ -0,0 +1,346 @@ +package holdings + +import ( + "testing" + + "github.com/indexdata/crosslink/iso18626" + "github.com/stretchr/testify/assert" +) + +func TestMarc21Plus1HoldingsParser_ParseError(t *testing.T) { + parser := NewMarc21Plus1HoldingsParser() + + marcXML := []byte(` + + + LocalID123 + ISIL123 + Region1 + http://example.com/holding1 + ProduktSigel123 + + `) + + params := LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeLoan), + } + _, err := parser.Parse(marcXML, params) + assert.Error(t, err) +} + +func TestMarc21Plus1HoldingsParser_no_namespace(t *testing.T) { + parser := NewMarc21Plus1HoldingsParser() + + marcXML := []byte(` + + + LocalID123 + ISIL123 + Region1 + a + http://example.com/holding1 + ProduktSigel123 + + `) + + params := LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeLoan), + } + holdings, err := parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + holding := holdings[0] + assert.Equal(t, "LocalID123", holding.LocalIdentifier) + assert.Equal(t, "ISIL:ISIL123", holding.Symbol) +} + +func TestMarc21Plus1HoldingsParser_Parse_da(t *testing.T) { + parser := NewMarc21Plus1HoldingsParser() + + marcXML := []byte(` + + + LocalID123 + ISIL123 + Region1 + a + http://example.com/holding1 + ProduktSigel123 + + `) + + params := LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeLoan), + } + holdings, err := parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + holding := holdings[0] + assert.Equal(t, "LocalID123", holding.LocalIdentifier) + assert.Equal(t, "ISIL:ISIL123", holding.Symbol) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopyOrLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + + params = LookupParams{ + ServiceType: "", + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopy), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 0) +} + +func TestMarc21Plus1HoldingsParser_Parse_db(t *testing.T) { + parser := NewMarc21Plus1HoldingsParser() + + marcXML := []byte(` + + + LocalID123 + ISIL123 + Region1 + b + http://example.com/holding1 + ProduktSigel123 + + `) + + params := LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopy), + } + holdings, err := parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + holding := holdings[0] + assert.Equal(t, "LocalID123", holding.LocalIdentifier) + assert.Equal(t, "ISIL:ISIL123", holding.Symbol) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopyOrLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + + params = LookupParams{ + ServiceType: "", + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 0) +} + +func TestMarc21Plus1HoldingsParser_Parse_dc(t *testing.T) { + parser := NewMarc21Plus1HoldingsParser() + + marcXML := []byte(` + + + LocalID123 + ISIL123 + Region1 + c + http://example.com/holding1 + ProduktSigel123 + + `) + + params := LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopy), + } + holdings, err := parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + holding := holdings[0] + assert.Equal(t, "LocalID123", holding.LocalIdentifier) + assert.Equal(t, "ISIL:ISIL123", holding.Symbol) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopyOrLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + + params = LookupParams{ + ServiceType: "", + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) +} + +func TestMarc21Plus1HoldingsParser_Parse_dd(t *testing.T) { + parser := NewMarc21Plus1HoldingsParser() + + marcXML := []byte(` + + + LocalID123 + ISIL123 + Region1 + d + http://example.com/holding1 + ProduktSigel123 + + `) + + params := LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopy), + } + holdings, err := parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 0) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopyOrLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 0) + + params = LookupParams{ + ServiceType: "", + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 0) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 0) +} + +func TestMarc21Plus1HoldingsParser_Parse_de(t *testing.T) { + parser := NewMarc21Plus1HoldingsParser() + + marcXML := []byte(` + + + LocalID123 + ISIL123 + Region1 + e + http://example.com/holding1 + ProduktSigel123 + + `) + + params := LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopy), + } + holdings, err := parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + holding := holdings[0] + assert.Equal(t, "LocalID123", holding.LocalIdentifier) + assert.Equal(t, "ISIL:ISIL123", holding.Symbol) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopyOrLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + + params = LookupParams{ + ServiceType: "", + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 1) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 0) +} + +func TestMarc21Plus1HoldingsParser_Parse_multiple(t *testing.T) { + parser := NewMarc21Plus1HoldingsParser() + + marcXML := []byte(` + + + LocalID123 + 123 + Region1 + e + http://example.com/holding1 + ProduktSigel123 + + + LocalID124 + 124 + Region1 + e + http://example.com/holding2 + ProduktSigel124 + + `) + + params := LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopy), + } + holdings, err := parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 2) + holding := holdings[0] + assert.Equal(t, "LocalID123", holding.LocalIdentifier) + assert.Equal(t, "ISIL:123", holding.Symbol) + holding = holdings[1] + assert.Equal(t, "LocalID124", holding.LocalIdentifier) + assert.Equal(t, "ISIL:124", holding.Symbol) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeCopyOrLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 2) + + params = LookupParams{ + ServiceType: "", + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 2) + + params = LookupParams{ + ServiceType: string(iso18626.TypeServiceTypeLoan), + } + holdings, err = parser.Parse(marcXML, params) + assert.NoError(t, err) + assert.Len(t, holdings, 0) +} diff --git a/broker/adapter/opac_holdings_parser.go b/broker/holdings/parser_opac.go similarity index 90% rename from broker/adapter/opac_holdings_parser.go rename to broker/holdings/parser_opac.go index ea97cab4..52ba6ae4 100644 --- a/broker/adapter/opac_holdings_parser.go +++ b/broker/holdings/parser_opac.go @@ -1,4 +1,4 @@ -package adapter +package holdings import ( "encoding/xml" @@ -14,7 +14,7 @@ func NewOpacHoldingsParser(config directory.OpacParserConfig) HoldingsParser { return &OpacHoldingsParser{} } -func (p *OpacHoldingsParser) Parse(record []byte) ([]Holding, error) { +func (p *OpacHoldingsParser) Parse(record []byte, params LookupParams) ([]Holding, error) { var opacRecord marcxml.OpacRecord err := xml.Unmarshal(record, &opacRecord) if err != nil { diff --git a/broker/adapter/reservoir_holdings_parser.go b/broker/holdings/parser_reservoir.go similarity index 85% rename from broker/adapter/reservoir_holdings_parser.go rename to broker/holdings/parser_reservoir.go index 1592b57b..6a775b98 100644 --- a/broker/adapter/reservoir_holdings_parser.go +++ b/broker/holdings/parser_reservoir.go @@ -1,4 +1,4 @@ -package adapter +package holdings import ( "encoding/xml" @@ -8,8 +8,14 @@ import ( "github.com/indexdata/crosslink/marcxml" ) +const isilPrefix = "ISIL:" + type ReservoirHoldingsParser struct{} +func NewReservoirHoldingsParser() HoldingsParser { + return &ReservoirHoldingsParser{} +} + func parseHoldingsForIndicator(rec *marcxml.Record, ind2 string) []Holding { var holdings []Holding for _, df := range rec.Datafield { @@ -47,7 +53,7 @@ func parseHoldings(rec *marcxml.Record) []Holding { return holdings } -func (p *ReservoirHoldingsParser) Parse(recordData []byte) ([]Holding, error) { +func (p *ReservoirHoldingsParser) Parse(recordData []byte, params LookupParams) ([]Holding, error) { var rec marcxml.Record err := xml.Unmarshal(recordData, &rec) if err != nil { diff --git a/broker/holdings/query_builder_general.go b/broker/holdings/query_builder_general.go new file mode 100644 index 00000000..b60e8014 --- /dev/null +++ b/broker/holdings/query_builder_general.go @@ -0,0 +1,107 @@ +package holdings + +import ( + "errors" + "fmt" + "strings" + + "github.com/indexdata/cql-go/cqlbuilder" + "github.com/indexdata/crosslink/directory" +) + +type QueryBuilderGen struct { + config directory.QueryConfig +} + +func NewString(s string) *string { + if len(s) > 0 { + return &s + } + return nil +} + +func NewQueryBuilderGen(queryConfig *directory.QueryConfig) (LookupQueryBuilder, error) { + var config directory.QueryConfig + if queryConfig != nil { + config = *queryConfig + } + if config.Type == nil || *config.Type == directory.Pqf { + if config.Identifier == nil { + config.Identifier = NewString("@attr 1=12 {term}") + } + if config.Isbn == nil { + config.Isbn = NewString("@attr 1=7 {term}") + } + if config.Issn == nil { + config.Issn = NewString("@attr 1=8 {term}") + } + if config.Title == nil { + config.Title = NewString("@attr 1=4 {term}") + } + return &QueryBuilderGen{config: config}, nil + } + if *config.Type == directory.Cql { + if config.Identifier == nil { + config.Identifier = NewString("rec.id = {term}") + } + if config.Isbn == nil { + config.Isbn = NewString("isbn = {term}") + } + if config.Issn == nil { + config.Issn = NewString("issn = {term}") + } + if config.Title == nil { + config.Title = NewString("title = {term}") + } + return &QueryBuilderGen{config: config}, nil + } + return nil, fmt.Errorf("unsupported query builder type: %s", *config.Type) +} + +func pqfEncode(value string) string { + // escape backslashes and double quotes + escaped := "\"" + for _, r := range value { + if r == '\\' || r == '"' { + escaped += "\\" + } + escaped += string(r) + } + escaped += "\"" + return escaped +} + +func cqlEncode(value string) string { + return "\"" + cqlbuilder.EscapeMaskingChars(cqlbuilder.EscapeSpecialChars(value)) + "\"" +} + +func (s *QueryBuilderGen) Build(params LookupParams) (cql []string, pqf []string, err error) { + type paramMapping struct { + value string + mapping *string + } + + paramMappings := []paramMapping{ + {params.Identifier, s.config.Identifier}, + {params.Isbn, s.config.Isbn}, + {params.Issn, s.config.Issn}, + {params.Title, s.config.Title}, + } + var pqfList []string + var cqlList []string + for _, pm := range paramMappings { + if pm.value != "" && pm.mapping != nil && *pm.mapping != "" { + if s.config.Type != nil && *s.config.Type == directory.Cql { + cql := strings.ReplaceAll(*pm.mapping, "{term}", cqlEncode(pm.value)) + cqlList = append(cqlList, cql) + } else { + pqf := strings.ReplaceAll(*pm.mapping, "{term}", pqfEncode(pm.value)) + pqfList = append(pqfList, pqf) + } + } + } + if len(cqlList) == 0 && len(pqfList) == 0 { + return nil, nil, errors.New("no search parameters provided for PQF/CQL query") + } + return cqlList, pqfList, nil +} diff --git a/broker/holdings/query_builder_general_test.go b/broker/holdings/query_builder_general_test.go new file mode 100644 index 00000000..6dca3d62 --- /dev/null +++ b/broker/holdings/query_builder_general_test.go @@ -0,0 +1,89 @@ +package holdings + +import ( + "testing" + + "github.com/indexdata/crosslink/directory" + "github.com/stretchr/testify/assert" +) + +func TestNewQueryBuilderGen(t *testing.T) { + // Test with nil config (should use default PQF mappings) + qb, err := NewQueryBuilderGen(nil) + assert.NoError(t, err) + assert.NotNil(t, qb) + + gg := (qb).(*QueryBuilderGen) + assert.Equal(t, "@attr 1=12 {term}", *gg.config.Identifier) + assert.Equal(t, "@attr 1=7 {term}", *gg.config.Isbn) + assert.Equal(t, "@attr 1=8 {term}", *gg.config.Issn) + assert.Equal(t, "@attr 1=4 {term}", *gg.config.Title) + + // Test with empty config (should use default PQF mappings) + qb, err = NewQueryBuilderGen(&directory.QueryConfig{}) + assert.NoError(t, err) + assert.NotNil(t, qb) + gg = (qb).(*QueryBuilderGen) + assert.Equal(t, "@attr 1=12 {term}", *gg.config.Identifier) + assert.Equal(t, "@attr 1=7 {term}", *gg.config.Isbn) + assert.Equal(t, "@attr 1=8 {term}", *gg.config.Issn) + assert.Equal(t, "@attr 1=4 {term}", *gg.config.Title) + + // Test with CQL type and no mappings (should use default CQL mappings) + cqlType := directory.Cql + qb, err = NewQueryBuilderGen(&directory.QueryConfig{Type: &cqlType}) + assert.NoError(t, err) + assert.NotNil(t, qb) + gg = (qb).(*QueryBuilderGen) + assert.Equal(t, "rec.id = {term}", *gg.config.Identifier) + assert.Equal(t, "isbn = {term}", *gg.config.Isbn) + assert.Equal(t, "issn = {term}", *gg.config.Issn) + assert.Equal(t, "title = {term}", *gg.config.Title) + + cql, pqf, err := qb.Build(LookupParams{Identifier: "12345", Title: "Test Title"}) + assert.NoError(t, err) + assert.Len(t, pqf, 0) + assert.Equal(t, []string{"rec.id = \"12345\"", "title = \"Test Title\""}, cql) + + empty := "" + // Test with CQL type and one mapping + qb, err = NewQueryBuilderGen(&directory.QueryConfig{ + Type: &cqlType, + Identifier: NewString("id == {term}"), + Title: &empty, + }) + assert.NoError(t, err) + assert.NotNil(t, qb) + gg = (qb).(*QueryBuilderGen) + assert.Equal(t, "id == {term}", *gg.config.Identifier) + assert.Equal(t, "isbn = {term}", *gg.config.Isbn) + assert.Equal(t, "issn = {term}", *gg.config.Issn) + assert.Equal(t, "", *gg.config.Title) + cql, pqf, err = qb.Build(LookupParams{Identifier: "12345", Title: "Test Title"}) + assert.NoError(t, err) + assert.Len(t, pqf, 0) + assert.Equal(t, []string{"id == \"12345\""}, cql) + + // Test with unsupported type + unsupportedType := directory.QueryConfigType("unsupported") + qb, err = NewQueryBuilderGen(&directory.QueryConfig{Type: &unsupportedType}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported query builder type") + assert.Nil(t, qb) +} + +func TestPqfEncode(t *testing.T) { + assert.Equal(t, "\"computer\"", pqfEncode("computer")) + assert.Equal(t, "\"co?puter*\"", pqfEncode("co?puter*")) + assert.Equal(t, "\"comp\\\"uter\"", pqfEncode("comp\"uter")) + assert.Equal(t, "\"comp\\\\uter\"", pqfEncode("comp\\uter")) + assert.Equal(t, "\"comp\\\\\\\"uter\"", pqfEncode("comp\\\"uter")) +} + +func TestCqlEncode(t *testing.T) { + assert.Equal(t, "\"computer\"", cqlEncode("computer")) + assert.Equal(t, "\"co\\?puter\\*\"", cqlEncode("co?puter*")) + assert.Equal(t, "\"comp\\\"uter\"", cqlEncode("comp\"uter")) + assert.Equal(t, "\"comp\\\\uter\"", cqlEncode("comp\\uter")) + assert.Equal(t, "\"comp\\\\\\\"uter\"", cqlEncode("comp\\\"uter")) +} diff --git a/broker/adapter/query_builder_isxn.go b/broker/holdings/query_builder_isxn.go similarity index 93% rename from broker/adapter/query_builder_isxn.go rename to broker/holdings/query_builder_isxn.go index ded077bd..eb58a0bf 100644 --- a/broker/adapter/query_builder_isxn.go +++ b/broker/holdings/query_builder_isxn.go @@ -1,4 +1,4 @@ -package adapter +package holdings import ( "errors" @@ -9,7 +9,7 @@ type QueryBuilderIsxn struct { isxn bool } -func NewQueryBuilderIsxn(isxn bool) *QueryBuilderIsxn { +func NewQueryBuilderIsxn(isxn bool) LookupQueryBuilder { return &QueryBuilderIsxn{isxn: isxn} } diff --git a/broker/test/adapter/sru_holdings_test.go b/broker/holdings/sru_holdings_test.go similarity index 90% rename from broker/test/adapter/sru_holdings_test.go rename to broker/holdings/sru_holdings_test.go index eeedc87a..6dc33898 100644 --- a/broker/test/adapter/sru_holdings_test.go +++ b/broker/holdings/sru_holdings_test.go @@ -1,4 +1,4 @@ -package adapter +package holdings import ( "encoding/xml" @@ -6,17 +6,16 @@ import ( "net/http/httptest" "testing" - "github.com/indexdata/crosslink/broker/adapter" "github.com/indexdata/crosslink/marcxml" "github.com/indexdata/crosslink/sru" "github.com/indexdata/crosslink/sru/diag" "github.com/stretchr/testify/assert" ) -func createSruAdapter(t *testing.T, isxn bool, url ...string) adapter.LookupAdapter { - parser := &adapter.ReservoirHoldingsParser{} - queryBuilder := adapter.NewQueryBuilderIsxn(isxn) - ad := adapter.CreateSruHoldingsLookupAdapter(http.DefaultClient, url, "", queryBuilder, parser, "marcxml") +func createSruAdapter(t *testing.T, isxn bool, url ...string) LookupAdapter { + parser := &ReservoirHoldingsParser{} + queryBuilder := NewQueryBuilderIsxn(isxn) + ad := CreateSruHoldingsLookupAdapter(http.DefaultClient, url, "", queryBuilder, parser, "marcxml") assert.NotNil(t, ad) return ad } @@ -30,7 +29,7 @@ func TestSru500(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } _, query, err := ad.Lookup(p) @@ -47,7 +46,7 @@ func TestSruBadXml(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } _, query, err := ad.Lookup(p) @@ -81,7 +80,7 @@ func TestSruBadDiagnostics(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } _, query, err := ad.Lookup(p) @@ -106,13 +105,13 @@ func TestSruMarcxmlNoHits(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } - holdings, query, err := ad.Lookup(p) + holdingsList, query, err := ad.Lookup(p) assert.NotEmpty(t, query) assert.NoError(t, err) - assert.Len(t, holdings, 0) + assert.Len(t, holdingsList, 0) } func TestSruMarcxmlStringEncoding(t *testing.T) { @@ -144,7 +143,7 @@ func TestSruMarcxmlStringEncoding(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } _, query, err := ad.Lookup(p) @@ -181,7 +180,7 @@ func TestSruMarcxmlUnsupportedSchema(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } _, query, err := ad.Lookup(p) @@ -218,7 +217,7 @@ func TestSruMarcxmlBadSurrogateDiagnostic(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } _, query, err := ad.Lookup(p) @@ -261,7 +260,7 @@ func TestSruMarcxmlOkSurrogateDiagnostic(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } _, query, err := ad.Lookup(p) @@ -300,7 +299,7 @@ func TestSruMarcxmlBadMarc(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } _, query, err := ad.Lookup(p) @@ -371,7 +370,7 @@ func TestSruMarcxmlWithFallbackHoldings(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } holdings, query, err := ad.Lookup(p) @@ -449,7 +448,7 @@ func TestSruMarcxmlWithHoldingsDoesNotUseFallback(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } holdings, query, err := ad.Lookup(p) @@ -512,7 +511,7 @@ func TestSruMarcxmlLeavesSchemedSymbolUnchanged(t *testing.T) { defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } holdings, query, err := ad.Lookup(p) @@ -586,7 +585,7 @@ func TestSruMarcxmlUsesFallbackWhenPrimaryFieldHasNoUsableHolding(t *testing.T) defer server.Close() ad := createSruAdapter(t, false, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } holdings, query, err := ad.Lookup(p) @@ -692,48 +691,48 @@ func TestSruMarcxmlWithHoldings(t *testing.T) { defer server.Close() ad := createSruAdapter(t, true, server.URL) - p := adapter.LookupParams{ + p := LookupParams{ Identifier: "123", } - holdings, query, err := ad.Lookup(p) + holdingsList, query, err := ad.Lookup(p) assert.NoError(t, err) assert.NotEmpty(t, query) assert.Equal(t, "rec.id = 123", receivedQuery) - assert.Len(t, holdings, 3) - assert.Equal(t, "l1", holdings[0].LocalIdentifier) - assert.Equal(t, "ISIL:s1", holdings[0].Symbol) - assert.Equal(t, "l2", holdings[1].LocalIdentifier) - assert.Equal(t, "ISIL:s2", holdings[1].Symbol) - assert.Equal(t, "l3", holdings[2].LocalIdentifier) - assert.Equal(t, "ISIL:s3", holdings[2].Symbol) + assert.Len(t, holdingsList, 3) + assert.Equal(t, "l1", holdingsList[0].LocalIdentifier) + assert.Equal(t, "ISIL:s1", holdingsList[0].Symbol) + assert.Equal(t, "l2", holdingsList[1].LocalIdentifier) + assert.Equal(t, "ISIL:s2", holdingsList[1].Symbol) + assert.Equal(t, "l3", holdingsList[2].LocalIdentifier) + assert.Equal(t, "ISIL:s3", holdingsList[2].Symbol) ad = createSruAdapter(t, true, server.URL, server.URL) - p = adapter.LookupParams{ + p = LookupParams{ Identifier: "123", Isbn: "99-222", Issn: "99-333", } - holdings, query, err = ad.Lookup(p) + holdingsList, query, err = ad.Lookup(p) assert.NoError(t, err) assert.NotEmpty(t, query) assert.Equal(t, "rec.id = 123 or isbn = 99-222 or issn = 99-333", receivedQuery) - assert.Len(t, holdings, 6) - assert.Equal(t, "l1", holdings[0].LocalIdentifier) - assert.Equal(t, "ISIL:s1", holdings[0].Symbol) - assert.Equal(t, "l2", holdings[1].LocalIdentifier) - assert.Equal(t, "ISIL:s2", holdings[1].Symbol) - assert.Equal(t, "l3", holdings[2].LocalIdentifier) - assert.Equal(t, "ISIL:s3", holdings[2].Symbol) - - assert.Equal(t, "l1", holdings[3].LocalIdentifier) - assert.Equal(t, "ISIL:s1", holdings[3].Symbol) - assert.Equal(t, "l2", holdings[4].LocalIdentifier) - assert.Equal(t, "ISIL:s2", holdings[4].Symbol) - assert.Equal(t, "l3", holdings[5].LocalIdentifier) - assert.Equal(t, "ISIL:s3", holdings[5].Symbol) + assert.Len(t, holdingsList, 6) + assert.Equal(t, "l1", holdingsList[0].LocalIdentifier) + assert.Equal(t, "ISIL:s1", holdingsList[0].Symbol) + assert.Equal(t, "l2", holdingsList[1].LocalIdentifier) + assert.Equal(t, "ISIL:s2", holdingsList[1].Symbol) + assert.Equal(t, "l3", holdingsList[2].LocalIdentifier) + assert.Equal(t, "ISIL:s3", holdingsList[2].Symbol) + + assert.Equal(t, "l1", holdingsList[3].LocalIdentifier) + assert.Equal(t, "ISIL:s1", holdingsList[3].Symbol) + assert.Equal(t, "l2", holdingsList[4].LocalIdentifier) + assert.Equal(t, "ISIL:s2", holdingsList[4].Symbol) + assert.Equal(t, "l3", holdingsList[5].LocalIdentifier) + assert.Equal(t, "ISIL:s3", holdingsList[5].Symbol) ad = createSruAdapter(t, false, server.URL) - p = adapter.LookupParams{ + p = LookupParams{ Isbn: "99-222", } _, _, err = ad.Lookup(p) diff --git a/broker/service/supplierlocator.go b/broker/service/supplierlocator.go index 8ce10661..80342f85 100644 --- a/broker/service/supplierlocator.go +++ b/broker/service/supplierlocator.go @@ -9,9 +9,9 @@ import ( "github.com/google/uuid" "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/availability" "github.com/indexdata/crosslink/broker/common" "github.com/indexdata/crosslink/broker/events" + "github.com/indexdata/crosslink/broker/holdings" "github.com/indexdata/crosslink/broker/ill_db" "github.com/jackc/pgx/v5/pgtype" ) @@ -25,17 +25,19 @@ type SupplierLocator struct { eventBus events.EventBus illRepo ill_db.IllRepo dirAdapter adapter.DirectoryLookupAdapter - holdingsAdapter adapter.LookupAdapter - availabilityCreator availability.AvailabilityCreator + holdingsAdapter holdings.LookupAdapter + availabilityCreator holdings.AvailabilityCreator + consortiaSymbol string } -func CreateSupplierLocator(eventBus events.EventBus, illRepo ill_db.IllRepo, dirAdapter adapter.DirectoryLookupAdapter, holdingsAdapter adapter.LookupAdapter, availabilityCreator availability.AvailabilityCreator) SupplierLocator { +func CreateSupplierLocator(eventBus events.EventBus, illRepo ill_db.IllRepo, dirAdapter adapter.DirectoryLookupAdapter, holdingsAdapter holdings.LookupAdapter, availabilityCreator holdings.AvailabilityCreator, consortiaSymbol string) SupplierLocator { return SupplierLocator{ eventBus: eventBus, illRepo: illRepo, dirAdapter: dirAdapter, holdingsAdapter: holdingsAdapter, availabilityCreator: availabilityCreator, + consortiaSymbol: consortiaSymbol, } } @@ -54,8 +56,8 @@ func (s *SupplierLocator) CheckAvailability(ctx common.ExtendedContext, event ev _, _ = s.eventBus.ProcessTask(ctx, event, events.SignalConsumers, s.checkAvailability) } -func createHoldingsParams(illTransactionData ill_db.IllTransactionData) adapter.LookupParams { - var holdingsParams adapter.LookupParams +func createHoldingsParams(illTransactionData ill_db.IllTransactionData) holdings.LookupParams { + var holdingsParams holdings.LookupParams bibliographicInfo := illTransactionData.BibliographicInfo holdingsParams.Identifier = bibliographicInfo.SupplierUniqueRecordId holdingsParams.Title = bibliographicInfo.Title @@ -68,9 +70,31 @@ func createHoldingsParams(illTransactionData ill_db.IllTransactionData) adapter. holdingsParams.Issn = id.BibliographicItemIdentifier } } + if illTransactionData.ServiceInfo != nil { + holdingsParams.ServiceType = string(illTransactionData.ServiceInfo.ServiceType) + } return holdingsParams } +// 3 cases to consider for getting the adapter: +// 1. If holdingsAdapter is set from the start (for example for testing), use it directly +// 2. If consortiaSymbol is set, lookup the peer for the consortia and use its availability adapter +// 3. Otherwise, use the availability adapter for the requesting peer +func (s *SupplierLocator) getAdapterForConsortia(ctx common.ExtendedContext, requestPeer ill_db.Peer) (holdings.LookupAdapter, error) { + lookupAdapter := s.holdingsAdapter + if lookupAdapter != nil { + return lookupAdapter, nil + } + if s.consortiaSymbol == "" { + return s.availabilityCreator.GetAdapter(requestPeer) + } + peer, err := s.illRepo.GetPeerBySymbol(ctx, s.consortiaSymbol) + if err != nil { + return nil, err + } + return s.availabilityCreator.GetAdapter(peer) +} + func (s *SupplierLocator) locateSuppliers(ctx common.ExtendedContext, event events.Event) (events.EventStatus, *events.EventResult) { illTrans, err := s.illRepo.GetIllTransactionById(ctx, event.IllTransactionID) if err != nil { @@ -86,8 +110,14 @@ func (s *SupplierLocator) locateSuppliers(ctx common.ExtendedContext, event even if err != nil { return events.LogErrorAndReturnResult(ctx, "failed to read requester peer", err) } - - holdings, query, err := s.holdingsAdapter.Lookup(holdingsParams) + lookupAdapter, err := s.getAdapterForConsortia(ctx, requester) + if err != nil { + return events.LogErrorAndReturnResult(ctx, "failed to get holdings adapter for locating suppliers", err) + } + if lookupAdapter == nil { + return events.LogErrorAndReturnResult(ctx, "no holdings adapter available for locating suppliers", fmt.Errorf("no adapter found")) + } + holdings, query, err := lookupAdapter.Lookup(holdingsParams) if err != nil { return events.LogErrorAndReturnResult(ctx, fmt.Sprintf("failed to locate holdings for query '%s'", query), err) } @@ -244,7 +274,7 @@ func (s *SupplierLocator) checkAvailability(ctx common.ExtendedContext, event ev if err != nil { return events.LogErrorAndReturnResult(ctx, "could not get peer", err) } - aa, err := s.availabilityCreator.GetAdapter(ctx, peer) + aa, err := s.availabilityCreator.GetAdapter(peer) if err != nil { return events.LogErrorAndReturnResult(ctx, "could not create availability adapter", err) } diff --git a/broker/service/supplierlocator_test.go b/broker/service/supplierlocator_test.go index 32f2f4b0..9faa542c 100644 --- a/broker/service/supplierlocator_test.go +++ b/broker/service/supplierlocator_test.go @@ -9,9 +9,9 @@ import ( "time" "github.com/indexdata/crosslink/broker/adapter" - "github.com/indexdata/crosslink/broker/availability" "github.com/indexdata/crosslink/broker/common" "github.com/indexdata/crosslink/broker/events" + "github.com/indexdata/crosslink/broker/holdings" "github.com/indexdata/crosslink/broker/ill_db" "github.com/indexdata/crosslink/broker/test/mocks" "github.com/indexdata/crosslink/directory" @@ -65,7 +65,7 @@ func TestGetNextSupplierEmptyMap(t *testing.T) { peerId := "p1" mockIllRepo := new(MockIllRepoRequester) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId}}) assert.NoError(t, err) @@ -91,7 +91,7 @@ func TestGetNextSupplierClosed(t *testing.T) { err := json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId, SupplierSymbol: "ISIL:SUP"}}) assert.NoError(t, err) @@ -105,7 +105,7 @@ func TestGetNextSupplierFailToLoadPeer(t *testing.T) { peerId := "p1" mockIllRepo := new(MockIllRepoRequester) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{}, errors.New("db error")) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId}}) assert.Equal(t, "db error", err.Error()) @@ -123,7 +123,7 @@ func TestGetNextSupplierNoClosures(t *testing.T) { err := json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId}}) assert.NoError(t, err) @@ -147,7 +147,7 @@ func TestGetNextSupplierNoStartDate(t *testing.T) { err := json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId}}) assert.NoError(t, err) @@ -171,7 +171,7 @@ func TestGetNextSupplierNoEndDate(t *testing.T) { err := json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId}}) assert.NoError(t, err) @@ -197,7 +197,7 @@ func TestGetNextSupplierBothInPast(t *testing.T) { err := json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId}}) assert.NoError(t, err) @@ -223,7 +223,7 @@ func TestGetNextSupplierBothInFuture(t *testing.T) { err := json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId}}) assert.NoError(t, err) @@ -248,7 +248,7 @@ func TestGetNextSupplierCannotParseDate(t *testing.T) { err := json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId}}) assert.NoError(t, err) @@ -273,7 +273,7 @@ func TestGetNextSupplierCannotParseEndDate(t *testing.T) { err := json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "1", SupplierID: peerId}}) assert.NoError(t, err) @@ -307,7 +307,7 @@ func TestGetNextSupplierBetweenHolidays(t *testing.T) { err = json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") locSup, skipped, err := locator.getNextSupplier(appCtx, []ill_db.LocatedSupplier{{ID: "l1", SupplierID: peerId}}) assert.NoError(t, err) @@ -333,7 +333,7 @@ func TestGetNextSupplierClosedEventFailed(t *testing.T) { err := json.Unmarshal([]byte(jsonData), &data) assert.NoError(t, err) mockIllRepo.On("GetPeerById", peerId).Return(ill_db.Peer{CustomData: data}, nil) - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(adapter.SruHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.ApiDirectory), new(holdings.SruHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") status, result := locator.selectSupplier(appCtx, events.Event{IllTransactionID: "1"}) assert.Equal(t, events.EventStatusProblem, status) @@ -367,7 +367,7 @@ func TestLocateSuppliersDeduplicatesHoldingSymbolsForDirectoryLookup(t *testing. }, } - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.MockDirectoryLookupAdapter), new(adapter.MockHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.MockDirectoryLookupAdapter), new(holdings.MockHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") status, _ := locator.locateSuppliers(appCtx, events.Event{IllTransactionID: "ill-1"}) assert.Equal(t, events.EventStatusSuccess, status) @@ -394,7 +394,7 @@ func TestLocateSuppliersUsesFirstHoldingLocalIdentifierForDuplicateSymbol(t *tes }, } - locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.MockDirectoryLookupAdapter), new(adapter.MockHoldingsLookupAdapter), new(availability.AvailabilityCreatorImpl)) + locator := CreateSupplierLocator(new(events.PostgresEventBus), mockIllRepo, new(adapter.MockDirectoryLookupAdapter), new(holdings.MockHoldingsLookupAdapter), new(holdings.AvailabilityCreatorImpl), "") status, _ := locator.locateSuppliers(appCtx, events.Event{IllTransactionID: "ill-1"}) assert.Equal(t, events.EventStatusSuccess, status) diff --git a/broker/test/adapter/create_holdings_test.go b/broker/test/adapter/create_holdings_test.go deleted file mode 100644 index 816a1212..00000000 --- a/broker/test/adapter/create_holdings_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package adapter - -import ( - "testing" - - "github.com/indexdata/crosslink/broker/adapter" - "github.com/stretchr/testify/assert" -) - -func TestCreateHoldings(t *testing.T) { - m := make(map[string]any) - - _, err := adapter.CreateHoldingsLookupAdapter(m) - assert.Error(t, err) - assert.ErrorContains(t, err, "missing value for HOLDINGS_ADAPTER") - - m[adapter.HoldingsAdapter] = "sru" - - _, err = adapter.CreateHoldingsLookupAdapter(m) - assert.ErrorContains(t, err, "missing value for HOLDINGS_SRU_URL") - - m[adapter.HoldingsSruURL] = "http://example.com" - _, err = adapter.CreateHoldingsLookupAdapter(m) - assert.Error(t, err) - assert.ErrorContains(t, err, "missing value for HOLDINGS_ISXN_LOOKUP") - - m[adapter.HoldingsIsxnLookup] = "fake" - _, err = adapter.CreateHoldingsLookupAdapter(m) - assert.Error(t, err) - assert.ErrorContains(t, err, "invalid value for HOLDINGS_ISXN_LOOKUP") - - m[adapter.HoldingsIsxnLookup] = true - _, err = adapter.CreateHoldingsLookupAdapter(m) - assert.NoError(t, err) - - m["HOLDINGS_ADAPTER"] = "mock" - _, err = adapter.CreateHoldingsLookupAdapter(m) - assert.NoError(t, err) - - m["HOLDINGS_ADAPTER"] = "other" - _, err = adapter.CreateHoldingsLookupAdapter(m) - assert.ErrorContains(t, err, "bad value for HOLDINGS_ADAPTER") -} diff --git a/broker/test/service/e2e_test.go b/broker/test/service/e2e_test.go index 12b2943d..7a05690b 100644 --- a/broker/test/service/e2e_test.go +++ b/broker/test/service/e2e_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - "github.com/indexdata/crosslink/broker/availability" "github.com/indexdata/crosslink/broker/events" + "github.com/indexdata/crosslink/broker/holdings" "github.com/indexdata/crosslink/broker/adapter" "github.com/indexdata/crosslink/broker/app" @@ -52,7 +52,7 @@ func TestMain(m *testing.M) { mockPort := utils.Must(test.GetFreePort()) app.HTTP_PORT = utils.Must(test.GetFreePort()) test.Expect(os.Setenv("PEER_URL", "http://localhost:"+strconv.Itoa(app.HTTP_PORT)+"/iso18626"), "failed to set peer URL") - app.AVAILABILITY_ADAPTER = availability.AvailabilityAdapterMock + app.AVAILABILITY_ADAPTER = holdings.AvailabilityAdapterMock apptest.StartMockApp(mockPort) app.ConnectionString = connStr diff --git a/broker/test/service/supplierlocator_test.go b/broker/test/service/supplierlocator_test.go index 223adc77..26227de5 100644 --- a/broker/test/service/supplierlocator_test.go +++ b/broker/test/service/supplierlocator_test.go @@ -944,7 +944,7 @@ func TestCheckAvailability_Z3950AdapterSkipped(t *testing.T) { func TestCheckAvailability_Z3950AdapterNotSkipped(t *testing.T) { appCtx := common.CreateExtCtxWithArgs(context.Background(), nil) customData := directory.Entry{AvailabilityConfig: &directory.AvailabilityConfig{ - Z3950: &directory.Z3950Config{ + Zoom: &directory.ZoomConfig{ Address: "a", Options: &map[string]string{ "location": "1234", // ensures that availability lookup returns a result and supplier is not skipped @@ -997,7 +997,7 @@ func TestCheckAvailability_Z3950AdapterError(t *testing.T) { appCtx := common.CreateExtCtxWithArgs(context.Background(), nil) customData := directory.Entry{ AvailabilityConfig: &directory.AvailabilityConfig{ - Z3950: &directory.Z3950Config{ + Zoom: &directory.ZoomConfig{ Address: "a", Options: &map[string]string{ "adapter-error": "true", @@ -1046,7 +1046,7 @@ func TestCheckAvailability_Z3950LookupError(t *testing.T) { appCtx := common.CreateExtCtxWithArgs(context.Background(), nil) customData := directory.Entry{ AvailabilityConfig: &directory.AvailabilityConfig{ - Z3950: &directory.Z3950Config{ + Zoom: &directory.ZoomConfig{ Address: "a", Options: &map[string]string{ "lookup-error": "true", diff --git a/directory/directory_api.yaml b/directory/directory_api.yaml index d7b942e1..aabd3e02 100644 --- a/directory/directory_api.yaml +++ b/directory/directory_api.yaml @@ -261,8 +261,8 @@ components: properties: sru: $ref: '#/components/schemas/SruConfig' - z3950: - $ref: '#/components/schemas/Z3950Config' + zoom: + $ref: '#/components/schemas/ZoomConfig' queryConfig: $ref: '#/components/schemas/QueryConfig' parserConfig: @@ -282,7 +282,7 @@ components: description: Record schema to use for parsing holdings records (for example, "marcxml") additionalProperties: false - Z3950Config: + ZoomConfig: type: object required: - address @@ -301,6 +301,13 @@ components: QueryConfig: type: object properties: + type: + type: string + enum: + - cql + - pqf + default: pqf + description: whether mapping is CQL or PQF. Default is PQF. title: type: string description: PQF/CQL format string for title search. Default is "@attr 1=4 {term}" @@ -322,6 +329,16 @@ components: $ref: '#/components/schemas/MarcParserConfig' opac: $ref: '#/components/schemas/OpacParserConfig' + reservoir: + type: object + description: Configuration for Reservoir parser. Currently must be empty; no properties allowed. + properties: {} + additionalProperties: false + marc21plus1: + type: object + description: Configuration for MARC-21plus-1 parser. Currently must be empty; no properties allowed. + properties: {} + additionalProperties: false additionalProperties: false MarcParserConfig: diff --git a/zoom/zoom.go b/zoom/zoom.go index 246ade03..ce1281ac 100644 --- a/zoom/zoom.go +++ b/zoom/zoom.go @@ -36,6 +36,10 @@ type Connection struct { conn C.ZOOM_connection } +type Query struct { + zquery C.ZOOM_query +} + type ResultSet struct { connection *Connection rs C.ZOOM_resultset @@ -57,6 +61,42 @@ func (o *Options) toZoomOptions() C.ZOOM_options { return zo } +func NewPqfQuery(pqf string) (*Query, error) { + cPqf := C.CString(pqf) + defer C.free(unsafe.Pointer(cPqf)) + + query := &Query{zquery: C.ZOOM_query_create()} + return checkQuery(C.ZOOM_query_prefix(query.zquery, cPqf), query, "PQF") +} + +func NewCqlQuery(cql string) (*Query, error) { + cCql := C.CString(cql) + defer C.free(unsafe.Pointer(cCql)) + + query := &Query{zquery: C.ZOOM_query_create()} + return checkQuery(C.ZOOM_query_cql(query.zquery, cCql), query, "CQL") +} + +func checkQuery(ret C.int, query *Query, kind string) (*Query, error) { + if ret != 0 { + query.finalize() + return nil, &ZoomError{Code: 0, Message: "failed to create " + kind + " query"} + } + runtime.SetFinalizer(query, (*Query).finalize) + return query, nil +} + +func (q *Query) finalize() { + if q.zquery != nil { + C.ZOOM_query_destroy(q.zquery) + q.zquery = nil + } +} + +func (q *Query) Close() { + q.finalize() +} + func NewConnection(options Options) *Connection { c := &Connection{} cOptions := options.toZoomOptions() @@ -87,13 +127,17 @@ func (c *Connection) Close() { c.finalize() } -func (c *Connection) Search(query string) (*ResultSet, error) { +func (c *Connection) Search(query *Query) (*ResultSet, error) { if c.conn == nil { return nil, &ZoomError{Code: 0, Message: "connection is not established"} } - cQuery := C.CString(query) - defer C.free(unsafe.Pointer(cQuery)) - cSet := C.ZOOM_connection_search_pqf(c.conn, cQuery) + if query == nil { + return nil, &ZoomError{Code: 0, Message: "query is nil"} + } + if query.zquery == nil { + return nil, &ZoomError{Code: 0, Message: "query is not initialized"} + } + cSet := C.ZOOM_connection_search(c.conn, query.zquery) set := &ResultSet{rs: cSet, connection: c} err := c.checkError() if err != nil { diff --git a/zoom/zoom_test.go b/zoom/zoom_test.go index d2b6b0c5..6db009e9 100644 --- a/zoom/zoom_test.go +++ b/zoom/zoom_test.go @@ -57,9 +57,25 @@ func TestConnect(t *testing.T) { assert.Equal(t, 10000, err.(*ZoomError).Code) } +func TestPqfQuery(t *testing.T) { + query, err := NewPqfQuery("@attr 1=4 computer") + assert.NoError(t, err) + assert.NotNil(t, query) + query.Close() + + _, err = NewPqfQuery("@attr 1=4") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create PQF query") +} + func TestSearch(t *testing.T) { conn := &Connection{} - _, err := conn.Search("@attr 1=4 utah") + + query, err := NewPqfQuery("@attr 1=4 computer") + assert.NoError(t, err) + assert.NotNil(t, query) + + _, err = conn.Search(query) assert.Error(t, err) assert.Contains(t, err.Error(), "connection is not established") @@ -73,7 +89,22 @@ func TestSearch(t *testing.T) { err = conn.Connect(containerHost + ":" + mappedPort) assert.NoError(t, err) - rs, err := conn.Search("@attr 1=4 computer") + query.Close() + + _, err = conn.Search(nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "query is nil") + + _, err = conn.Search(query) + assert.Error(t, err) + assert.Contains(t, err.Error(), "query is not initialized") + + query, err = NewPqfQuery("@attr 1=4 computer") + assert.NoError(t, err) + assert.NotNil(t, query) + defer query.Close() + + rs, err := conn.Search(query) assert.NoError(t, err) assert.NotNil(t, rs) assert.Equal(t, 42, rs.Count()) @@ -104,6 +135,11 @@ func TestSearch(t *testing.T) { _, err = rs.GetRecord(0) assert.Error(t, err) assert.Contains(t, err.Error(), "result set is not available") + + query, err = NewCqlQuery("computer") + assert.NoError(t, err) + assert.NotNil(t, query) + defer query.Close() } func TestRecordData(t *testing.T) { @@ -122,8 +158,14 @@ func TestSearchUnsupportedSyntaxOnSearch(t *testing.T) { err := conn.Connect(containerHost + ":" + mappedPort) assert.NoError(t, err) + query, err := NewPqfQuery("@attr 1=4 computer") + assert.NoError(t, err) + assert.NotNil(t, query) + defer query.Close() + // getting non-surrogate diagnostic for unsupported record syntax - rs, err := conn.Search("@attr 1=4 computer") + + rs, err := conn.Search(query) assert.Error(t, err) assert.Nil(t, rs) assert.Contains(t, err.Error(), "Record syntax not supported") @@ -141,7 +183,12 @@ func TestSearchUnsupportedSyntaxOnPresent(t *testing.T) { err := conn.Connect(containerHost + ":" + mappedPort) assert.NoError(t, err) - rs, err := conn.Search("@attr 1=4 computer") + query, err := NewPqfQuery("@attr 1=4 computer") + assert.NoError(t, err) + assert.NotNil(t, query) + defer query.Close() + + rs, err := conn.Search(query) assert.NoError(t, err) assert.NotNil(t, rs) @@ -163,7 +210,12 @@ func TestSearchSurrogateDiagnostic(t *testing.T) { err := conn.Connect(containerHost + ":" + mappedPort) assert.NoError(t, err) - rs, err := conn.Search("@attr 1=4 computer") + query, err := NewPqfQuery("@attr 1=4 computer") + assert.NoError(t, err) + assert.NotNil(t, query) + defer query.Close() + + rs, err := conn.Search(query) assert.NoError(t, err) assert.NotNil(t, rs)