diff --git a/service/README.md b/service/README.md index a5f1284..dca63d5 100644 --- a/service/README.md +++ b/service/README.md @@ -122,6 +122,8 @@ The `_summary` parameter is specified in the [HL7 FHIR Search Docs](https://www. The `_elements` parameter is specified in the [HL7 FHIR Search Docs](https://www.hl7.org/fhir/search.html#_elements) and supports a comma-separated list of base element names. +The `_count` parameter is specified in the [HL7 FHIR Search Docs](https://www.hl7.org/fhir/search.html#_count) and supports a whole number indicating the number of results per page. + ### Package The Measure Repository Service server supports the `Library` and `Measure` `$cqfm-package` operation with the `id` and `identifier` parameters and SHALL parameters specified in the shareable measure repository section of the [HL7 Measure Repository Docs](http://hl7.org/fhir/us/cqfmeasures/measure-repository-service.html#publishable-measure-repository). Accepted parameters are: diff --git a/service/src/db/dbOperations.ts b/service/src/db/dbOperations.ts index 6c82745..3580f5e 100644 --- a/service/src/db/dbOperations.ts +++ b/service/src/db/dbOperations.ts @@ -13,21 +13,37 @@ export async function findResourceById(id: string, } /** - * searches the database and returns an array of all resources of the given type that match the given query + * searches the database and returns an array of objects that contain metadata and each respective resource of the given type that match the given query */ export async function findResourcesWithQuery( query: Filter, resourceType: FhirResourceType ) { const collection = Connection.db.collection(resourceType); + const projection = { _id: 0, _dataRequirements: 0 }; + const pagination: any = query.skip ? [{ $skip: query.skip }, { $limit: query.limit }] : []; query._dataRequirements = { $exists: false }; query._summary = { $exists: false }; query._elements = { $exists: false }; - return collection.find(query, { projection: { _id: 0, _dataRequirements: 0 } }).toArray(); + query._count = { $exists: false }; + query.limit = { $exists: false }; + query.skip = { $exists: false }; + return collection + .aggregate<{ metadata: { total: number }[]; data: T }>([ + { $match: query }, + { + $facet: { + metadata: [{ $count: 'total' }], + data: pagination + } + }, + { $project: projection } + ]) + .toArray(); } /** - * searches the database and returns an array of all resources of the given type that match the given query + * searches the database and returns an array objects that contain metadata and each respective resource of the given type that match the given query * but the resources only include the elements specified by the _elements parameter */ export async function findResourceElementsWithQuery( @@ -44,10 +60,27 @@ export async function findResourceElementsWithQuery(query, { projection: projection }).toArray(); + query.limit = { $exists: false }; + query.skip = { $exists: false }; + return collection + .aggregate<{ metadata: { total: number }[]; data: T }>([ + { $match: query }, + { + $facet: { + metadata: [{ $count: 'total' }], + data: pagination + } + }, + { $project: projection } + ]) + .toArray(); } /** diff --git a/service/src/requestSchemas.ts b/service/src/requestSchemas.ts index 58ec260..5c670d8 100644 --- a/service/src/requestSchemas.ts +++ b/service/src/requestSchemas.ts @@ -199,7 +199,8 @@ export const CoreSearchArgs = z _summary: z.literal('count'), // adding _elements for a comma separated string (https://www.hl7.org/fhir/search.html#_elements) _elements: z.string(), - _count: z.string() + _count: z.string(), + page: z.string() }) .partial() .strict(); diff --git a/service/src/services/LibraryService.ts b/service/src/services/LibraryService.ts index 47baa6e..92c4074 100644 --- a/service/src/services/LibraryService.ts +++ b/service/src/services/LibraryService.ts @@ -10,7 +10,12 @@ import { } from '../db/dbOperations'; import { LibrarySearchArgs, LibraryDataRequirementsArgs, PackageArgs, parseRequestSchema } from '../requestSchemas'; import { Service } from '../types/service'; -import { createLibraryPackageBundle, createSearchsetBundle, createSummarySearchsetBundle } from '../util/bundleUtils'; +import { + createLibraryPackageBundle, + createPaginationLinks, + createSearchsetBundle, + createSummarySearchsetBundle +} from '../util/bundleUtils'; import { BadRequestError, ResourceNotFoundError } from '../util/errorUtils'; import { getMongoQueryFromRequest } from '../util/queryUtils'; import { @@ -44,7 +49,11 @@ export class LibraryService implements Service { // if the _summary parameter with a value of count is included, then // return a searchset bundle that excludes the entries - if (parsedQuery._summary && parsedQuery._summary === 'count') { + // if _count has the value 0, this shall be treated the same as _summary=count + if ( + (parsedQuery._summary && parsedQuery._summary === 'count') || + (parsedQuery._count && parsedQuery._count === '0') + ) { const count = await findResourceCountWithQuery(mongoQuery, 'Library'); return createSummarySearchsetBundle(count); } @@ -52,7 +61,8 @@ export class LibraryService implements Service { // then return a searchset bundle that includes only those elements // on those resource entries else if (parsedQuery._elements) { - const entries = await findResourceElementsWithQuery(mongoQuery, 'Library'); + const result = await findResourceElementsWithQuery(mongoQuery, 'Library'); + const entries = result.map(r => r.data); // add the SUBSETTED tag to the resources returned by the _elements parameter entries.map(e => { if (e.meta) { @@ -67,10 +77,37 @@ export class LibraryService implements Service { }; } }); - return createSearchsetBundle(entries); + const bundle = createSearchsetBundle(entries); + if (parsedQuery._count) { + bundle.link = createPaginationLinks( + `http://${req.headers.host}/${req.params.base_version}/`, + 'Library', + new URLSearchParams(req.query), + { + numberOfPages: Math.ceil(result[0].metadata[0].total / parseInt(parsedQuery._count)), + page: parseInt(parsedQuery.page || '1') + } + ); + } + return bundle; } else { - const entries = await findResourcesWithQuery(mongoQuery, 'Library'); - return createSearchsetBundle(entries); + const result = await findResourcesWithQuery(mongoQuery, 'Library'); + const entries = result.map(r => r.data); + const bundle = createSearchsetBundle(entries); + if (parsedQuery._count) { + if (parsedQuery._count) { + bundle.link = createPaginationLinks( + `http://${req.headers.host}/${req.params.base_version}/`, + 'Library', + new URLSearchParams(req.query), + { + numberOfPages: Math.ceil(result[0].metadata[0].total / parseInt(parsedQuery._count)), + page: parseInt(parsedQuery.page || '1') + } + ); + } + } + return bundle; } } diff --git a/service/src/services/MeasureService.ts b/service/src/services/MeasureService.ts index ceafdd5..1dc93d9 100644 --- a/service/src/services/MeasureService.ts +++ b/service/src/services/MeasureService.ts @@ -9,7 +9,12 @@ import { updateResource } from '../db/dbOperations'; import { Service } from '../types/service'; -import { createMeasurePackageBundle, createSearchsetBundle, createSummarySearchsetBundle } from '../util/bundleUtils'; +import { + createPaginationLinks, + createMeasurePackageBundle, + createSearchsetBundle, + createSummarySearchsetBundle +} from '../util/bundleUtils'; import { BadRequestError, ResourceNotFoundError } from '../util/errorUtils'; import { getMongoQueryFromRequest } from '../util/queryUtils'; import { @@ -45,7 +50,11 @@ export class MeasureService implements Service { // if the _summary parameter with a value of count is included, then // return a searchset bundle that excludes the entries - if (parsedQuery._summary && parsedQuery._summary === 'count') { + // if _count has the value 0, this shall be treated the same as _summary=count + if ( + (parsedQuery._summary && parsedQuery._summary === 'count') || + (parsedQuery._count && parsedQuery._count === '0') + ) { const count = await findResourceCountWithQuery(mongoQuery, 'Measure'); return createSummarySearchsetBundle(count); } @@ -53,7 +62,8 @@ export class MeasureService implements Service { // then return a searchset bundle that includes only those elements // on those resource entries else if (parsedQuery._elements) { - const entries = await findResourceElementsWithQuery(mongoQuery, 'Measure'); + const result = await findResourceElementsWithQuery(mongoQuery, 'Measure'); + const entries = result.map(r => r.data); // add the SUBSETTED tag to the resources returned by the _elements parameter entries.map(e => { if (e.meta) { @@ -68,10 +78,39 @@ export class MeasureService implements Service { }; } }); - return createSearchsetBundle(entries); + const bundle = createSearchsetBundle(entries); + if (parsedQuery._count) { + if (parsedQuery._count) { + bundle.link = createPaginationLinks( + `http://${req.headers.host}/${req.params.base_version}/`, + 'Measure', + new URLSearchParams(req.query), + { + numberOfPages: Math.ceil(result[0].metadata[0].total / parseInt(parsedQuery._count)), + page: parseInt(parsedQuery.page || '1') + } + ); + } + } + return bundle; } else { - const entries = await findResourcesWithQuery(mongoQuery, 'Measure'); - return createSearchsetBundle(entries); + const result = await findResourcesWithQuery(mongoQuery, 'Measure'); + const entries = result.map(r => r.data); + const bundle = createSearchsetBundle(entries); + if (parsedQuery._count) { + if (parsedQuery._count) { + bundle.link = createPaginationLinks( + `http://${req.headers.host}/${req.params.base_version}/`, + 'Measure', + new URLSearchParams(req.query), + { + numberOfPages: Math.ceil(result[0].metadata[0].total / parseInt(parsedQuery._count)), + page: parseInt(parsedQuery.page || '1') + } + ); + } + } + return bundle; } } diff --git a/service/src/util/bundleUtils.ts b/service/src/util/bundleUtils.ts index dc36a9c..ff0219a 100644 --- a/service/src/util/bundleUtils.ts +++ b/service/src/util/bundleUtils.ts @@ -40,6 +40,72 @@ export function createSummarySearchsetBundle(count }; } +/** + * Creates pagination links for the search results bundle. + * Logic pulled from implementation in deqm-test-server + * https://github.com/projecttacoma/deqm-test-server/blob/dc89180bf4b355e97db38cfa84471dd16db82e93/src/util/baseUtils.js#L61 + * + * @param {string} baseUrl Base URL of the server and FHIR base path. Should be pulled from request. + * @param {string} resourceType The resource type these results are for. + * @param {url.URLSearchParams} searchParams The search parameter object used for the initial query pulled from the request. + * @param {{numberOfPages: number, page: number}} resultsMetadata The results metadata object from the mongo results. + * @returns {fhir4.BundleLink[]} The links that should be added to the search st results bundle. + */ +export function createPaginationLinks( + baseUrl: string, + resourceType: string, + searchParams: URLSearchParams, + resultsMetadata: { numberOfPages: number; page: number } +): fhir4.BundleLink[] { + const { numberOfPages, page } = resultsMetadata; + const links = []; + + // create self link, including query params only if there were any + if (searchParams.toString() !== '') { + links.push({ + relation: 'self', + url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString() + }); + } else { + links.push({ + relation: 'self', + url: new URL(`${resourceType}`, baseUrl).toString() + }); + } + + // first page + searchParams.set('page', '1'); + links.push({ + relation: 'first', + url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString() + }); + + // only add previous and next if appropriate + if (page > 1) { + searchParams.set('page', `${page - 1}`); + links.push({ + relation: 'previous', + url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString() + }); + } + if (page < numberOfPages) { + searchParams.set('page', `${page + 1}`); + links.push({ + relation: 'next', + url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString() + }); + } + + // last page + searchParams.set('page', `${numberOfPages}`); + links.push({ + relation: 'last', + url: new URL(`${resourceType}?${searchParams}`, baseUrl).toString() + }); + + return links; +} + /** * Takes in a mongo query, finds a Measure based on the query and all dependent * Library resources and bundles them together with the Measure in a collection bundle @@ -49,7 +115,7 @@ export async function createMeasurePackageBundle( params: z.infer ): Promise> { const mongoQuery = getMongoQueryFromRequest(query); - const measure = await findResourcesWithQuery(mongoQuery, 'Measure'); + const measure = (await findResourcesWithQuery(mongoQuery, 'Measure')).map(r => r.data); if (!measure || !(measure.length > 0)) { throw new ResourceNotFoundError( `No resource found in collection: Measure, with ${Object.keys(query) @@ -71,7 +137,7 @@ export async function createMeasurePackageBundle( if (measureForPackaging.library && measureForPackaging.library.length > 0) { const [mainLibraryRef] = measureForPackaging.library; const mainLibQuery = getQueryFromReference(mainLibraryRef); - const libs = await findResourcesWithQuery(mainLibQuery, 'Library'); + const libs = (await findResourcesWithQuery(mainLibQuery, 'Library')).map(r => r.data); if (!libs || libs.length < 1) { throw new ResourceNotFoundError( `Could not find Library ${mainLibraryRef} referenced by Measure ${measureForPackaging.id}` @@ -97,7 +163,7 @@ export async function createLibraryPackageBundle( params: z.infer ): Promise<{ libraryBundle: fhir4.Bundle; rootLibRef?: string }> { const mongoQuery = getMongoQueryFromRequest(query); - const library = await findResourcesWithQuery(mongoQuery, 'Library'); + const library = (await findResourcesWithQuery(mongoQuery, 'Library')).map(r => r.data); if (!library || !(library.length > 0)) { throw new ResourceNotFoundError( `No resource found in collection: Library, with ${Object.keys(query) @@ -281,7 +347,7 @@ export async function getAllDependentLibraries(lib: fhir4.Library): Promise { const libQuery = getQueryFromReference(url); - const libs = await findResourcesWithQuery(libQuery, 'Library'); + const libs = (await findResourcesWithQuery(libQuery, 'Library')).map(r => r.data); if (!libs || libs.length < 1) { throw new ResourceNotFoundError( `Failed to find dependent library with ${ diff --git a/service/src/util/queryUtils.ts b/service/src/util/queryUtils.ts index 2c1ede3..e292788 100644 --- a/service/src/util/queryUtils.ts +++ b/service/src/util/queryUtils.ts @@ -8,6 +8,9 @@ const STRING_TYPE_PARAMS = ['name', 'title', 'description', 'version']; * a usable mongo query */ export function getMongoQueryFromRequest(query: RequestQuery): Filter { + const pageSize = (query['_count'] && parseInt(query['_count'] as string)) || 100; // set a base limit of 100 + if (!query['page']) query['page'] = '1'; // default to first page + //TODO: Handle potential for query value to be array return Object.keys(query).reduce((mf: Filter, key: string) => { if (!query[key] || Array.isArray(query[key])) { @@ -35,7 +38,9 @@ export function getMongoQueryFromRequest(query: RequestQuery): Filter { const elements = query[key] as string; mf[key] = elements.split(','); } else if (key === '_count') { - //blackhole _count for now + mf['limit'] = pageSize; + } else if (key === 'page') { + mf['skip'] = (parseInt((query[key] || '1') as string) - 1) * pageSize; //default to first page } else { // Otherwise no parsing necessary mf[key] = query[key];