diff --git a/app.js b/app.js index ff9e2877..571f56a7 100644 --- a/app.js +++ b/app.js @@ -33,6 +33,7 @@ app.init = async () => { require('./lib/resources')(app) require('./lib/subjects')(app) + require('./lib/contributors')(app) require('./lib/vocabularies')(app) // routes diff --git a/config/qa.env b/config/qa.env index 4e2ddad1..627e25b8 100644 --- a/config/qa.env +++ b/config/qa.env @@ -1,6 +1,7 @@ ENCRYPTED_ELASTICSEARCH_URI=AQECAHh7ea2tyZ6phZgT4B9BDKwguhlFtRC6hgt+7HbmeFsrsgAAAJYwgZMGCSqGSIb3DQEHBqCBhTCBggIBADB9BgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDMIkDoQ9C/cCDCAq1wIBEIBQ+L3OgUGeOW9rs1CWkhpBjwM4LbbVRFIWedqew4UXIeSNMJ8cO9SNe4YGCUIoKwCDYt7W7ip3VtDRRRMVvz6QJw+Eg8ugTMVs2pbNFGNvaAQ= + ENCRYPTED_RESOURCES_INDEX=AQECAHh7ea2tyZ6phZgT4B9BDKwguhlFtRC6hgt+7HbmeFsrsgAAAHIwcAYJKoZIhvcNAQcGoGMwYQIBADBcBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDPMBVNbSFDq16QAs4AIBEIAvHrJZjGewR7g4oT5oifQUDGTj2SgYibnrhU05uBatHEVYz/mOawAVrjt/1oxPqv4= -SUBJECTS_INDEX=browse-qa-2026-01-15 +BROWSE_INDEX=browse-qa-2026-01-15 ENCRYPTED_ELASTICSEARCH_API_KEY=AQECAHh7ea2tyZ6phZgT4B9BDKwguhlFtRC6hgt+7HbmeFsrsgAAAJ4wgZsGCSqGSIb3DQEHBqCBjTCBigIBADCBhAYJKoZIhvcNAQcBMB4GCWCGSAFlAwQBLjARBAx+kryf2KUmGdBYD9sCARCAV3ygz3eXIdq8JX/wpG9JRWlTNMRcpNE1qT0zNlN4t+ZvXEoedLQa/3p1YjgHw06GIAdA9xtkMV4eH9a1K8uCvjP8XxxNKekcMj59TlResnu9QF3r7pGXuQ== ENCRYPTED_SCSB_URL=AQECAHh7ea2tyZ6phZgT4B9BDKwguhlFtRC6hgt+7HbmeFsrsgAAAH8wfQYJKoZIhvcNAQcGoHAwbgIBADBpBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDBKllElmWYLxGOGopQIBEIA8JJyKde/8m8iCJGKR5D8HoTJhXHeyvw9eIDeuUNKiXLfJwoVz+PDAZSxkCQtM9O91zGhXbe3l6Bk1RlYJ diff --git a/config/test.env b/config/test.env index 8c796074..3883094a 100644 --- a/config/test.env +++ b/config/test.env @@ -1,6 +1,6 @@ ELASTICSEARCH_URI=encrypted-elasticsearch-uri RESOURCES_INDEX=test-resources-index -SUBJECTS_INDEX=test-subjects-index +BROWSE_INDEX=test-browse-index SCSB_URL=encrypted-scsb-url SCSB_API_KEY=encrypted-scsb-api-key diff --git a/lib/contributors.js b/lib/contributors.js new file mode 100644 index 00000000..60107475 --- /dev/null +++ b/lib/contributors.js @@ -0,0 +1,121 @@ +const { parseBrowseParams } = require('./elasticsearch/browse-utils') + +const ApiRequest = require('./api-request') +const ElasticQueryBrowseBuilder = require('./elasticsearch/elastic-query-browse-builder') + +const BROWSE_INDEX = process.env.BROWSE_INDEX + +const parseNameAndRole = (delimitedString) => { + if (!delimitedString.includes('|')) { + return { name: delimitedString, role: null } + } + const [name, role] = delimitedString.split('|') + return { name, role } +} + +module.exports = function (app, _private = null) { + app.contributors = {} + + app.contributors.browse = function (params, opts, request) { + app.logger.debug('Unparsed params: ', params) + params = parseBrowseParams(params) + + app.logger.debug('Parsed params: ', params) + + const body = buildElasticContributorsBody(params) + + app.logger.debug('Contrbutors#browse', BROWSE_INDEX, body) + + return app.esClient.search(body, process.env.BROWSE_INDEX) + .then((resp) => { + return { + '@type': 'contributorList', + page: params.page, + per_page: params.per_page, + totalResults: resp.hits?.total?.value, + contributors: resp.hits?.hits?.reduce((workingResponse, hit) => { + if (hit.matched_queries?.[0] === 'preferredTerm' || hit.matched_queries?.[0] === 'preferredTermPrefix') { // if match is on preferredTerm, use that regardless of variant matches + const { name, role } = parseNameAndRole(hit._source.preferredTerm) + + let contributorData = workingResponse.find(item => item.termLabel === name) + + if (!contributorData) { + contributorData = { + '@type': 'preferredTerm', + termLabel: name + } + workingResponse.push(contributorData) + } + + if (role) { + // just add the role count to the top level response + const roleCount = { role, count: hit._source.count } + if (!contributorData.roleCounts) { + contributorData.roleCounts = [] + } + contributorData.roleCounts.push(roleCount) + } else { + // top-level contributor object + contributorData.count = hit._source.count + contributorData.broaderTerms = hit._source.broaderTerms?.map((term) => ({ termLabel: term })) + contributorData.narrowerTerms = hit._source.narrowerTerms?.map((term) => ({ termLabel: term })) + contributorData.seeAlso = hit._source.seeAlso?.map((term) => ({ termLabel: term })) + contributorData.uri = hit._source.uri + } + } else { + // Match was on a variant- use that in the response + const matchedVariantTerm = hit.inner_hits.variants.hits.hits[0]._source.variant + + const variantData = { + '@type': 'variant', + termLabel: matchedVariantTerm, + preferredTerms: [ + { + termLabel: hit._source.preferredTerm, + count: hit._source.count + } + ] + } + + workingResponse.push(variantData) + } + + return workingResponse + }, []) + } + }) + } + + // For unit testing + if (_private && typeof _private === 'object') { + _private.buildElasticContributorsBody = buildElasticContributorsBody + _private.parseBrowseParams = parseBrowseParams + } +} + +/** + * Given GET params, returns a plainobject with `from`, `size`, `query`, + * `sort`, and any other params necessary to perform the ES query based + * on the GET params. + * + * @return {object} An object that can be posted directly to ES + */ +const buildElasticContributorsBody = function (params) { + const body = { + from: (params.per_page * (params.page - 1)), + size: params.per_page + } + + const request = ApiRequest.fromParams(params) + const builder = ElasticQueryBrowseBuilder.forApiRequest(request) + + body.query = builder.query.toJson() + + // match only termType 'contributor' + body.query.bool.must.push({ term: { termType: { value: 'contributor' } } }) + + // Exclude items that have count == 0 + body.query.bool.must.push({ range: { count: { gt: 0 } } }) + + return body +} diff --git a/lib/elasticsearch/browse-utils.js b/lib/elasticsearch/browse-utils.js new file mode 100644 index 00000000..48a97fd0 --- /dev/null +++ b/lib/elasticsearch/browse-utils.js @@ -0,0 +1,29 @@ +const { parseParams } = require('../util') + +// Default sort orders for different search scopes +const SEARCH_SCOPE_SORT_ORDER = { + has: 'count', + starts_with: 'termLabel' +} + +const SEARCH_SCOPES = [ + 'has', + 'starts_with' +] + +const SORT_FIELDS = [ + 'termLabel', + 'count', + 'relevance' +] + +exports.parseBrowseParams = function (params) { + return parseParams(params, { + q: { type: 'string' }, + page: { type: 'int', default: 1 }, + per_page: { type: 'int', default: 50, range: [0, 100] }, + sort: { type: 'string', range: SORT_FIELDS, default: SEARCH_SCOPE_SORT_ORDER[params.search_scope] || 'termLabel' }, + sort_direction: { type: 'string', range: ['asc', 'desc'] }, + search_scope: { type: 'string', range: SEARCH_SCOPES, default: '' } + }) +} diff --git a/lib/elasticsearch/elastic-query-subjects-builder.js b/lib/elasticsearch/elastic-query-browse-builder.js similarity index 93% rename from lib/elasticsearch/elastic-query-subjects-builder.js rename to lib/elasticsearch/elastic-query-browse-builder.js index 1a3b8fe6..36615241 100644 --- a/lib/elasticsearch/elastic-query-subjects-builder.js +++ b/lib/elasticsearch/elastic-query-browse-builder.js @@ -1,6 +1,6 @@ const ElasticQuery = require('./elastic-query') -class ElasticQuerySubjectsBuilder { +class ElasticQueryBrowseBuilder { constructor (apiRequest) { this.request = apiRequest this.query = new ElasticQuery() @@ -63,8 +63,8 @@ class ElasticQuerySubjectsBuilder { * Create a ElasticQueryBuilder for given ApiRequest instance */ static forApiRequest (request) { - return new ElasticQuerySubjectsBuilder(request) + return new ElasticQueryBrowseBuilder(request) } } -module.exports = ElasticQuerySubjectsBuilder +module.exports = ElasticQueryBrowseBuilder diff --git a/lib/preflight_check.js b/lib/preflight_check.js index dc5ca102..08a0fa2a 100644 --- a/lib/preflight_check.js +++ b/lib/preflight_check.js @@ -5,7 +5,7 @@ const requiredEnvVars = [ 'SCSB_API_KEY', 'ELASTICSEARCH_URI', 'RESOURCES_INDEX', - 'SUBJECTS_INDEX', + 'BROWSE_INDEX', 'NYPL_API_BASE_URL', 'NYPL_OAUTH_URL', 'NYPL_OAUTH_ID', diff --git a/lib/subjects.js b/lib/subjects.js index 5a5e533b..db12fe7c 100644 --- a/lib/subjects.js +++ b/lib/subjects.js @@ -1,37 +1,9 @@ -const { parseParams } = require('../lib/util') +const { parseBrowseParams } = require('./elasticsearch/browse-utils') const ApiRequest = require('./api-request') -const ElasticQuerySubjectsBuilder = require('./elasticsearch/elastic-query-subjects-builder') +const ElasticQueryBrowseBuilder = require('./elasticsearch/elastic-query-browse-builder') -const SUBJECTS_INDEX = process.env.SUBJECTS_INDEX - -const SEARCH_SCOPES = [ - 'has', - 'starts_with' -] - -const SORT_FIELDS = [ - 'termLabel', - 'count', - 'relevance' -] - -// Default sort orders for different search scopes -const SEARCH_SCOPE_SORT_ORDER = { - has: 'count', - starts_with: 'termLabel' -} - -const parseBrowseParams = function (params) { - return parseParams(params, { - q: { type: 'string' }, - page: { type: 'int', default: 1 }, - per_page: { type: 'int', default: 50, range: [0, 100] }, - sort: { type: 'string', range: SORT_FIELDS, default: SEARCH_SCOPE_SORT_ORDER[params.search_scope] || 'termLabel' }, - sort_direction: { type: 'string', range: ['asc', 'desc'] }, - search_scope: { type: 'string', range: SEARCH_SCOPES, default: '' } - }) -} +const BROWSE_INDEX = process.env.BROWSE_INDEX module.exports = function (app, _private = null) { app.subjects = {} @@ -44,9 +16,9 @@ module.exports = function (app, _private = null) { const body = buildElasticSubjectsBody(params) - app.logger.debug('Subjects#browse', SUBJECTS_INDEX, body) + app.logger.debug('Subjects#browse ' + BROWSE_INDEX) - return app.esClient.search(body, process.env.SUBJECTS_INDEX) + return app.esClient.search(body, BROWSE_INDEX) .then((resp) => { return { '@type': 'subjectList', @@ -105,7 +77,7 @@ const buildElasticSubjectsBody = function (params) { } const request = ApiRequest.fromParams(params) - const builder = ElasticQuerySubjectsBuilder.forApiRequest(request) + const builder = ElasticQueryBrowseBuilder.forApiRequest(request) body.query = builder.query.toJson() @@ -159,6 +131,9 @@ const buildElasticSubjectsBody = function (params) { } } + // match only termType 'subject' + body.query.bool.must.push({ term: { termType: { value: 'subject' } } }) + // Exclude items that have count == 0 body.query.bool.must.push({ range: { count: { gt: 0 } } }) diff --git a/routes/resources.js b/routes/resources.js index dbc159d0..9b7e394f 100644 --- a/routes/resources.js +++ b/routes/resources.js @@ -52,6 +52,14 @@ module.exports = function (app) { .catch((error) => next(error)) }) + app.get(`/api/v${VER}/discovery/browse/contributors`, function (req, res, next) { + const params = req.query + + return app.contributors.browse(params, { baseUrl: app.baseUrl }, req) + .then((resp) => respond(res, resp, params)) + .catch((error) => next(error)) + }) + app.get(`/api/v${VER}/discovery/vocabularies`, function (req, res, next) { const params = Object.assign({}, req.query, req.params) diff --git a/test/elastic-query-subjects-builder.test.js b/test/elastic-query-browse-builder.test.js similarity index 88% rename from test/elastic-query-subjects-builder.test.js rename to test/elastic-query-browse-builder.test.js index a50f7539..e9830b07 100644 --- a/test/elastic-query-subjects-builder.test.js +++ b/test/elastic-query-browse-builder.test.js @@ -1,13 +1,13 @@ const { expect } = require('chai') -const ElasticQuerySubjectsBuilder = require('../lib/elasticsearch/elastic-query-subjects-builder') +const ElasticQueryBrowseBuilder = require('../lib/elasticsearch/elastic-query-browse-builder') const ApiRequest = require('../lib/api-request') -describe('ElasticQuerySubjectsBuilder', () => { +describe('ElasticQueryBrowseBuilder', () => { describe('search_scope=""', () => { it('applies subject term clauses to query', () => { const request = new ApiRequest({ q: 'toast' }) - const inst = ElasticQuerySubjectsBuilder.forApiRequest(request) + const inst = ElasticQueryBrowseBuilder.forApiRequest(request) const query = inst.query.toJson() @@ -39,7 +39,7 @@ describe('ElasticQuerySubjectsBuilder', () => { describe('search_scope="has"', () => { it('applies subject match clauses to query', () => { const request = new ApiRequest({ q: 'toast bread', search_scope: 'has' }) - const inst = ElasticQuerySubjectsBuilder.forApiRequest(request) + const inst = ElasticQueryBrowseBuilder.forApiRequest(request) const query = inst.query.toJson() @@ -75,7 +75,7 @@ describe('ElasticQuerySubjectsBuilder', () => { describe('search_scope="starts_with"', () => { it('applies subject_prefix clauses to query', () => { const request = new ApiRequest({ q: 'toast', search_scope: 'starts_with' }) - const inst = ElasticQuerySubjectsBuilder.forApiRequest(request) + const inst = ElasticQueryBrowseBuilder.forApiRequest(request) const query = inst.query.toJson()