Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
b85f3fb
Move esRangeValue
danamansana Dec 9, 2025
a02a058
Move parseParams
danamansana Dec 9, 2025
0beec4c
Refactor nyplSourc/id calculation and move nyplSourceAndId to utils'
danamansana Dec 9, 2025
a749878
Add bodybuilder methods for findByUri
danamansana Dec 9, 2025
683445f
Add nyplSourceAndId call to annotatedMarc
danamansana Dec 9, 2025
5ac57ee
Move itemsByFilter to utils
danamansana Dec 9, 2025
d50ea45
Move buildElasticQuery/Body to bodybuilder
danamansana Dec 9, 2025
2f6c887
Factor out body for search
danamansana Dec 9, 2025
1075d65
Move buildElasticAggregationsBody
danamansana Dec 9, 2025
a0237b7
Move aggregationQueriesForParams to bodybuilder
danamansana Dec 9, 2025
3340f5e
Move mergeAggregationsResposes to utils
danamansana Dec 9, 2025
e072d93
Factor out body for aggregation
danamansana Dec 9, 2025
054d1e4
Move findByUri to async/await
danamansana Dec 9, 2025
2d60b15
Make annotatedMarc async
danamansana Dec 9, 2025
2ec818a
Make deliveryLocationsByBarcode async
danamansana Dec 9, 2025
ba7fcf5
Pull search from promise chain in search
danamansana Dec 11, 2025
9189c1b
Pull massaged response from promise chain in resources#search
danamansana Dec 11, 2025
3297b63
Pull ResourceResultsSerializer.serialize from promise chain in search
danamansana Dec 11, 2025
03d57dc
Remove nested promise
danamansana Dec 11, 2025
1c34469
Remove promise from search
danamansana Dec 11, 2025
6e8af8d
Factor out relevance report
danamansana Dec 11, 2025
cf7edcd
Make aggregation endpoint async
danamansana Dec 11, 2025
eefeb44
Reorganize addInnerHits
danamansana Jan 9, 2026
d26553b
Reorganize bodyForFindByUri except for innerHits
danamansana Jan 9, 2026
7429ad6
Remove addInnerHits from findByUri
danamansana Jan 9, 2026
b386ec1
Reorganize buildElasticBody
danamansana Jan 9, 2026
36d56c5
Fix linting
danamansana Jan 13, 2026
d8249ac
Remove adding source to body in body for search
danamansana Jan 13, 2026
9353887
Add innerHits options
danamansana Jan 15, 2026
15a5e5c
Remove dependence on addInnerHits
danamansana Jan 15, 2026
b5530eb
Add tests for bodyForSearch; remove tests for addInnerHits
danamansana Jan 16, 2026
6cfbd49
Remove remaining references to addInnerHits
danamansana Jan 16, 2026
1c769c9
Add bodybuilder tests
danamansana Jan 16, 2026
c379ba5
Fix merge conflicts
danamansana Jan 20, 2026
a5e190e
Remove options from elastic-query
danamansana Jan 23, 2026
5990165
Remove irrelevant code
danamansana Feb 19, 2026
bd098d5
Merge branch 'main' into scc-5050-2
danamansana Feb 19, 2026
020c75d
Remove spurious merge code
danamansana Feb 19, 2026
ec600c5
new clean branch for adding nyql api tests
chrismulholland May 26, 2026
95bb522
edit to package.json and cleanup
chrismulholland May 26, 2026
358e764
updated README
chrismulholland May 26, 2026
03ec93d
added remaining test and cleanup of console.log statements
chrismulholland May 28, 2026
f983883
Merge branch 'main' into SCC-5389/NYQL-api-tests3
chrismulholland May 28, 2026
c88f349
added code to have npm test ignore the test/integration/nyql folder
chrismulholland May 28, 2026
089647e
updated README
chrismulholland May 29, 2026
65cacad
fixed typo in readme
chrismulholland May 29, 2026
0b5888e
Merge branch 'main' into SCC-5389/NYQL-api-tests3
charmingduchess Jun 3, 2026
7cc1fba
readme typo fix
chrismulholland Jun 3, 2026
33d49e0
Merge branch 'main' into SCC-5389/NYQL-api-tests3
chrismulholland Jun 3, 2026
d8ed837
modified call number search and SASB search tests to make them less r…
chrismulholland Jun 3, 2026
98b52ce
added yml for running nyql api tests on pull requests
chrismulholland Jun 4, 2026
d685aa0
Add scripts/build-mappings-document.js script
nonword Jun 12, 2026
26796bd
Fix browse-term git link
nonword Jun 12, 2026
c8d3843
set up helpers.js, linting changes
chrismulholland Jun 23, 2026
e064560
PR1: trim precision tests and remove API test for PR2
chrismulholland Jun 27, 2026
28adf34
PR1: add env var override, clean up debug comments, update README doc…
chrismulholland Jun 27, 2026
f756dd8
Style: standardize nyql helpers formatting
chrismulholland Jun 27, 2026
daed169
PR1: remove NYQL CI workflow (moved to PR2)
chrismulholland Jun 27, 2026
15dde0c
Fix: remove unused helper import in precision test
chrismulholland Jun 27, 2026
3e67ad0
clean up, mostly moving constants to helper file
chrismulholland Jun 30, 2026
60413dd
linting fix
chrismulholland Jun 30, 2026
e8a649b
removed blank line in helpers file
chrismulholland Jun 30, 2026
60f15cb
Merge pull request #767 from NYPL/SCC-5452/NYQL-api-tests-refactor
chrismulholland Jul 2, 2026
06548b7
add NYQL-api-tests.yml
chrismulholland Jul 2, 2026
bd9bab4
deleted file NYQL-api-tests.yml. added step to integration-tests.yml…
chrismulholland Jul 6, 2026
a5467e6
updated API testing section of readme
chrismulholland Jul 7, 2026
05a7be7
Attempt to resolve merge conflicts
danamansana Jul 8, 2026
b21c70a
Update parseparams in merge
danamansana Jul 8, 2026
4fb05d3
Add cql and filters to existing refactor
danamansana Jul 9, 2026
267d570
Fix tests
danamansana Jul 9, 2026
ca466bc
Remove console log
danamansana Jul 9, 2026
3468b99
Remove console log
danamansana Jul 9, 2026
e94c11f
added env conditional to Run NYQL API tests step
chrismulholland Jul 9, 2026
35acf64
updated API testing section of readme
chrismulholland Jul 9, 2026
aca96af
copy env logic from loadConfig into nyql helpers file
chrismulholland Jul 10, 2026
485f616
Merge pull request #770 from NYPL/SCC-5452/NYQL-api-tests-refactor_PR2
chrismulholland Jul 13, 2026
663ed12
Merge pull request #764 from NYPL/SCC-5384-mappings-script
nonword Jul 14, 2026
277d628
Merge pull request #771 from NYPL/scc-5050-2-fix-merge
danamansana Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ jobs:
env:
ENV: ${{env.ENV}}
run: node test/integration/delivery-locations-by-barcode.test.js
- name: Run NYQL API tests
run: npm run nyql-api-test
41 changes: 29 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ To start the container with AWS creds so that the app can decrypt config from `c
```

After making changes, rebuild the image:

```
docker-compose build
```
Expand Down Expand Up @@ -61,9 +62,10 @@ This app uses a [PRs Target Main, Merge to Deployment Branches](https://github.c
### Deploying

App deploys through [GH Actions](./.github/workflows/test-and-deploy.yml) to ECS when updates are made to deployment branches:
- `qa`: discovery-api-qa.nypl.org
- `qa2`: discovery-api-qa2.nypl.org
- `production`: discovery-api-production.nypl.org

- `qa`: discovery-api-qa.nypl.org
- `qa2`: discovery-api-qa2.nypl.org
- `production`: discovery-api-production.nypl.org

## Testing

Expand All @@ -73,6 +75,22 @@ Run all tests:
npm test
```

### API testing

NYQL integration tests are a QA contract suite in `test/integration/nyql/`. They verify that NYQL queries return the expected results against live QA data and are not run by default during `npm test` to avoid adding latency to CI. They do, however, run after deploys to QA and Production. The call to run them has been added to integration-tests.yml file.

To run them locally:

```bash
npm run nyql-api-test
```

To override the QA URL (e.g., to test against your local environment):

```bash
NYQL_TEST_BASE_URL=http://localhost:3000/api/v0.1 npm run nyql-api-test
```

### Adding fixtures

Almost all HTTP dependencies are rerouted to fixtures (except for requesting nypl-core mapping files). All fixtures can be updated dynamically (using creds in `./config/production.env`) via the following:
Expand Down Expand Up @@ -113,7 +131,7 @@ For example, you could `q` alone without filters to retrieve records matching wa

> /resources?q=war dateStartYear:[1999 TO 2012]

.. Or get things created in 1999 *or later*:
.. Or get things created in 1999 _or later_:

> /resources?q=dateStartYear:>1999

Expand All @@ -131,7 +149,7 @@ Filters are applied using a `filters` param that expects this syntax on the quer

Where `property*` is one of: 'owner', 'subjectLiteral', 'holdingLocation', 'deliveryLocation', 'language', 'materialType', 'mediaType', 'carrierType', 'publisher', 'contributor', 'creator', 'issuance', 'createdYear', 'dateAfter', or 'dateBefore'.

The value given should be *exact*. Do not use quotes.
The value given should be _exact_. Do not use quotes.

For example, to filter by English language:

Expand Down Expand Up @@ -165,16 +183,16 @@ Note that dateStartYear and dateEndYear are often very broad, causing the above

All search queries support:

- `page`: Integer. Page number to retrieve. (Default 1)
- `per_page`: Integer. Number of results to retrieve at a time. Default 50. Valid range 0-100.
- `page`: Integer. Page number to retrieve. (Default 1)
- `per_page`: Integer. Number of results to retrieve at a time. Default 50. Valid range 0-100.

### Sorting

All search queries support `sort`ing on:

- `title`: Case insensitive sort on title. Default ascending.
- `date`: Sort on dateStartYear. Default descending.
- `creator`: Case insensitive sort on first creator. (Note the "first" creator may not be the best creator.) Default ascending.
- `title`: Case insensitive sort on title. Default ascending.
- `date`: Sort on dateStartYear. Default descending.
- `creator`: Case insensitive sort on first creator. (Note the "first" creator may not be the best creator.) Default ascending.

To set a non-default direction use `sort_direction=(asc|desc)`. To sort by relevance (i.e. keyword query), omit the `sort` param.

Expand Down Expand Up @@ -210,7 +228,6 @@ Note that `page=` is not supported for aggregations (ES doesn't seem to offer a

There is currently one feature flag in this app, which is 'no-on-site-edd'. When it is set, all onsite items have an eddRequestable property of false.

NB: numAvailable and numItem*Parsed counts do not **exclude** the e-item, but these items are not indexed with statuses, volumes, or date ranges, and are therefore not actually included in this count.
NB: numAvailable and numItem\*Parsed counts do not **exclude** the e-item, but these items are not indexed with statuses, volumes, or date ranges, and are therefore not actually included in this count.

NB: As the table above indicates, there is a mismatch between what the front end and API regard as "electronic items". As far as the API is concerned, there is only at most ONE electronic item, which can have many electronic locator values. `numElectronicResources` counts these locator values, but the other item count values treat all the electronic resources as a single item.

5 changes: 2 additions & 3 deletions data/annotated-marc-rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,7 @@
"marcIndicatorRegExp": "^340",
"subfieldSpec": {
"subfields": [
"6", "2"
"6"
],
"directive": "exclude"
},
Expand Down Expand Up @@ -724,7 +724,6 @@
"marcIndicatorRegExp": "^386",
"subfieldSpec": {
"subfields": [
"n",
"2",
"6"
],
Expand Down Expand Up @@ -2860,4 +2859,4 @@
"label": "Research Call Number",
"directive": "include"
}
]
]
41 changes: 40 additions & 1 deletion lib/elasticsearch/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,47 @@ const AGGREGATIONS_SPEC = {
collection: { terms: { field: 'collectionIds' } }
}

const ITEM_FILTER_AGGREGATIONS = {
item_location: { nested: { path: 'items' }, aggs: { _nested: { terms: { size: 100, field: 'items.holdingLocation_packed' } } } },
item_status: { nested: { path: 'items' }, aggs: { _nested: { terms: { size: 100, field: 'items.status_packed' } } } },
item_format: { nested: { path: 'items' }, aggs: { _nested: { terms: { size: 100, field: 'items.formatLiteral' } } } }
}

// Configure sort fields:
const SORT_FIELDS = {
title: {
initialDirection: 'asc',
field: 'title_sort'
},
date: {
initialDirection: 'desc',
field: 'dateStartYear'
},
creator: {
initialDirection: 'asc',
field: 'creator_sort'
},
relevance: {}
}

// The following fields can be excluded from ES responses because we don't pass them to client:
const EXCLUDE_FIELDS = [
'uris',
'*_packed',
'*_sort',
'items.*_packed',
'contentsTitle',
'suppressed',
// Hide contributor and creator transformed fields:
'*WithoutDates',
'*Normalized'
]

module.exports = {
SEARCH_SCOPES,
FILTER_CONFIG,
AGGREGATIONS_SPEC
AGGREGATIONS_SPEC,
ITEM_FILTER_AGGREGATIONS,
EXCLUDE_FIELDS,
SORT_FIELDS
}
219 changes: 219 additions & 0 deletions lib/elasticsearch/elastic-body-builder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
const { EXCLUDE_FIELDS, ITEM_FILTER_AGGREGATIONS, SORT_FIELDS, AGGREGATIONS_SPEC } = require('./config')
const { innerHits, itemsQueryContext, itemsFilterContext } = require('./elastic-query-filter-builder')
const ApiRequest = require('../api-request')
const ElasticQueryBuilder = require('../elasticsearch/elastic-query-builder')
const { CqlQuery } = require('./cql_query_builder')

const { InvalidQuerySyntaxError } = require('../errors')

const bodyForFindByUri = function (recapBarcodesByStatus, params) {
const paramsIncludesItemLevelFiltering = Object.keys(params)
.filter((param) => param.startsWith('item_')).length > 0

const returnAllItems = params.all_items && !paramsIncludesItemLevelFiltering

const excludes = returnAllItems ? EXCLUDE_FIELDS.filter((field) => field !== '*_sort') : EXCLUDE_FIELDS.concat(['items'])

const aggregations = params.include_item_aggregations
? { aggregations: ITEM_FILTER_AGGREGATIONS }
: {}

const itemsOptions = {
size: params.items_size,
from: params.items_from,
merge_checkin_card_items: params.merge_checkin_card_items,
query: {
volume: params.item_volume,
date: params.item_date,
format: params.item_format,
location: params.item_location,
status: params.item_status,
itemUri: params.itemUri
},
unavailable_recap_barcodes: recapBarcodesByStatus['Not Available']
}

const queryFilter = { filter: !returnAllItems ? [innerHits(itemsOptions)] : [] }

const body = {
_source: {
excludes
},
size: 1,
query: {
bool: {
must: [
{
term: {
uri: params.uri
}
}
],
...queryFilter
}
},
...aggregations
}

return body
}

/**
* Given GET params, returns a plainobject suitable for use in a ES query.
*
* @param {object} params - A hash of request params including `filters`,
* `search_scope`, `q`
*
* @return {object} ES query object suitable to be POST'd to ES endpoint
*/
const buildElasticQuery = function (params, options = {}) {
const request = ApiRequest.fromParams(params)

if (params.search_scope === 'cql') {
const cqlQuery = new CqlQuery(params.q)
let parsed
try {
parsed = cqlQuery.displayParsed()
} catch (e) {
throw new InvalidQuerySyntaxError('Unknown parsing error. Error most likely near end of string')
}
if (parsed.error) {
throw new InvalidQuerySyntaxError(parsed.error)
}
if (!parsed.parsed) {
throw new InvalidQuerySyntaxError('Unknown parsing error. Error most likely near end of string')
}
return cqlQuery.buildEsQuery(request)
}

const builder = ElasticQueryBuilder.forApiRequest(request, options)
return builder.query.toJson()
}

/**
* 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 buildElasticBody = function (params, options = {}) {
// Apply sort:
let direction
let field

if (params.sort === 'relevance') {
field = '_score'
direction = 'desc'
} else {
field = SORT_FIELDS[params.sort].field || params.sort
direction = params.sort_direction || SORT_FIELDS[params.sort].initialDirection
}

const from = params.per_page && params.page ? { from: params.per_page * (params.page - 1) } : {}
const size = params.per_page ? { size: params.per_page } : {}

return {
...from,
...size,
query: buildElasticQuery(params, options),
sort: [{ [field]: direction }, { uri: 'asc' }]
}
}

const bodyForSearch = function (params) {
const itemsOptions = { merge_checkin_card_items: params.merge_checkin_card_items }

const body = Object.assign(
buildElasticBody(params, { items: itemsOptions }),
{
_source: {
excludes: EXCLUDE_FIELDS.concat(['items'])
}
}
)

return body
}

const buildElasticAggregationsBody = (params, aggregateProps) => {
// Add an `aggregations` entry to the ES body describing the aggretations
// we want. Set the `size` property to per_page (default 50) for each.
// https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-size
const aggregations = aggregateProps.reduce((aggs, prop) => {
aggs[prop] = AGGREGATIONS_SPEC[prop]
// Only set size for terms aggs for now:
if (aggs[prop].terms) {
aggs[prop].terms.size = params.per_page
}
return aggs
}, {})

return Object.assign(
buildElasticBody(params),
{ size: 0, aggregations }
)
}

/**
* Given a params hash, returns an array of ES queries for fetching relevant aggregations.
*/
const aggregationQueriesForParams = (params) => {
// Build the complete set of distinct aggregation queries we need to run
// depending on active filters. We want:
// - one agg representing the counts for all properties _not_ used in filter
// - one agg each for each property that is used in a filter, but counts should exclude that filter

// Build the standard aggregation:
const unfilteredAggregationProps = Object.keys(AGGREGATIONS_SPEC)
// Aggregate on all properties that aren't involved in filters:
.filter((prop) => !Object.keys(params.filters || {}).includes(prop))
const queries = [buildElasticAggregationsBody(params, unfilteredAggregationProps)]

// Now append all property-specific aggregation queries (one for each
// distinct property used in a filter):
return queries.concat(
Object.entries(params.filters || {})
// Only consider filters that are also aggregations:
.filter(([prop, values]) => Object.keys(AGGREGATIONS_SPEC).includes(prop))
.map(([prop, values]) => {
const aggFilters = structuredClone(params.filters)
// For this aggregation, don't filter on namesake property:
delete aggFilters[prop]

// Build query for single aggregation:
const modifiedParams = Object.assign({}, params, { filters: aggFilters })
return buildElasticAggregationsBody(modifiedParams, [prop])
})
)
}

const bodyForAggregation = (params) => {
const aggregations = {}
aggregations[params.field] = AGGREGATIONS_SPEC[params.field]

// If it's a terms agg, we can apply per_page:
if (aggregations[params.field].terms) {
aggregations[params.field].terms.size = params.per_page
}

return Object.assign(
buildElasticBody(params),
{
size: 0,
aggregations
}
)
}

module.exports = {
bodyForFindByUri,
itemsFilterContext,
itemsQueryContext,
buildElasticQuery,
buildElasticBody,
bodyForSearch,
buildElasticAggregationsBody,
aggregationQueriesForParams,
bodyForAggregation
}
Loading
Loading