Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ app.init = async () => {

require('./lib/resources')(app)
require('./lib/subjects')(app)
require('./lib/contributors')(app)
require('./lib/vocabularies')(app)

// routes
Expand Down
3 changes: 2 additions & 1 deletion config/qa.env
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion config/test.env
Original file line number Diff line number Diff line change
@@ -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
Expand Down
121 changes: 121 additions & 0 deletions lib/contributors.js
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 29 additions & 0 deletions lib/elasticsearch/browse-utils.js
Original file line number Diff line number Diff line change
@@ -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: '' }
})
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const ElasticQuery = require('./elastic-query')

class ElasticQuerySubjectsBuilder {
class ElasticQueryBrowseBuilder {
constructor (apiRequest) {
this.request = apiRequest
this.query = new ElasticQuery()
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/preflight_check.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
43 changes: 9 additions & 34 deletions lib/subjects.js
Original file line number Diff line number Diff line change
@@ -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 = {}
Expand All @@ -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',
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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 } } })

Expand Down
8 changes: 8 additions & 0 deletions routes/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down
Loading