From 5387d39c8eacc99acc072f988ef0741c8d02f908 Mon Sep 17 00:00:00 2001 From: Laton Vermette <1619661+latonv@users.noreply.github.com> Date: Wed, 28 Jun 2023 15:05:51 -0700 Subject: [PATCH 1/5] Add models for collection page extra info --- index.ts | 6 ++ src/models/hit-types/hit.ts | 13 ++-- src/responses/collection-extra-info.ts | 55 ++++++++++++++ src/responses/search-response-details.ts | 20 ++++- .../responses/search-response-details.test.ts | 74 +++++++++++++++++++ 5 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 src/responses/collection-extra-info.ts diff --git a/index.ts b/index.ts index 4d4b7279..faaa1e30 100644 --- a/index.ts +++ b/index.ts @@ -32,6 +32,12 @@ export { export { SearchResponse } from './src/responses/search-response'; export { SearchResponseHeader } from './src/responses/search-response-header'; export { SearchResponseParams } from './src/responses/search-response-params'; +export { + CollectionExtraInfo, + CollectionSearchDocs, + RelatedCollection, + UserDetails, +} from './src/responses/collection-extra-info'; export { MetadataSearchBackend } from './src/search-backend/metadata-search-backend'; export { FulltextSearchBackend } from './src/search-backend/fulltext-search-backend'; diff --git a/src/models/hit-types/hit.ts b/src/models/hit-types/hit.ts index 03e185b0..f97d4076 100644 --- a/src/models/hit-types/hit.ts +++ b/src/models/hit-types/hit.ts @@ -8,11 +8,14 @@ import type { TextHit } from './text-hit'; export type HitType = 'item' | 'text'; /** - * Applying this to the Result type forces Intellisense to present Result as - * a type in its own right, and not as the underlying merge type it aliases. - * Really just to keep things clean at the call site. + * Additional information provided by the PPS about hits, separately from + * their fields map. */ -interface PreserveAlias {} // eslint-disable-line @typescript-eslint/no-empty-interface +interface HitInfo { + hit_type?: HitType; + index?: string; + service_backend?: string; +} /** * Result is an expansive type definition encompassing all the optional @@ -20,4 +23,4 @@ interface PreserveAlias {} // eslint-disable-line @typescript-eslint/no-empty-in * ('hit') returned by the various search backends. (Most metadata * properties are optional anyway). */ -export type SearchResult = Partial & PreserveAlias; +export type SearchResult = Partial & HitInfo; diff --git a/src/responses/collection-extra-info.ts b/src/responses/collection-extra-info.ts new file mode 100644 index 00000000..b7525c2b --- /dev/null +++ b/src/responses/collection-extra-info.ts @@ -0,0 +1,55 @@ +import { Metadata } from '../models/metadata'; + +/** + * Extra info about the target collection that is returned for + * the `collection_details` page type. + */ +export interface CollectionExtraInfo { + thumbnail_url?: string; + search_doc_fields?: CollectionSearchDocs; + has_items_with_searchable_text?: boolean; + forum_identifier?: string; + forum_count?: number; + review_count?: number; + related_collection_details?: RelatedCollection[]; + uploader_details?: UserDetails; + contributors_details?: UserDetails[]; + public_metadata?: typeof Metadata.prototype.rawMetadata; +} + +/** + * Fields from the search docs returned for the `collection_details` + * page type. + */ +export interface CollectionSearchDocs { + item_count?: number; + files_count?: number; + collection_size?: number; + collection_files_count?: number; + month?: number; + week?: number; + downloads?: number; + num_favorites?: number; + title_message?: string | null; + primary_collection?: string | null; +} + +/** + * Info about a related collection, as returned for the `collection_details` + * page type. + */ +export interface RelatedCollection { + identifier: string; + title?: string; + item_count?: number; +} + +/** + * Info about a user (e.g., uploaders/contributors), as returned for + * the `collection_details` page type. + */ +export interface UserDetails { + screen_name?: string; + useritem?: string; + is_admin?: boolean; +} diff --git a/src/responses/search-response-details.ts b/src/responses/search-response-details.ts index c1ff1b26..125fe8c1 100644 --- a/src/responses/search-response-details.ts +++ b/src/responses/search-response-details.ts @@ -3,6 +3,7 @@ import { Aggregation } from '../models/aggregation'; import { SearchResult, HitType } from '../models/hit-types/hit'; import { ItemHit } from '../models/hit-types/item-hit'; import { TextHit } from '../models/hit-types/text-hit'; +import { CollectionExtraInfo } from './collection-extra-info'; import type { SearchHitSchema } from './search-hit-schema'; /** @@ -12,6 +13,7 @@ export interface SearchResponseBody { hits: SearchResponseHits; aggregations?: Record; collection_titles?: Record; + collection_extra_info?: CollectionExtraInfo; } /** @@ -59,6 +61,12 @@ export class SearchResponseDetails { */ collectionTitles?: Record; + /** + * Extra info about the target collection, returned when the page type is + * `collection_details`. + */ + collectionExtraInfo?: CollectionExtraInfo; + /** * The hit schema for this response */ @@ -66,13 +74,13 @@ export class SearchResponseDetails { constructor(body: SearchResponseBody, schema: SearchHitSchema) { this.schema = schema; - const hitType = schema?.hit_type; + const schemaHitType = schema?.hit_type; this.totalResults = body?.hits?.total ?? 0; this.returnedCount = body?.hits?.returned ?? 0; this.results = body?.hits?.hits?.map((hit: SearchResult) => - SearchResponseDetails.createResult(hitType, hit) + SearchResponseDetails.createResult(hit.hit_type ?? schemaHitType, hit) ) ?? []; // Construct Aggregation objects @@ -89,6 +97,10 @@ export class SearchResponseDetails { if (body?.collection_titles) { this.collectionTitles = body.collection_titles; } + + if (body?.collection_extra_info) { + this.collectionExtraInfo = body.collection_extra_info; + } } /** @@ -104,8 +116,8 @@ export class SearchResponseDetails { case 'text': return new TextHit(result); default: - // The hit type doesn't tell us what to construct, so just return the input as-is - return result; + // The hit type doesn't tell us what to construct, so just construct an ItemHit + return new ItemHit(result); } } } diff --git a/test/responses/search-response-details.test.ts b/test/responses/search-response-details.test.ts index 5f7cdcf1..4be1a78a 100644 --- a/test/responses/search-response-details.test.ts +++ b/test/responses/search-response-details.test.ts @@ -30,6 +30,9 @@ const responseBody: SearchResponseBody = { collection_titles: { baz: 'Baz Collection', }, + collection_extra_info: { + thumbnail_url: 'foo', + }, }; describe('SearchResponseDetails', () => { @@ -63,6 +66,46 @@ describe('SearchResponseDetails', () => { expect(details.results[1].collection?.value).to.equal('baz'); }); + it('prefers hit-type specified on hit itself over schema hit-type', () => { + const responseBodyWithTextHit = Object.assign( + {}, + responseBody + ) as SearchResponseBody; + responseBodyWithTextHit.hits = { + total: 2, + returned: 2, + hits: [ + { + hit_type: 'text', + fields: { + identifier: 'foo', + mediatype: 'texts', + }, + }, + { + fields: { + identifier: 'bar', + collection: ['baz'], + }, + }, + ], + }; + + const responseSchema = { + hit_type: 'item' as HitType, + field_properties: {}, + }; + + const details = new SearchResponseDetails(responseBody, responseSchema); + expect(details.results[0]).to.be.instanceOf(TextHit); // From hit, not schema + expect(details.results[0].identifier).to.equal('foo'); + expect(details.results[0].mediatype?.value).to.equal('texts'); + expect(details.results[0].creator?.value).to.be.undefined; + expect(details.results[1]).to.be.instanceOf(ItemHit); // From schema + expect(details.results[1].identifier).to.equal('bar'); + expect(details.results[1].collection?.value).to.equal('baz'); + }); + it('includes aggregations', () => { const aggsResponseBody: SearchResponseBody = { hits: { @@ -131,4 +174,35 @@ describe('SearchResponseDetails', () => { expect(details.results.length).to.equal(2); expect(details.collectionTitles).to.be.undefined; }); + + it('provides access to collection extra info', () => { + const responseSchema = { + hit_type: 'item' as HitType, + field_properties: {}, + }; + + const details = new SearchResponseDetails(responseBody, responseSchema); + expect(details.results.length).to.equal(2); + expect(details.collectionExtraInfo).to.deep.equal({ thumbnail_url: 'foo' }); + }); + + it('collection extra info is optional', () => { + const responseBodyWithoutExtraInfo = Object.assign( + {}, + responseBody + ) as SearchResponseBody; + delete responseBodyWithoutExtraInfo.collection_extra_info; + + const responseSchema = { + hit_type: 'item' as HitType, + field_properties: {}, + }; + + const details = new SearchResponseDetails( + responseBodyWithoutExtraInfo, + responseSchema + ); + expect(details.results.length).to.equal(2); + expect(details.collectionExtraInfo).to.be.undefined; + }); }); From bbb70d2e0878c95acfb6e0af420436b011542b58 Mon Sep 17 00:00:00 2001 From: Laton Vermette <1619661+latonv@users.noreply.github.com> Date: Wed, 28 Jun 2023 17:41:42 -0700 Subject: [PATCH 2/5] Fix test typo --- test/responses/search-response-details.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/responses/search-response-details.test.ts b/test/responses/search-response-details.test.ts index 4be1a78a..6a811ff4 100644 --- a/test/responses/search-response-details.test.ts +++ b/test/responses/search-response-details.test.ts @@ -96,7 +96,10 @@ describe('SearchResponseDetails', () => { field_properties: {}, }; - const details = new SearchResponseDetails(responseBody, responseSchema); + const details = new SearchResponseDetails( + responseBodyWithTextHit, + responseSchema + ); expect(details.results[0]).to.be.instanceOf(TextHit); // From hit, not schema expect(details.results[0].identifier).to.equal('foo'); expect(details.results[0].mediatype?.value).to.equal('texts'); From 97c481a080684731d8e2fc011d11888f834885a8 Mon Sep 17 00:00:00 2001 From: Laton Vermette <1619661+latonv@users.noreply.github.com> Date: Thu, 29 Jun 2023 11:08:11 -0700 Subject: [PATCH 3/5] Change is_admin flag name to is_archivist --- src/responses/collection-extra-info.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/responses/collection-extra-info.ts b/src/responses/collection-extra-info.ts index b7525c2b..53f131bf 100644 --- a/src/responses/collection-extra-info.ts +++ b/src/responses/collection-extra-info.ts @@ -51,5 +51,5 @@ export interface RelatedCollection { export interface UserDetails { screen_name?: string; useritem?: string; - is_admin?: boolean; + is_archivist?: boolean; } From 0e7d64bae7dec145ceffd653c459ff61eab46b4e Mon Sep 17 00:00:00 2001 From: Laton Vermette <1619661+latonv@users.noreply.github.com> Date: Mon, 10 Jul 2023 20:18:02 -0700 Subject: [PATCH 4/5] More concise syntax in tests --- src/responses/search-request.ts | 23 ++++++++++++++++--- .../responses/search-response-details.test.ts | 15 +++--------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/responses/search-request.ts b/src/responses/search-request.ts index 73eddd2b..f6e15be0 100644 --- a/src/responses/search-request.ts +++ b/src/responses/search-request.ts @@ -1,6 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { SearchRequestParams } from './search-request-params'; +export type RequestKind = 'hits' | 'aggregations' | 'noop'; + +export interface BackendRequest { + /** The finalized client parameters used to query the backend service */ + finalized_parameters: SearchRequestParams; + /** The backend request kind (which may differ from the overall request kind) */ + kind: RequestKind; + /** Name of which other backend request spawned this one, if any */ + parent?: string; +} + /** * A model for the request parameters returned with each search response. */ @@ -11,12 +22,18 @@ export class SearchRequest { clientParameters: SearchRequestParams; /** - * The finalized request parameters as determined by the backend + * Details about the actual backend requests made + */ + backendRequests: Record; + + /** + * The overall 'kind' of request being made (e.g., for hits vs. aggregations) */ - finalizedParameters: SearchRequestParams; + kind: RequestKind; constructor(json: Record) { this.clientParameters = json.client_parameters; - this.finalizedParameters = json.finalized_parameters; + this.backendRequests = json.backend_requests; + this.kind = json.kind; } } diff --git a/test/responses/search-response-details.test.ts b/test/responses/search-response-details.test.ts index 6a811ff4..8c9e07f8 100644 --- a/test/responses/search-response-details.test.ts +++ b/test/responses/search-response-details.test.ts @@ -67,10 +67,7 @@ describe('SearchResponseDetails', () => { }); it('prefers hit-type specified on hit itself over schema hit-type', () => { - const responseBodyWithTextHit = Object.assign( - {}, - responseBody - ) as SearchResponseBody; + const responseBodyWithTextHit = {...responseBody} as SearchResponseBody; responseBodyWithTextHit.hits = { total: 2, returned: 2, @@ -159,10 +156,7 @@ describe('SearchResponseDetails', () => { }); it('collection titles map is optional', () => { - const responseBodyWithoutTitles = Object.assign( - {}, - responseBody - ) as SearchResponseBody; + const responseBodyWithoutTitles = {...responseBody} as SearchResponseBody; delete responseBodyWithoutTitles.collection_titles; const responseSchema = { @@ -190,10 +184,7 @@ describe('SearchResponseDetails', () => { }); it('collection extra info is optional', () => { - const responseBodyWithoutExtraInfo = Object.assign( - {}, - responseBody - ) as SearchResponseBody; + const responseBodyWithoutExtraInfo = {...responseBody} as SearchResponseBody; delete responseBodyWithoutExtraInfo.collection_extra_info; const responseSchema = { From 76629ba8140e89ab4281604de57db1acaef300dd Mon Sep 17 00:00:00 2001 From: Laton Vermette <1619661+latonv@users.noreply.github.com> Date: Mon, 10 Jul 2023 20:41:57 -0700 Subject: [PATCH 5/5] Test fixes --- test/mock-response-generator.ts | 80 ++++++++++--------- .../responses/search-response-details.test.ts | 8 +- test/search-service.test.ts | 7 +- 3 files changed, 53 insertions(+), 42 deletions(-) diff --git a/test/mock-response-generator.ts b/test/mock-response-generator.ts index 5186162f..3b8ab9c9 100644 --- a/test/mock-response-generator.ts +++ b/test/mock-response-generator.ts @@ -22,24 +22,28 @@ export class MockResponseGenerator { hits_per_page: params.rows ?? 0, fields: params.fields ?? [], }, - finalized_parameters: { - client: 'page_production_service_endpoint', - user_query: params.query, - service_backend: 'metadata', - page: params.page ?? 0, - page_type: 'search_results', - hits_per_page: 50, - fields: ['_tile_'].concat(params.fields ?? []), - sort: ['_score'], - aggregations: [ - 'mediatype', - 'year', - 'subject', - 'collection', - 'creator', - 'language', - ], - aggregations_size: 6, + backend_requests: { + primary: { + finalized_parameters: { + client: 'page_production_service_endpoint', + user_query: params.query, + service_backend: 'metadata', + page: params.page ?? 0, + page_type: 'search_results', + hits_per_page: 50, + fields: ['_tile_'].concat(params.fields ?? []), + sort: ['_score'], + aggregations: [ + 'mediatype', + 'year', + 'subject', + 'collection', + 'creator', + 'language', + ], + aggregations_size: 6, + }, + }, }, }, responseHeader: { @@ -76,24 +80,28 @@ export class MockResponseGenerator { hits_per_page: params.rows ?? 0, fields: params.fields ?? [], }, - finalized_parameters: { - client: 'page_production_service_endpoint', - user_query: params.query, - service_backend: 'fts', - page: params.page ?? 0, - page_type: 'search_results', - hits_per_page: 50, - fields: ['_tile_'].concat(params.fields ?? []), - sort: ['_score'], - aggregations: [ - 'mediatype', - 'year', - 'subject', - 'collection', - 'creator', - 'language', - ], - aggregations_size: 6, + backend_requests: { + primary: { + finalized_parameters: { + client: 'page_production_service_endpoint', + user_query: params.query, + service_backend: 'fts', + page: params.page ?? 0, + page_type: 'search_results', + hits_per_page: 50, + fields: ['_tile_'].concat(params.fields ?? []), + sort: ['_score'], + aggregations: [ + 'mediatype', + 'year', + 'subject', + 'collection', + 'creator', + 'language', + ], + aggregations_size: 6, + }, + }, }, }, responseHeader: { diff --git a/test/responses/search-response-details.test.ts b/test/responses/search-response-details.test.ts index 8c9e07f8..1de11c38 100644 --- a/test/responses/search-response-details.test.ts +++ b/test/responses/search-response-details.test.ts @@ -67,7 +67,7 @@ describe('SearchResponseDetails', () => { }); it('prefers hit-type specified on hit itself over schema hit-type', () => { - const responseBodyWithTextHit = {...responseBody} as SearchResponseBody; + const responseBodyWithTextHit = { ...responseBody } as SearchResponseBody; responseBodyWithTextHit.hits = { total: 2, returned: 2, @@ -156,7 +156,7 @@ describe('SearchResponseDetails', () => { }); it('collection titles map is optional', () => { - const responseBodyWithoutTitles = {...responseBody} as SearchResponseBody; + const responseBodyWithoutTitles = { ...responseBody } as SearchResponseBody; delete responseBodyWithoutTitles.collection_titles; const responseSchema = { @@ -184,7 +184,9 @@ describe('SearchResponseDetails', () => { }); it('collection extra info is optional', () => { - const responseBodyWithoutExtraInfo = {...responseBody} as SearchResponseBody; + const responseBodyWithoutExtraInfo = { + ...responseBody, + } as SearchResponseBody; delete responseBodyWithoutExtraInfo.collection_extra_info; const responseSchema = { diff --git a/test/search-service.test.ts b/test/search-service.test.ts index 3c3b5e5e..be110f2b 100644 --- a/test/search-service.test.ts +++ b/test/search-service.test.ts @@ -37,9 +37,10 @@ describe('SearchService', () => { const query = 'title:foo AND collection:bar'; const service = new SearchService(); const result = await service.search({ query }); - expect(result.success?.request.finalizedParameters.user_query).to.equal( - query - ); + expect( + result.success?.request.backendRequests.primary?.finalized_parameters + ?.user_query + ).to.equal(query); SearchService.getBackendForSearchType = realFactoryMethod; });