diff --git a/.vscode/settings.json b/.vscode/settings.json index 612d4448ba..3c8012515e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,6 @@ }, "editor.codeActionsOnSave": { "source.fixAll.eslint": "never" - } + }, + "cSpell.words": ["Medplum", "FHIR"] } diff --git a/packages/definitions/dist/fhir/r4/fhir.schema.json b/packages/definitions/dist/fhir/r4/fhir.schema.json index 55e0a9720b..c8b047bf70 100644 --- a/packages/definitions/dist/fhir/r4/fhir.schema.json +++ b/packages/definitions/dist/fhir/r4/fhir.schema.json @@ -60782,7 +60782,7 @@ }, "status": { "description": "The status of the request.", - "enum": ["accepted", "active", "completed", "error"] + "enum": ["accepted", "active", "completed", "error", "cancelled"] }, "requestTime": { "description": "Indicates the server\u0027s time when the query is requested.", @@ -61059,7 +61059,7 @@ }, "status": { "description": "The status of the request.", - "enum": ["accepted", "active", "completed", "error"] + "enum": ["accepted", "active", "completed", "error", "cancelled"] }, "requestTime": { "description": "Indicates the server\u0027s time when the query is requested.", diff --git a/packages/definitions/dist/fhir/r4/profiles-medplum.json b/packages/definitions/dist/fhir/r4/profiles-medplum.json index fb75151bb8..a88f8f92a4 100644 --- a/packages/definitions/dist/fhir/r4/profiles-medplum.json +++ b/packages/definitions/dist/fhir/r4/profiles-medplum.json @@ -5250,7 +5250,7 @@ { "id" : "AsyncJob.status", "path" : "AsyncJob.status", - "short" : "accepted | error | completed", + "short" : "accepted | error | completed | cancelled", "definition" : "The status of the request.", "min" : 1, "max" : "1", diff --git a/packages/definitions/dist/fhir/r4/valuesets-medplum.json b/packages/definitions/dist/fhir/r4/valuesets-medplum.json index af4c834c99..de472ff45c 100644 --- a/packages/definitions/dist/fhir/r4/valuesets-medplum.json +++ b/packages/definitions/dist/fhir/r4/valuesets-medplum.json @@ -496,6 +496,11 @@ "code": "error", "display": "Error", "definition": "Error" + }, + { + "code": "cancelled", + "display": "Cancelled", + "definition": "Cancelled" } ] } diff --git a/packages/docs/static/data/medplumDefinitions/asyncjob.json b/packages/docs/static/data/medplumDefinitions/asyncjob.json index bdd84595aa..bc33ec4bfb 100644 --- a/packages/docs/static/data/medplumDefinitions/asyncjob.json +++ b/packages/docs/static/data/medplumDefinitions/asyncjob.json @@ -170,7 +170,7 @@ "path": "AsyncJob.status", "min": 1, "max": "1", - "short": "accepted | error | completed", + "short": "accepted | error | completed | cancelled", "definition": "The status of the request.", "comment": "", "inherited": false diff --git a/packages/fhirtypes/dist/AsyncJob.d.ts b/packages/fhirtypes/dist/AsyncJob.d.ts index 9c0aa49498..d95b3012cb 100644 --- a/packages/fhirtypes/dist/AsyncJob.d.ts +++ b/packages/fhirtypes/dist/AsyncJob.d.ts @@ -93,7 +93,7 @@ export interface AsyncJob { /** * The status of the request. */ - status: 'accepted' | 'active' | 'completed' | 'error'; + status: 'accepted' | 'active' | 'completed' | 'error' | 'cancelled'; /** * Indicates the server's time when the query is requested. diff --git a/packages/fhirtypes/dist/BulkDataExport.d.ts b/packages/fhirtypes/dist/BulkDataExport.d.ts index 844a859756..8b78831a10 100644 --- a/packages/fhirtypes/dist/BulkDataExport.d.ts +++ b/packages/fhirtypes/dist/BulkDataExport.d.ts @@ -93,7 +93,7 @@ export interface BulkDataExport { /** * The status of the request. */ - status: 'accepted' | 'active' | 'completed' | 'error'; + status: 'accepted' | 'active' | 'completed' | 'error' | 'cancelled'; /** * Indicates the server's time when the query is requested. diff --git a/packages/server/src/fhir/operations/asyncjobcancel.test.ts b/packages/server/src/fhir/operations/asyncjobcancel.test.ts new file mode 100644 index 0000000000..b0105f1332 --- /dev/null +++ b/packages/server/src/fhir/operations/asyncjobcancel.test.ts @@ -0,0 +1,138 @@ +import { allOk, badRequest, ContentType, sleep } from '@medplum/core'; +import { AsyncJob, OperationOutcome } from '@medplum/fhirtypes'; +import express from 'express'; +import request from 'supertest'; +import { initApp, shutdownApp } from '../../app'; +import { loadTestConfig } from '../../config'; +import { initTestAuth } from '../../test.setup'; +import { asyncJobCancelHandler } from './asyncjobcancel'; + +const app = express(); + +describe('AsyncJob/$cancel', () => { + let accessToken: string; + + beforeAll(async () => { + const config = await loadTestConfig(); + await initApp(app, config); + }); + + beforeEach(async () => { + accessToken = await initTestAuth({ superAdmin: true }); + expect(accessToken).toBeDefined(); + }); + + afterAll(async () => { + await shutdownApp(); + }); + + test('Sets AsyncJob.status to `cancelled`', async () => { + const res = await request(app) + .post('/fhir/R4/AsyncJob') + .set('Authorization', 'Bearer ' + accessToken) + .set('Content-Type', ContentType.FHIR_JSON) + .send({ + resourceType: 'AsyncJob', + status: 'accepted', + requestTime: new Date().toISOString(), + request: 'random-request', + } satisfies AsyncJob); + expect(res.status).toEqual(201); + expect(res.body).toBeDefined(); + + const asyncJob = res.body as AsyncJob; + + const res2 = await request(app) + .post(`/fhir/R4/AsyncJob/${asyncJob.id as string}/$cancel`) + .set('Authorization', 'Bearer ' + accessToken); + expect(res2.status).toEqual(200); + expect(res2.body).toMatchObject(allOk); + + const res3 = await request(app) + .get(`/fhir/R4/AsyncJob/${asyncJob.id as string}`) + .set('Authorization', 'Bearer ' + accessToken) + .set('Content-Type', ContentType.FHIR_JSON); + + expect(res3.status).toEqual(200); + expect(res3.body).toMatchObject({ + id: asyncJob.id, + resourceType: 'AsyncJob', + status: 'cancelled', + requestTime: asyncJob.requestTime, + request: 'random-request', + }); + }); + + test('No-op when AsyncJob is already `cancelled`', async () => { + const res = await request(app) + .post('/fhir/R4/AsyncJob') + .set('Authorization', 'Bearer ' + accessToken) + .set('Content-Type', ContentType.FHIR_JSON) + .send({ + resourceType: 'AsyncJob', + status: 'cancelled', + requestTime: new Date().toISOString(), + request: 'random-request', + } satisfies AsyncJob); + expect(res.status).toEqual(201); + expect(res.body).toBeDefined(); + + const asyncJob = res.body as AsyncJob; + + const res2 = await request(app) + .post(`/fhir/R4/AsyncJob/${asyncJob.id as string}/$cancel`) + .set('Authorization', 'Bearer ' + accessToken); + expect(res2.status).toEqual(200); + expect(res2.body).toMatchObject(allOk); + + const res3 = await request(app) + .get(`/fhir/R4/AsyncJob/${asyncJob.id as string}`) + .set('Authorization', 'Bearer ' + accessToken) + .set('Content-Type', ContentType.FHIR_JSON); + + expect(res3.status).toEqual(200); + expect(res3.body).toMatchObject({ + id: asyncJob.id, + resourceType: 'AsyncJob', + status: 'cancelled', + requestTime: asyncJob.requestTime, + request: 'random-request', + }); + }); + + test.each(['completed', 'error'] as const)('Fails if AsyncJob.status is `%s`', async (status) => { + const res = await request(app) + .post('/fhir/R4/AsyncJob') + .set('Authorization', 'Bearer ' + accessToken) + .set('Content-Type', ContentType.FHIR_JSON) + .send({ + resourceType: 'AsyncJob', + status, + requestTime: new Date().toISOString(), + request: 'random-request', + } satisfies AsyncJob); + expect(res.status).toEqual(201); + expect(res.body).toBeDefined(); + + const asyncJob = res.body as AsyncJob; + + const res2 = await request(app) + .post(`/fhir/R4/AsyncJob/${asyncJob.id as string}/$cancel`) + .set('Authorization', 'Bearer ' + accessToken); + expect(res2.status).toEqual(400); + + const outcome = res2.body as OperationOutcome; + expect(outcome).toMatchObject( + badRequest(`AsyncJob cannot be cancelled if status is not 'accepted', job had status '${status}'`) + ); + }); + + test('Fails if not executed on an instance (no ID given)', async () => { + const next = jest.fn(); + expect(() => asyncJobCancelHandler({ params: {} } as express.Request, {} as express.Response, next)).not.toThrow(); + while (next.mock.calls.length === 0) { + await sleep(100); + } + expect(next).toHaveBeenCalledWith(new Error('This operation can only be executed on an instance')); + }); +}); diff --git a/packages/server/src/fhir/operations/asyncjobcancel.ts b/packages/server/src/fhir/operations/asyncjobcancel.ts new file mode 100644 index 0000000000..c7f415b819 --- /dev/null +++ b/packages/server/src/fhir/operations/asyncjobcancel.ts @@ -0,0 +1,48 @@ +import { allOk, assert, badRequest } from '@medplum/core'; +import { AsyncJob, OperationDefinition } from '@medplum/fhirtypes'; +import { Request, Response } from 'express'; +import { asyncWrap } from '../../async'; +import { getAuthenticatedContext } from '../../context'; +import { sendOutcome } from '../outcomes'; + +export const operation: OperationDefinition = { + id: 'AsyncJob-cancel', + resourceType: 'OperationDefinition', + name: 'asyncjob-cancel', + status: 'active', + kind: 'operation', + code: 'cancel', + experimental: true, + resource: ['AsyncJob'], + system: false, + type: false, + instance: true, + parameter: [{ use: 'out', name: 'return', type: 'OperationOutcome', min: 1, max: '1' }], +}; + +/** + * Handles HTTP requests for the AsyncJob $cancel operation. + * Sets the status of the `AsyncJob` to `cancelled`. + */ +export const asyncJobCancelHandler = asyncWrap(async (req: Request, res: Response) => { + assert(req.params.id, 'This operation can only be executed on an instance'); + // Update status of async job + const { repo } = getAuthenticatedContext(); + const job = await repo.readResource('AsyncJob', req.params.id); + switch (job.status) { + case 'accepted': + await repo.patchResource('AsyncJob', req.params.id, [ + { op: 'test', path: '/status', value: 'accepted' }, + { op: 'add', path: '/status', value: 'cancelled' }, + ]); + break; + case 'cancelled': + break; + default: + sendOutcome( + res, + badRequest(`AsyncJob cannot be cancelled if status is not 'accepted', job had status '${job.status}'`) + ); + } + sendOutcome(res, allOk); +}); diff --git a/packages/server/src/fhir/routes.ts b/packages/server/src/fhir/routes.ts index 573c94ec38..b7d17db1d3 100644 --- a/packages/server/src/fhir/routes.ts +++ b/packages/server/src/fhir/routes.ts @@ -16,6 +16,7 @@ import { agentPushHandler } from './operations/agentpush'; import { agentReloadConfigHandler } from './operations/agentreloadconfig'; import { agentStatusHandler } from './operations/agentstatus'; import { agentUpgradeHandler } from './operations/agentupgrade'; +import { asyncJobCancelHandler } from './operations/asyncjobcancel'; import { codeSystemImportHandler } from './operations/codesystemimport'; import { codeSystemLookupHandler } from './operations/codesystemlookup'; import { codeSystemValidateCodeHandler } from './operations/codesystemvalidatecode'; @@ -123,6 +124,9 @@ protectedRoutes.get('/:resourceType/([$]|%24)csv', asyncWrap(csvHandler)); protectedRoutes.post('/Agent/([$]|%24)push', agentPushHandler); protectedRoutes.post('/Agent/:id/([$]|%24)push', agentPushHandler); +// AsyncJob $cancel operation +protectedRoutes.post('/AsyncJob/:id/([$]|%24)cancel', asyncJobCancelHandler); + // Bot $execute operation // Allow extra path content after the "$execute" to support external callers who append path info const botPaths = [