diff --git a/README.md b/README.md index 0a8be883..aed4d052 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,19 @@ cp app/.env.example app/.env.local cp service/.env.example service/.env ``` -Make any changes to point to the measure repository service, Mongo database, and optionally the VSAC API. `0.0.0.0` may be a more appropriate database address than `localhost` for certain environment setups. +Make any changes to point to the measure repository service, Mongo database, and optionally the VSAC API. `0.0.0.0` may be a more appropriate database address than `localhost` for certain environment setups. +Additionally, some versions of tooling may have issues with running `next dev` within workspaces. Disabling telemetry can prevent the disallowed npm command from running under the hood. +```bash +npx next telemetry disable +``` + ### Mongo Replica Set Setup Use the mongodb configuration file to configure the single node replica set. For more information about the configuration file and system location, see the mongodb [configuration file documentation](https://www.mongodb.com/docs/manual/reference/configuration-options/). 1. First shutdown any currently running mongodb standalone instances: `brew services stop mongodb-community`. -2. Locate your [Mongo Configuration File](https://www.mongodb.com/docs/compass/current/settings/config-file/#:~:text=For%20macOS%20and%20Linux%2C%20the,%5Cmongodb%2Dcompass.). _System dependent but may be found at `/usr/local/etc/mongod.conf`_. +2. Locate your [Mongo Configuration File](https://www.mongodb.com/docs/manual/reference/configuration-options/). _System dependent but may be found at `/opt/homebrew/etc/mongod.conf`_. 3. Add this replication set configuration to the mongo configuration file: ``` diff --git a/package-lock.json b/package-lock.json index 1a9e65cd..9ea1f9b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "service" ], "devDependencies": { + "@types/lodash": "^4.17.4", "concurrently": "^7.6.0" } }, @@ -4128,6 +4129,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.4.tgz", + "integrity": "sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==", + "dev": true + }, "node_modules/@types/luxon": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.7.tgz", diff --git a/package.json b/package.json index 78cf1096..ab61346e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:service": "npm run test --workspace=service" }, "devDependencies": { + "@types/lodash": "^4.17.4", "concurrently": "^7.6.0" } } diff --git a/service/.env.example b/service/.env.example index 0fa81549..691e4c9c 100644 --- a/service/.env.example +++ b/service/.env.example @@ -3,4 +3,5 @@ PORT=3000 HOST='localhost' DATABASE_URL='mongodb://localhost:27017/measure-repository?replicaSet=rs0' -VSAC_API_KEY="" # Add if you plan on using the `include-terminology` query param \ No newline at end of file +VSAC_API_KEY="" # Add if you plan on using the `include-terminology` query param +AUTHORING=true # Make false if this is running as a publishable repository instead \ No newline at end of file diff --git a/service/.env.test b/service/.env.test index 95bd33aa..a8b8e9c4 100644 --- a/service/.env.test +++ b/service/.env.test @@ -1 +1,2 @@ VSAC_API_KEY="example-api-key" +AUTHORING=true \ No newline at end of file diff --git a/service/README.md b/service/README.md index a5f12840..a2bc5e5d 100644 --- a/service/README.md +++ b/service/README.md @@ -74,12 +74,11 @@ The server supports transaction bundle uploads via the `:/base_version/` endpoin - The request body must be a FHIR bundle of type `transaction`. - The entries SHALL be of resource type "Measure" or "Library." An error will be thrown otherwise. -For ease of use, the `service/directory-upload.sh` script can be used to run the transaction bundle upload on an input directory. Details are as follows: +For ease of use, an upload workflow similar to `db:loadBundle` can be done by POSTing a directory of transaction bundles rather than directly uploading to the database. While the server is running (script assumes default location http://localhost:3000/4_0_1), run the following script with the desired directory path: -- The `-h` option can be used to view usage. -- A server URL must be supplied via the `-s` option. -- A directory path must be supplied via the `-d` option. -- The script can support nested directories (one level deep). +``` +npm run db:postBundle +``` ## Usage @@ -95,12 +94,34 @@ When sending requests, ensure that the `"Content-type": "application/json+fhir"` ### CRUD Operations +This server can be configured as a [Publishable Measure Repository](https://build.fhir.org/ig/HL7/cqf-measures/measure-repository-service.html#publishable-measure-repository) or an [Authoring Measure Repository](https://build.fhir.org/ig/HL7/cqf-measures/measure-repository-service.html#authoring-measure-repository) using the `AUTHORING` environment variable. The minimum write capabilities for these repositories are described further in the [CRMI Publishable Artifact Repository](https://hl7.org/fhir/uv/crmi/1.0.0-snapshot/artifact-repository-service.html#publishable-artifact-repository) and [CRMI Authoring Artifact Repository](https://hl7.org/fhir/uv/crmi/1.0.0-snapshot/artifact-repository-service.html#authoring-artifact-repository) specifications, respectively. The write capabilities implemented in this server are further detailed for the create, update, and delete operations described below. + This server currently supports the following CRUD operations: - Read by ID with `GET` to endpoint: `4_0_1//` - Create resource (Library or Measure) with `POST` to endpoint: `4_0_1/` + - Publishable: + - Supports the _Publishable_ minimum write capability _publish_ + - Artifact must be in active status and conform to appropriate shareable and publishable profiles + - Authoring: + - Supports the additional _Authoring_ capability _submit_ + - Artifact must be in draft status + - Update resource (Library or Measure) with `PUT` to endpoint: `4_0_1//` - _More functionality coming soon!_ + - Publishable: + - Supports the _Publishable_ minimum write capability _retire_ + - Artifact must be in active status and may only change the status to retired and update the date (and other metadata appropriate to indicate retired status) + - Authoring: + - Supports the additional _Authoring_ capability _revise_ + - Artifact must be in (and remain in) draft status + +- Delete resource (Library or Measure) with `DELETE` to endpoint: `4_0_1//` + - Publishable: + - Supports the _Publishable_ minimum write capability _archive_ + - Artifact must be in retired status + - Authoring: + - Supports the additional _Authoring_ capability _withdraw_ + - Artifact must be in draft status ### Search diff --git a/service/directory-upload.sh b/service/directory-upload.sh deleted file mode 100755 index 488dd410..00000000 --- a/service/directory-upload.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash - -usage=" -usage: $(basename "$0") command [-h] [-s] [-d] arguments... -Uploads Measure and Library resources to the measure repository service. -Options: - -h - Displays help menu. - -s [server baseUrl] - Specifies the base URL of the FHIR server to access. - -d [path] - Provides directory or file path to parse for upload. -" - -while getopts ':hs:d:' option; -do - case "$option" in - h) - echo -e "$usage" - exit 0 - ;; - s) - server=$OPTARG - ;; - d) - directory_path=$OPTARG - ;; - - \?) printf "illegal option: -%s\n" "$OPTARG" 1>&2 - echo "$usage" 1>&2 - exit 1 - ;; - : ) - echo "Invalid option: $OPTARG requires an argument" 1>&2 - ;; - esac -done - -if [[ $directory_path == "" ]] ; then - echo No directory path provided. Provide directory path via the '-d' flag. - exit 1 -fi - -if [[ $server == "" ]] ; then - echo No server URL provided. Provide server URL via the '-s' flag. - exit 1 -fi - -echo Using Server URL: $server and directory path: $directory_path - -upload_bundle() { - echo "Uploading resources for bundle $1" - curl_command="curl -X POST -H 'Content-Type: application/json+fhir' -d @\"$1\" $server -o /dev/null" - # execute the curl command - eval "$curl_command" - - echo "Finished bundle upload." - echo "" -} - -# loop over FHIR bundles in specified directory -for file_path in "$directory_path" ; do - if [[ -d $file_path ]] ; then - # recurse on directory - for f in $(find $file_path -name "*.json") ; do - upload_bundle $f - done - - elif [[ -f $file_path ]] ; then - if [[ ${file_path: -5} == ".json" ]] ; then - upload_bundle $file_path - fi - fi -done \ No newline at end of file diff --git a/service/package.json b/service/package.json index fe36faff..25c2a83d 100644 --- a/service/package.json +++ b/service/package.json @@ -13,6 +13,7 @@ "db:reset": "ts-node ./scripts/dbSetup.ts reset", "db:setup": "ts-node ./scripts/dbSetup.ts create", "db:loadBundle": "ts-node ./scripts/dbSetup.ts loadBundle", + "db:postBundle": "ts-node ./scripts/dbSetup.ts postBundle", "lint": "eslint \"./src/**/*.{js,ts}\"", "lint:fix": "eslint \"./src/**/*.{js,ts}\" --fix", "prettier": "prettier --check \"./src/**/*.{js,ts}\"", diff --git a/service/scripts/dbSetup.ts b/service/scripts/dbSetup.ts index a051d490..8bfcae5a 100644 --- a/service/scripts/dbSetup.ts +++ b/service/scripts/dbSetup.ts @@ -4,7 +4,7 @@ import * as dotenv from 'dotenv'; import { MongoError } from 'mongodb'; import { v4 as uuidv4 } from 'uuid'; import path from 'path'; -import { DetailedEntry, addIsOwnedExtension, addLibraryIsOwned } from '../src/util/baseUtils'; +import { addIsOwnedExtension, addLibraryIsOwned } from '../src/util/baseUtils'; dotenv.config(); const DB_URL = process.env.DATABASE_URL || 'mongodb://localhost:27017/measure-repository'; @@ -39,10 +39,10 @@ async function deleteCollections() { /* * Gathers necessary file path(s) for bundle(s) to upload, then uploads all measure and * library resources found in the bundle(s). + * If a connectionURL is provided, then posts the resources to the server at the + * connectionURL (as a transaction bundle), otherwise, loads the resources directly to the database */ -async function loadBundle(fileOrDirectoryPath: string) { - await Connection.connect(DB_URL); - console.log(`Connected to ${DB_URL}`); +async function loadBundle(fileOrDirectoryPath: string, connectionURL?: string) { const status = fs.statSync(fileOrDirectoryPath); if (status.isDirectory()) { const filePaths: string[] = []; @@ -60,15 +60,72 @@ async function loadBundle(fileOrDirectoryPath: string) { }); for (const filePath of filePaths) { - await uploadBundleResources(filePath); + await (connectionURL ? postBundleResources(filePath, connectionURL) : uploadBundleResources(filePath)); } } else { - await uploadBundleResources(fileOrDirectoryPath); + await (connectionURL + ? postBundleResources(fileOrDirectoryPath, connectionURL) + : uploadBundleResources(fileOrDirectoryPath)); + } +} + +/* + * POSTs a transaction bundle to url + */ +async function transactBundle(bundle: fhir4.Bundle, url: string) { + if (bundle.entry) { + // only upload Measures and Libraries + bundle.entry = bundle.entry.filter( + e => e.resource?.resourceType === 'Measure' || e.resource?.resourceType === 'Library' + ); + for (const entry of bundle.entry) { + if (entry.request?.method === 'POST') { + entry.request.method = 'PUT'; + } + } + } + + try { + console.log(` POST ${url}`); + + const resp = await fetch(`${url}`, { + method: 'POST', + body: JSON.stringify(bundle), + headers: { + 'Content-Type': 'application/json+fhir' + } + }); + console.log(` ${resp.status}`); + if (resp.status !== 200) { + console.log(`${JSON.stringify(await resp.json())}`); + } + } catch (e) { + console.error(e); + } +} + +/* + * Loads all resources found in the bundle at filePath, by POSTing them to the provided url + */ +async function postBundleResources(filePath: string, url: string) { + console.log(`Loading bundle from path ${filePath}`); + + const data = fs.readFileSync(filePath, 'utf8'); + if (data) { + console.log(`POSTing ${filePath.split('/').slice(-1)}...`); + const bundle: fhir4.Bundle = JSON.parse(data); + const entries = bundle.entry as fhir4.BundleEntry[]; + // modify bundles before posting + if (entries) { + const modifiedEntries = modifyEntriesForUpload(entries); + bundle.entry = modifiedEntries; + } + await transactBundle(bundle, url); } } /* - * Loads all measure or library resources found in the bundle located at param filePath + * Loads all resources found in the bundle at filePath, directly to the database */ async function uploadBundleResources(filePath: string) { console.log(`Loading bundle from path ${filePath}`); @@ -77,56 +134,21 @@ async function uploadBundleResources(filePath: string) { if (data) { console.log(`Uploading ${filePath.split('/').slice(-1)}...`); const bundle: fhir4.Bundle = JSON.parse(data); - const entries = bundle.entry as DetailedEntry[]; + const entries = bundle.entry as fhir4.BundleEntry[]; // retrieve each resource and insert into database if (entries) { + await Connection.connect(DB_URL); + console.log(`Connected to ${DB_URL}`); let resourcesUploaded = 0; let notUploaded = 0; - // pre-process to find owned relationships - const ownedUrls: string[] = []; - const modifiedEntries = entries.map(ent => { - // if the artifact is a Measure, get the main Library from the Measure and add the is owned extension on - // that library's entry in the relatedArtifacts of the measure - const { modifiedEntry, url } = addIsOwnedExtension(ent); - if (url) ownedUrls.push(url); - // check if there are other isOwned urls but already in the relatedArtifacts - if (ent.resource?.resourceType === 'Measure' || ent.resource?.resourceType === 'Library') { - ent.resource.relatedArtifact?.forEach(ra => { - if (ra.type === 'composed-of') { - if ( - ra.extension?.some( - e => e.url === 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned' && e.valueBoolean === true - ) - ) { - if (ra.resource) { - ownedUrls.push(ra.resource); - } - } - } - }); - } - return modifiedEntry; - }); + const modifiedEntries = modifyEntriesForUpload(entries); const uploads = modifiedEntries.map(async entry => { // add Library owned extension - entry = addLibraryIsOwned(entry, ownedUrls); - if ( - entry.resource?.resourceType && - (entry.resource?.resourceType === 'Library' || entry.resource?.resourceType === 'Measure') - ) { + if (entry.resource?.resourceType === 'Library' || entry.resource?.resourceType === 'Measure') { // Only upload Library or Measure resources try { - if (!entry.resource.id) { - entry.resource.id = uuidv4(); - } - if (entry.resource?.status != 'active') { - entry.resource.status = 'active'; - console.warn( - `Resource ${entry?.resource?.resourceType}/${entry.resource.id} status has been coerced to 'active'.` - ); - } const collection = Connection.db.collection(entry.resource.resourceType); - console.log(`Inserting ${entry?.resource?.resourceType}/${entry.resource.id} into database`); + console.log(`Inserting ${entry.resource.resourceType}/${entry.resource.id} into database`); await collection.insertOne(entry.resource); resourcesUploaded += 1; } catch (e) { @@ -140,7 +162,7 @@ async function uploadBundleResources(filePath: string) { } } } else { - if (entry?.resource?.resourceType) { + if (entry.resource?.resourceType) { notUploaded += 1; } else { console.log('Resource or resource type undefined'); @@ -156,6 +178,56 @@ async function uploadBundleResources(filePath: string) { } } +/* + * Convenience modification of an array of entries to create isOwned relationships and coerce to status active. + * This lets us massage existing data that may not have the appropriate properties needed for a Publishable Measure Repository + */ +function modifyEntriesForUpload(entries: fhir4.BundleEntry[]) { + // pre-process to find owned relationships + const ownedUrls: string[] = []; + const modifiedEntries = entries.map(ent => { + // if the artifact is a Measure, get the main Library from the Measure and add the is owned extension on + // that library's entry in the relatedArtifacts of the measure + const { modifiedEntry, url } = addIsOwnedExtension(ent); + if (url) ownedUrls.push(url); + // check if there are other isOwned urls but already in the relatedArtifacts + if (ent.resource?.resourceType === 'Measure' || ent.resource?.resourceType === 'Library') { + ent.resource.relatedArtifact?.forEach(ra => { + if (ra.type === 'composed-of') { + if ( + ra.extension?.some( + e => e.url === 'http://hl7.org/fhir/StructureDefinition/artifact-isOwned' && e.valueBoolean === true + ) + ) { + if (ra.resource) { + ownedUrls.push(ra.resource); + } + } + } + }); + } + return modifiedEntry; + }); + const updatedEntries = modifiedEntries.map(entry => { + // add Library owned extension + const updatedEntry = addLibraryIsOwned(entry, ownedUrls); + if (updatedEntry.resource?.resourceType === 'Library' || updatedEntry.resource?.resourceType === 'Measure') { + // Only upload Library or Measure resources + if (!updatedEntry.resource.id) { + updatedEntry.resource.id = uuidv4(); + } + if (updatedEntry.resource.status != 'active') { + updatedEntry.resource.status = 'active'; + console.warn( + `Resource ${updatedEntry.resource.resourceType}/${updatedEntry.resource.id} status has been coerced to 'active'.` + ); + } + } + return updatedEntry; + }); + return updatedEntries; +} + /* * Inserts the FHIR ModelInfo library into the database */ @@ -216,6 +288,22 @@ if (process.argv[2] === 'delete') { .finally(() => { Connection.connection?.close(); }); +} else if (process.argv[2] === 'postBundle') { + if (process.argv.length < 4) { + throw new Error('Filename argument required.'); + } + let url = 'http://localhost:3000/4_0_1'; + if (process.argv.length < 5) { + console.log('Given only filename input. Defaulting service url to http://localhost:3000/4_0_1'); + } else { + url = process.argv[4]; + } + + loadBundle(process.argv[3], url) + .then(() => { + console.log('Done'); + }) + .catch(console.error); } else { console.log('Usage: ts-node src/scripts/dbSetup.ts '); } diff --git a/service/src/config/capabilityStatementResources.json b/service/src/config/capabilityStatementResources.json index 06c22415..e51f1f0e 100644 --- a/service/src/config/capabilityStatementResources.json +++ b/service/src/config/capabilityStatementResources.json @@ -51,6 +51,16 @@ ], "code": "update", "documentation": "Update allows authoring workflows to update existing libraries in _draft_ (**revise**) status, add comments to existing libraries (**review** and **approve**), and **release** or **retire** a library." + }, + { + "extension" : [ + { + "url" : "http://hl7.org/fhir/StructureDefinition/capabilitystatement-expectation", + "valueCode" : "SHALL" + } + ], + "code" : "delete", + "documentation" : "Delete allows authoring workflows to **withdraw** _draft_ libraries or **archive** _retired_ libraries." } ], "searchParam": [ @@ -207,6 +217,16 @@ ], "code": "update", "documentation": "Update allows authoring workflows to update existing measures in _draft_ (**revise**) status, add comments to existing measures (**review** and **approve**), and **release** or **retire** a measure." + }, + { + "extension" : [ + { + "url" : "http://hl7.org/fhir/StructureDefinition/capabilitystatement-expectation", + "valueCode" : "SHALL" + } + ], + "code" : "delete", + "documentation" : "Delete allows authoring workflows to **withdraw** _draft_ measures or **archive** _retired_ measures." } ], "searchParam": [ diff --git a/service/src/db/dbOperations.ts b/service/src/db/dbOperations.ts index 6c82745d..af184f06 100644 --- a/service/src/db/dbOperations.ts +++ b/service/src/db/dbOperations.ts @@ -99,3 +99,16 @@ export async function updateResource(id: string, data: fhir4.FhirResource, resou return { id, created: false }; } + +/** + * Searches for a document for a resource and deletes it if found + */ +export async function deleteResource(id: string, resourceType: string) { + const collection = Connection.db.collection(resourceType); + logger.debug(`Finding and deleting ${resourceType}/${id} from database`); + const results = await collection.deleteOne({ id }); + if (results.deletedCount === 1) { + return { id, deleted: true }; + } + return { id, deleted: false }; +} diff --git a/service/src/services/BaseService.ts b/service/src/services/BaseService.ts index c83954d4..cd2ca42a 100644 --- a/service/src/services/BaseService.ts +++ b/service/src/services/BaseService.ts @@ -1,8 +1,8 @@ import { loggers, constants, resolveSchema } from '@projecttacoma/node-fhir-server-core'; import { BadRequestError } from '../util/errorUtils'; -import { checkContentTypeHeader } from '../util/inputUtils'; +import { checkContentTypeHeader, checkFieldsForCreate, checkFieldsForUpdate } from '../util/inputUtils'; import { v4 as uuidv4 } from 'uuid'; -import { createResource, updateResource } from '../db/dbOperations'; +import { createResource, findResourceById, updateResource } from '../db/dbOperations'; import path from 'path'; import { DetailedEntry, addIsOwnedExtension, addLibraryIsOwned, replaceReferences } from '../util/baseUtils'; @@ -43,7 +43,7 @@ async function uploadResourcesFromBundle(entries: DetailedEntry[]) { // pre-process to find owned relationships and error as needed const ownedUrls: string[] = []; - const modifedRequestsArray = scrubbedEntries.map(entry => { + const modifiedRequestsArray = scrubbedEntries.map(entry => { if (entry.request) { const { method } = entry.request; if (method !== 'PUT' && method !== 'POST') { @@ -61,10 +61,10 @@ async function uploadResourcesFromBundle(entries: DetailedEntry[]) { } }); - const requestsArray = modifedRequestsArray.map(async entry => { + const requestsArray = modifiedRequestsArray.map(async entry => { // add library owned extension entry = addLibraryIsOwned(entry, ownedUrls); - return insertBundleResources(entry); + return insertBundleResources(entry as DetailedEntry); }); return Promise.all(requestsArray); } @@ -74,22 +74,8 @@ async function uploadResourcesFromBundle(entries: DetailedEntry[]) { */ async function insertBundleResources(entry: DetailedEntry) { if (entry.resource?.resourceType === 'Library' || entry.resource?.resourceType === 'Measure') { - if (entry.resource.status != 'active') { - entry.resource.status = 'active'; - entry.outcome = { - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'warning', - code: 'value', // code from: https://build.fhir.org/valueset-issue-type.html - details: { text: 'Artifact status has been coerced to active to meet server specifications' }, - expression: [`${entry.resource.resourceType}.status`] - } - ] - }; - } - if (entry.isPost) { + checkFieldsForCreate(entry.resource); entry.resource.id = uuidv4(); const { id } = await createResource(entry.resource, entry.resource.resourceType); if (id != null) { @@ -98,6 +84,16 @@ async function insertBundleResources(entry: DetailedEntry) { } } else { if (entry.resource.id) { + // note: the distance between this database call and the update resource call, could cause a race condition + const oldResource = (await findResourceById(entry.resource.id, entry.resource.resourceType)) as + | fhir4.Library + | fhir4.Measure + | null; + if (oldResource) { + checkFieldsForUpdate(entry.resource, oldResource); + } else { + checkFieldsForCreate(entry.resource); + } const { id, created } = await updateResource(entry.resource.id, entry.resource, entry.resource.resourceType); if (created === true) { entry.status = 201; diff --git a/service/src/services/LibraryService.ts b/service/src/services/LibraryService.ts index 47baa6e8..d291d3e3 100644 --- a/service/src/services/LibraryService.ts +++ b/service/src/services/LibraryService.ts @@ -1,6 +1,7 @@ import { loggers, RequestArgs, RequestCtx } from '@projecttacoma/node-fhir-server-core'; import { createResource, + deleteResource, findDataRequirementsWithQuery, findResourceById, findResourceCountWithQuery, @@ -18,7 +19,10 @@ import { gatherParams, validateParamIdSource, checkContentTypeHeader, - checkExpectedResourceType + checkExpectedResourceType, + checkFieldsForCreate, + checkFieldsForUpdate, + checkFieldsForDelete } from '../util/inputUtils'; import { v4 as uuidv4 } from 'uuid'; import { Calculator } from 'fqm-execution'; @@ -97,11 +101,8 @@ export class LibraryService implements Service { checkContentTypeHeader(contentType); const resource = req.body; checkExpectedResourceType(resource.resourceType, 'Library'); + checkFieldsForCreate(resource); resource['id'] = uuidv4(); - if (resource.status != 'active') { - resource.status = 'active'; - logger.warn(`Resource ${resource.id} has been coerced to active`); - } return createResource(resource, 'Library'); } @@ -120,13 +121,29 @@ export class LibraryService implements Service { if (resource.id !== args.id) { throw new BadRequestError('Argument id must match request body id for PUT request'); } - if (resource.status != 'active') { - resource.status = 'active'; - logger.warn(`Resource ${resource.id} has been coerced to active`); + // note: the distance between this database call and the update resource call, could cause a race condition + const oldResource = (await findResourceById(resource.id, resource.resourceType)) as fhir4.Library | null; + if (oldResource) { + checkFieldsForUpdate(resource, oldResource); + } else { + checkFieldsForCreate(resource); } return updateResource(args.id, resource, 'Library'); } + /** + * result of sending a DELETE request to {BASE_URL}/4_0_1/Library/{id} + * deletes the library with the passed in id if it exists in the database + */ + async remove(args: RequestArgs) { + const resource = (await findResourceById(args.id, 'Library')) as fhir4.Library | null; + if (!resource) { + throw new ResourceNotFoundError(`Existing resource not found with id ${args.id}`); + } + checkFieldsForDelete(resource); + return deleteResource(args.id, 'Library'); + } + /** * result of sending a POST or GET request to: * {BASE_URL}/4_0_1/Library/$cqfm.package or {BASE_URL}/4_0_1/Library/:id/$cqfm.package diff --git a/service/src/services/MeasureService.ts b/service/src/services/MeasureService.ts index ceafdd5c..1cfa3466 100644 --- a/service/src/services/MeasureService.ts +++ b/service/src/services/MeasureService.ts @@ -1,6 +1,7 @@ import { loggers, RequestArgs, RequestCtx } from '@projecttacoma/node-fhir-server-core'; import { createResource, + deleteResource, findDataRequirementsWithQuery, findResourceById, findResourceCountWithQuery, @@ -17,7 +18,10 @@ import { gatherParams, validateParamIdSource, checkContentTypeHeader, - checkExpectedResourceType + checkExpectedResourceType, + checkFieldsForCreate, + checkFieldsForUpdate, + checkFieldsForDelete } from '../util/inputUtils'; import { Calculator } from 'fqm-execution'; import { MeasureSearchArgs, MeasureDataRequirementsArgs, PackageArgs, parseRequestSchema } from '../requestSchemas'; @@ -98,11 +102,8 @@ export class MeasureService implements Service { checkContentTypeHeader(contentType); const resource = req.body; checkExpectedResourceType(resource.resourceType, 'Measure'); + checkFieldsForCreate(resource); resource['id'] = uuidv4(); - if (resource.status != 'active') { - resource.status = 'active'; - logger.warn(`Resource ${resource.id} has been coerced to active`); - } return createResource(resource, 'Measure'); } @@ -121,13 +122,30 @@ export class MeasureService implements Service { if (resource.id !== args.id) { throw new BadRequestError('Argument id must match request body id for PUT request'); } - if (resource.status != 'active') { - resource.status = 'active'; - logger.warn(`Resource ${resource.id} has been coerced to active`); + const oldResource = (await findResourceById(resource.id, resource.resourceType)) as fhir4.Measure | null; + // note: the distance between this database call and the update resource call, could cause a race condition + if (oldResource) { + checkFieldsForUpdate(resource, oldResource); + } else { + checkFieldsForCreate(resource); } + return updateResource(args.id, resource, 'Measure'); } + /** + * result of sending a DELETE request to {BASE_URL}/4_0_1/measure/{id} + * deletes the measure with the passed in id if it exists in the database + */ + async remove(args: RequestArgs) { + const resource = (await findResourceById(args.id, 'Measure')) as fhir4.Measure | null; + if (!resource) { + throw new ResourceNotFoundError(`Existing resource not found with id ${args.id}`); + } + checkFieldsForDelete(resource); + return deleteResource(args.id, 'Measure'); + } + /** * result of sending a POST or GET request to: * {BASE_URL}/4_0_1/Measure/$cqfm.package or {BASE_URL}/4_0_1/Measure/:id/$cqfm.package diff --git a/service/src/util/baseUtils.ts b/service/src/util/baseUtils.ts index 98c332de..35d01f93 100644 --- a/service/src/util/baseUtils.ts +++ b/service/src/util/baseUtils.ts @@ -19,7 +19,7 @@ export type DetailedEntry = fhir4.BundleEntry & { * * returns modified entry and url of owned library */ -export function addIsOwnedExtension(entry: DetailedEntry) { +export function addIsOwnedExtension(entry: fhir4.BundleEntry) { if (entry.resource?.resourceType && entry.resource?.resourceType === 'Measure' && entry.resource?.library) { // get the main Library of the Measure from the library property and the version const mainLibrary = entry.resource.library[0]; @@ -70,7 +70,7 @@ export function addIsOwnedExtension(entry: DetailedEntry) { /** * Checks ownedUrls for entry url and adds isOwned extension to the resource if found in ownedUrls */ -export function addLibraryIsOwned(entry: DetailedEntry, ownedUrls: string[]) { +export function addLibraryIsOwned(entry: fhir4.BundleEntry, ownedUrls: string[]) { // add owned to identified resources (currently assumes these will only be Libraries) if (entry.resource?.resourceType === 'Library' && entry.resource.url) { const libraryUrl = entry.resource.version diff --git a/service/src/util/inputUtils.ts b/service/src/util/inputUtils.ts index 10703f81..5469982c 100644 --- a/service/src/util/inputUtils.ts +++ b/service/src/util/inputUtils.ts @@ -1,6 +1,7 @@ import { RequestArgs, RequestQuery, FhirResourceType } from '@projecttacoma/node-fhir-server-core'; import { Filter } from 'mongodb'; import { BadRequestError } from './errorUtils'; +import _ from 'lodash'; /* * Gathers parameters from both the query and the FHIR parameter request body resource @@ -67,3 +68,79 @@ export function checkExpectedResourceType(resourceType: string, expectedResource throw new BadRequestError(`Expected resourceType '${expectedResourceType}' in body. Received '${resourceType}'.`); } } + +export function checkFieldsForCreate(resource: fhir4.Measure | fhir4.Library) { + // base shareable artifact requires url, version, title, status (required by base FHIR), description + if (!resource.url || !resource.version || !resource.title || !resource.description) { + throw new BadRequestError('Created artifacts must have url, version, title, status, and description'); + } + + if (process.env.AUTHORING === 'true') { + // authoring requires active or draft status + if (resource.status !== 'active' && resource.status !== 'draft') { + throw new BadRequestError( + 'Authoring repository service creations may only be made for active or draft status resources.' + ); + } + } else { + // publishable requires active status + if (resource.status !== 'active') { + throw new BadRequestError( + 'Publishable repository service creations may only be made for active status resources.' + ); + } + } +} + +export function checkFieldsForUpdate( + resource: fhir4.Measure | fhir4.Library, + oldResource: fhir4.Measure | fhir4.Library +) { + if (process.env.AUTHORING !== 'true' || oldResource.status === 'active') { + // publishable or active status requires retire functionality + if (process.env.AUTHORING !== 'true' && oldResource.status !== 'active') { + throw new BadRequestError( + `Resource status is currently ${oldResource.status}. Publishable repository service updates may only be made to active status resources.` + ); + } + const { status: statusOld, date: dateOld, ...limitedOld } = oldResource; // eslint-disable-line @typescript-eslint/no-unused-vars + const { status: statusNew, date: dateNew, ...limitedNew } = resource; // eslint-disable-line @typescript-eslint/no-unused-vars + + if (statusNew !== 'retired') { + throw new BadRequestError('Updating active status resources requires changing the resource status to retired.'); + } + + if (!_.isEqual(limitedOld, limitedNew)) { + throw new BadRequestError('Updating active status resources may only change the status and date.'); + } + } else if (oldResource.status === 'draft') { + // authoring and draft status requires revise functionality + if (resource.status != 'draft') { + throw new BadRequestError('Existing draft resources must stay in draft while revising.'); + } + // base shareable artifact requires url, version, title, status (required by base FHIR), description + if (!resource.url || !resource.version || !resource.title || !resource.description) { + throw new BadRequestError('Artifacts must have url, version, title, status, and description'); + } + } else { + throw new BadRequestError(`Cannot update existing resource with status ${oldResource.status}`); + } +} + +export function checkFieldsForDelete(resource: fhir4.Measure | fhir4.Library) { + if (process.env.AUTHORING === 'true') { + // authoring requires draft or retired status + if (resource.status !== 'draft' && resource.status !== 'retired') { + throw new BadRequestError( + 'Authoring repository service deletions may only be made to draft or retired status resources.' + ); + } + } else { + // publishable requires retired status + if (resource.status !== 'retired') { + throw new BadRequestError( + 'Publishable repository service deletions may only be made to retired status resources.' + ); + } + } +} diff --git a/service/test/services/BaseService.test.ts b/service/test/services/BaseService.test.ts index 3975b4a6..83ac2848 100644 --- a/service/test/services/BaseService.test.ts +++ b/service/test/services/BaseService.test.ts @@ -96,7 +96,12 @@ const VALID_PUT_REQ = { resource: { resourceType: 'Measure', id: 'test-measure', - library: ['Library/test-library'] + library: ['Library/test-library'], + status: 'draft', + url: 'http://example.com', + version: '1', + title: 'Sample title', + description: 'Sample description' }, request: { method: 'PUT', @@ -113,7 +118,12 @@ const VALID_POST_REQ = { { resource: { resourceType: 'Library', - id: 'test-library' + id: 'test-library', + status: 'draft', + url: 'http://example.com', + version: '1', + title: 'Sample title', + description: 'Sample description' }, request: { method: 'POST', @@ -126,6 +136,7 @@ const VALID_POST_REQ = { describe('BaseService', () => { beforeAll(async () => { server = initialize(serverConfig, app); + process.env.AUTHORING = 'true'; return setupTestDatabase([]); }); diff --git a/service/test/services/LibraryService.test.ts b/service/test/services/LibraryService.test.ts index 16a6c1cf..84514d8c 100644 --- a/service/test/services/LibraryService.test.ts +++ b/service/test/services/LibraryService.test.ts @@ -6,12 +6,34 @@ import { Calculator } from 'fqm-execution'; let server: Server; +// boiler plate required fields +const LIBRARY_BASE = { + type: { coding: [{ code: 'logic-library' }] }, + url: 'http://example.com', + version: '1', + title: 'Sample title', + description: 'Sample description' +}; + const LIBRARY_WITH_URL: fhir4.Library = { resourceType: 'Library', - type: { coding: [{ code: 'logic-library' }] }, id: 'testWithUrl', status: 'active', - url: 'http://example.com' + ...LIBRARY_BASE +}; + +const LIBRARY_WITH_URL_2: fhir4.Library = { + resourceType: 'Library', + id: 'testWithUrl2', + status: 'draft', + ...LIBRARY_BASE +}; + +const DRAFT_LIBRARY_WITH_URL: fhir4.Library = { + resourceType: 'Library', + id: 'testWithUrl', + status: 'draft', + ...LIBRARY_BASE }; const LIBRARY_WITH_URL_ONLY_ID: fhir4.Library = { @@ -31,25 +53,25 @@ const LIBRARY_WITH_URL_ONLY_ID: fhir4.Library = { const LIBRARY_WITH_IDENTIFIER_VALUE: fhir4.Library = { resourceType: 'Library', - type: { coding: [{ code: 'logic-library' }] }, identifier: [{ value: 'libraryWithIdentifierValue' }], - status: 'active' + status: 'active', + ...LIBRARY_BASE }; const LIBRARY_WITH_IDENTIFIER_SYSTEM: fhir4.Library = { resourceType: 'Library', - type: { coding: [{ code: 'logic-library' }] }, identifier: [{ system: 'http://example.com/libraryWithIdentifierSystem' }], - status: 'active' + status: 'active', + ...LIBRARY_BASE }; const LIBRARY_WITH_IDENTIFIER_SYSTEM_AND_VALUE: fhir4.Library = { resourceType: 'Library', - type: { coding: [{ code: 'logic-library' }] }, identifier: [ { system: 'http://example.com/libraryWithIdentifierSystemAndValue', value: 'libraryWithIdentifierSystemAndValue' } ], - status: 'active' + status: 'active', + ...LIBRARY_BASE }; const LIBRARY_WITH_NO_DEPS: fhir4.Library = { @@ -103,9 +125,11 @@ const LIBRARY_WITH_SAME_SYSTEM2: fhir4.Library = { status: 'active' }; + describe('LibraryService', () => { beforeAll(() => { server = initialize(serverConfig); + process.env.AUTHORING = 'true'; return setupTestDatabase([ LIBRARY_WITH_URL, LIBRARY_WITH_NO_DEPS, @@ -148,7 +172,7 @@ describe('LibraryService', () => { it('returns 200 and correct searchset bundle when query matches single resource', async () => { await supertest(server.app) .get('/4_0_1/Library') - .query({ url: 'http://example.com', status: 'active' }) + .query({ url: 'http://example.com', status: 'active', id: 'testWithUrl' }) .set('Accept', 'application/json+fhir') .expect(200) .then(response => { @@ -183,7 +207,7 @@ describe('LibraryService', () => { it('returns 200 and correct searchset bundle with only id element when query matches single resource', async () => { await supertest(server.app) .get('/4_0_1/Library') - .query({ _elements: 'id', status: 'active', url: 'http://example.com' }) + .query({ _elements: 'id', status: 'active', url: 'http://example.com', id: 'testWithUrl'}) .set('Accept', 'application/json+fhir') .expect(200) .then(response => { @@ -240,12 +264,94 @@ describe('LibraryService', () => { }); }); - describe('create', () => { - it('returns 201 status with populated location when provided correct headers and a FHIR Library', async () => { + describe('publishable repository validation', () => { + const ORIGINAL_AUTHORING = process.env.AUTHORING; + beforeAll(() => { + process.env.AUTHORING = 'false'; + createTestResource( + { + resourceType: 'Library', + id: 'publishable-retired', + status: 'retired', + ...LIBRARY_BASE + }, + 'Library' + ); + return createTestResource( + { + resourceType: 'Library', + id: 'publishable-active', + status: 'active', + ...LIBRARY_BASE + }, + 'Library' + ); + }); + it('publish: returns 400 status when provided with artifact in non-active status', async () => { await supertest(server.app) .post('/4_0_1/Library') .send({ resourceType: 'Library', status: 'draft' }) .set('content-type', 'application/json+fhir') + .expect(400); + }); + it('retire: returns 400 when artifact to update is not in active status', async () => { + await supertest(server.app) + .put('/4_0_1/Library/publishable-retired') + .send({ + resourceType: 'Library', + id: 'publishable-retired', + status: 'active', + ...LIBRARY_BASE + }) + .set('content-type', 'application/json+fhir') + .expect(400); + }); + + it('retire: returns 400 when attempting to update non-date/non-status fields', async () => { + await supertest(server.app) + .put('/4_0_1/Library/publishable-active') + .send({ + resourceType: 'Library', + id: 'publishable-active', + status: 'retired', + title: 'updated', + type: { coding: [{ code: 'logic-library' }], + url: 'http://example.com', + version: '1', + description: 'Sample description' } + }) + .set('content-type', 'application/json+fhir') + .expect(400); + }); + it('archive: returns 400 status when deleting an active artifact', async () => { + await supertest(server.app) + .delete('/4_0_1/Library/publishable-active') + .send() + .set('content-type', 'application/json+fhir') + .expect(400); + }); + afterAll(() => { + process.env.AUTHORING = ORIGINAL_AUTHORING; + }); + }); + + describe('create', () => { + it('submit: returns 201 status with populated location when provided correct headers and a FHIR Library', async () => { + await supertest(server.app) + .post('/4_0_1/Library') + .send(DRAFT_LIBRARY_WITH_URL) + .set('content-type', 'application/json+fhir') + .expect(201) + .then(response => { + expect(response.headers.location).toBeDefined(); + }); + }); + + it('publish: returns 201 status with populated location when provided correct headers and a FHIR Library', async () => { + await supertest(server.app) + .post('/4_0_1/Library') + .send(LIBRARY_WITH_URL) + .set('content-type', 'application/json+fhir') .expect(201) .then(response => { expect(response.headers.location).toBeDefined(); @@ -255,16 +361,66 @@ describe('LibraryService', () => { describe('update', () => { beforeAll(() => { + createTestResource( + { + resourceType: 'Library', + id: 'exampleId-active', + status: 'active', + ...LIBRARY_BASE + }, + 'Library' + ); return createTestResource( - { resourceType: 'Library', type: { coding: [{ code: 'logic-library' }] }, id: 'exampleId', status: 'draft' }, + { + resourceType: 'Library', + id: 'exampleId', + status: 'draft', + ...LIBRARY_BASE + }, 'Library' ); }); - it('returns 200 when provided correct headers and a FHIR Library whose id is in the database', async () => { + it('revise: returns 200 when provided correct headers and a FHIR Library whose id is in the database', async () => { await supertest(server.app) .put('/4_0_1/Library/exampleId') - .send({ resourceType: 'Library', id: 'exampleId', status: 'active' }) + .send({ + resourceType: 'Library', + type: { coding: [{ code: 'logic-library' }] }, + id: 'exampleId', + status: 'draft', + title: 'updated', + url: 'http://example.com', + version: '1', + description: 'Sample description' + }) + .set('content-type', 'application/json+fhir') + .expect(200) + .then(response => { + expect(response.headers.location).toBeDefined(); + }); + }); + + it('revise: returns 400 when status changes', async () => { + await supertest(server.app) + .put('/4_0_1/Library/exampleId') + .send({ resourceType: 'Library', id: 'exampleId', status: 'active', title: 'updated', + url: 'http://example.com', + version: '1', + description: 'Sample description' }) + .set('content-type', 'application/json+fhir') + .expect(400); + }); + + it('retire: returns 200 when provided updated status for retiring', async () => { + await supertest(server.app) + .put('/4_0_1/Library/exampleId-active') + .send({ + resourceType: 'Library', + id: 'exampleId-active', + status: 'retired', + ...LIBRARY_BASE + }) .set('content-type', 'application/json+fhir') .expect(200) .then(response => { @@ -274,8 +430,8 @@ describe('LibraryService', () => { it('returns 201 when provided correct headers and a FHIR Library whose id is not in the database', async () => { await supertest(server.app) - .put('/4_0_1/Library/newId') - .send({ resourceType: 'Library', id: 'newId', status: 'draft' }) + .put('/4_0_1/Library/testWithUrl2') + .send(LIBRARY_WITH_URL_2) .set('content-type', 'application/json+fhir') .expect(201) .then(response => { @@ -300,6 +456,59 @@ describe('LibraryService', () => { }); }); + describe('delete', () => { + beforeAll(() => { + createTestResource( + { + resourceType: 'Library', + id: 'delete-active', + status: 'active', + ...LIBRARY_BASE + }, + 'Library' + ); + createTestResource( + { + resourceType: 'Library', + id: 'delete-retired', + status: 'retired', + ...LIBRARY_BASE + }, + 'Library' + ); + return createTestResource( + { + resourceType: 'Library', + id: 'delete-draft', + status: 'draft', + ...LIBRARY_BASE + }, + 'Library' + ); + }); + it('withdraw: returns 204 status when deleting a draft artifact', async () => { + await supertest(server.app) + .delete('/4_0_1/Library/delete-draft') + .send() + .set('content-type', 'application/json+fhir') + .expect(204); + }); + it('archive: returns 204 status when deleting a retired artifact', async () => { + await supertest(server.app) + .delete('/4_0_1/Library/delete-retired') + .send() + .set('content-type', 'application/json+fhir') + .expect(204); + }); + it('archive: returns 400 status when deleting an active artifact', async () => { + await supertest(server.app) + .delete('/4_0_1/Library/delete-active') + .send() + .set('content-type', 'application/json+fhir') + .expect(400); + }); + }); + describe('$cqfm.package', () => { it('returns a Bundle including the Library when the Library has no dependencies and id passed through args', async () => { await supertest(server.app) diff --git a/service/test/services/MeasureService.test.ts b/service/test/services/MeasureService.test.ts index 4d0f501f..65c33e57 100644 --- a/service/test/services/MeasureService.test.ts +++ b/service/test/services/MeasureService.test.ts @@ -5,15 +5,35 @@ import supertest from 'supertest'; import { Calculator } from 'fqm-execution'; let server: Server; +// boiler plate required fields +const MEASURE_BASE = { + url: 'http://example.com', + version: '1', + title: 'Sample title', + description: 'Sample description' +}; -const MEASURE: fhir4.Measure = { resourceType: 'Measure', id: 'test', status: 'active', version: 'searchable' }; +const MEASURE: fhir4.Measure = { resourceType: 'Measure', id: 'test', status: 'active', ...MEASURE_BASE }; const MEASURE_WITH_URL: fhir4.Measure = { resourceType: 'Measure', id: 'testWithUrl', status: 'active', - url: 'http://example.com', - version: 'searchable' + ...MEASURE_BASE +}; + +const MEASURE_WITH_URL_2: fhir4.Measure = { + resourceType: 'Measure', + id: 'testWithUrl2', + status: 'draft', + ...MEASURE_BASE +}; + +const DRAFT_MEASURE_WITH_URL: fhir4.Measure = { + resourceType: 'Measure', + id: 'testWithUrl', + status: 'active', + ...MEASURE_BASE }; const MEASURE_WITH_URL_ONLY_ID: fhir4.Measure = { @@ -34,14 +54,16 @@ const MEASURE_WITH_IDENTIFIER_VALUE_ROOT_LIB: fhir4.Measure = { resourceType: 'Measure', identifier: [{ value: 'measureWithIdentifierValueRootLib' }], library: ['http://example.com/testLibrary'], - status: 'active' + status: 'active', + ...MEASURE_BASE }; const MEASURE_WITH_IDENTIFIER_SYSTEM_ROOT_LIB: fhir4.Measure = { resourceType: 'Measure', identifier: [{ system: 'http://example.com/measureWithIdentifierSystemRootLib' }], library: ['http://example.com/testLibrary'], - status: 'active' + status: 'active', + ...MEASURE_BASE }; const MEASURE_WITH_IDENTIFIER_SYSTEM_AND_VALUE_ROOT_LIB: fhir4.Measure = { @@ -53,7 +75,8 @@ const MEASURE_WITH_IDENTIFIER_SYSTEM_AND_VALUE_ROOT_LIB: fhir4.Measure = { } ], library: ['http://example.com/testLibrary'], - status: 'active' + status: 'active', + ...MEASURE_BASE }; const MEASURE_WITH_ROOT_LIB: fhir4.Measure = { @@ -97,6 +120,7 @@ const LIBRARY_WITH_DEPS: fhir4.Library = { describe('MeasureService', () => { beforeAll(() => { server = initialize(serverConfig); + process.env.AUTHORING = 'true'; return setupTestDatabase([ MEASURE, MEASURE_WITH_URL, @@ -139,7 +163,7 @@ describe('MeasureService', () => { it('returns 200 and correct searchset bundle when query matches single resource', async () => { await supertest(server.app) .get('/4_0_1/Measure') - .query({ url: 'http://example.com', status: 'active' }) + .query({ url: 'http://example.com', status: 'active', id: 'testWithUrl' }) .set('Accept', 'application/json+fhir') .expect(200) .then(response => { @@ -174,7 +198,7 @@ describe('MeasureService', () => { it('returns 200 and correct searchset bundle with only id element when query matches single resource', async () => { await supertest(server.app) .get('/4_0_1/Measure') - .query({ _elements: 'id', status: 'active', url: 'http://example.com' }) + .query({ _elements: 'id', status: 'active', url: 'http://example.com', id: 'testWithUrl' }) .set('Accept', 'application/json+fhir') .expect(200) .then(response => { @@ -231,12 +255,92 @@ describe('MeasureService', () => { }); }); - describe('create', () => { - it('returns 201 status with populated location when provided correct headers and a FHIR Measure', async () => { + describe('publishable repository validation', () => { + const ORIGINAL_AUTHORING = process.env.AUTHORING; + beforeAll(() => { + process.env.AUTHORING = 'false'; + createTestResource( + { + resourceType: 'Measure', + id: 'publishable-retired', + status: 'retired', + ...MEASURE_BASE + }, + 'Measure' + ); + return createTestResource( + { + resourceType: 'Measure', + id: 'publishable-active', + status: 'active', + ...MEASURE_BASE + }, + 'Measure' + ); + }); + it('publish: returns 400 status when provided with artifact in non-active status', async () => { await supertest(server.app) .post('/4_0_1/Measure') .send({ resourceType: 'Measure', status: 'draft' }) .set('content-type', 'application/json+fhir') + .expect(400); + }); + it('retire: returns 400 when artifact to update is not in active status', async () => { + await supertest(server.app) + .put('/4_0_1/Measure/publishable-retired') + .send({ + resourceType: 'Measure', + id: 'publishable-retired', + status: 'active', + ...MEASURE_BASE + }) + .set('content-type', 'application/json+fhir') + .expect(400); + }); + + it('retire: returns 400 when attempting to update non-date/non-status fields', async () => { + await supertest(server.app) + .put('/4_0_1/Measure/publishable-active') + .send({ + resourceType: 'Measure', + id: 'publishable-active', + status: 'retired', + title: 'updated', + url: 'http://example.com', + version: '1', + description: 'Sample description' + }) + .set('content-type', 'application/json+fhir') + .expect(400); + }); + it('archive: returns 400 status when deleting an active artifact', async () => { + await supertest(server.app) + .delete('/4_0_1/Measure/publishable-active') + .send() + .set('content-type', 'application/json+fhir') + .expect(400); + }); + afterAll(() => { + process.env.AUTHORING = ORIGINAL_AUTHORING; + }); + }); + + describe('create', () => { + it('submit: returns 201 status with populated location when provided correct headers and a FHIR Measure', async () => { + await supertest(server.app) + .post('/4_0_1/Measure') + .send(DRAFT_MEASURE_WITH_URL) + .set('content-type', 'application/json+fhir') + .expect(201) + .then(response => { + expect(response.headers.location).toBeDefined(); + }); + }); + it('publish: returns 201 status with populated location when provided correct headers and a FHIR Measure', async () => { + await supertest(server.app) + .post('/4_0_1/Measure') + .send(MEASURE_WITH_URL) + .set('content-type', 'application/json+fhir') .expect(201) .then(response => { expect(response.headers.location).toBeDefined(); @@ -246,13 +350,48 @@ describe('MeasureService', () => { describe('update', () => { beforeAll(() => { - return createTestResource({ resourceType: 'Measure', id: 'exampleId', status: 'draft' }, 'Measure'); + createTestResource( + { resourceType: 'Measure', id: 'exampleId-active', status: 'active', + ...MEASURE_BASE }, + 'Measure' + ); + return createTestResource( + { resourceType: 'Measure', id: 'exampleId', status: 'draft', + ...MEASURE_BASE }, + 'Measure' + ); + }); + + it('revise: returns 200 when provided correct headers and a FHIR Measure whose id is in the database', async () => { + await supertest(server.app) + .put('/4_0_1/Measure/exampleId') + .send({ resourceType: 'Measure', id: 'exampleId', status: 'draft', title: 'updated', + url: 'http://example.com', + version: '1', + description: 'Sample description' }) + .set('content-type', 'application/json+fhir') + .expect(200) + .then(response => { + expect(response.headers.location).toBeDefined(); + }); }); - it('returns 200 when provided correct headers and a FHIR Measure whose id is in the database', async () => { + it('revise: returns 400 when status changes', async () => { await supertest(server.app) .put('/4_0_1/Measure/exampleId') - .send({ resourceType: 'Measure', id: 'exampleId', status: 'active' }) + .send({ resourceType: 'Measure', id: 'exampleId', status: 'active', title: 'updated', + url: 'http://example.com', + version: '1', + description: 'Sample description' }) + .set('content-type', 'application/json+fhir') + .expect(400); + }); + + it('retire: returns 200 when provided updated status for retiring', async () => { + await supertest(server.app) + .put('/4_0_1/Measure/exampleId-active') + .send({ resourceType: 'Measure', id: 'exampleId-active', status: 'retired', + ...MEASURE_BASE}) .set('content-type', 'application/json+fhir') .expect(200) .then(response => { @@ -262,8 +401,8 @@ describe('MeasureService', () => { it('returns 201 when provided correct headers and a FHIR Measure whose id is not in the database', async () => { await supertest(server.app) - .put('/4_0_1/Measure/newId') - .send({ resourceType: 'Measure', id: 'newId', status: 'draft' }) + .put('/4_0_1/Measure/testWithUrl2') + .send(MEASURE_WITH_URL_2) .set('content-type', 'application/json+fhir') .expect(201) .then(response => { @@ -288,6 +427,59 @@ describe('MeasureService', () => { }); }); + describe('delete', () => { + beforeAll(() => { + createTestResource( + { + resourceType: 'Measure', + id: 'delete-active', + status: 'active', + ...MEASURE_BASE + }, + 'Measure' + ); + createTestResource( + { + resourceType: 'Measure', + id: 'delete-retired', + status: 'retired', + ...MEASURE_BASE + }, + 'Measure' + ); + return createTestResource( + { + resourceType: 'Measure', + id: 'delete-draft', + status: 'draft', + ...MEASURE_BASE + }, + 'Measure' + ); + }); + it('withdraw: returns 204 status when deleting a draft artifact', async () => { + await supertest(server.app) + .delete('/4_0_1/Measure/delete-draft') + .send() + .set('content-type', 'application/json+fhir') + .expect(204); + }); + it('archive: returns 204 status when deleting a retired artifact', async () => { + await supertest(server.app) + .delete('/4_0_1/Measure/delete-retired') + .send() + .set('content-type', 'application/json+fhir') + .expect(204); + }); + it('archive: returns 400 status when deleting an active artifact', async () => { + await supertest(server.app) + .delete('/4_0_1/Measure/delete-active') + .send() + .set('content-type', 'application/json+fhir') + .expect(400); + }); + }); + describe('$cqfm.package', () => { it('returns a Bundle including the root lib and Measure when root lib has no dependencies and id passed through args', async () => { await supertest(server.app)