Skip to content

Commit

Permalink
feat(AsyncJob): add $cancel operation for AsyncJobs (medplum#5514)
Browse files Browse the repository at this point in the history
* feat(AsyncJob): add `$cancel` operation for `AsyncJob`s

* feat(AsyncJob): test that status is `accepted` before cancelling

* test(AsyncJob): add tests for cancel operation

* tweak(AsyncJob): No-op if already cancelled

* fix(AsyncJob): don't attempt to patch unless `accepted`

* fix(server): revert change to `sendOutcome`

* fix(AsyncJob): remove unnecessary `try... catch`

* test(AsyncJob): rm unnecessary test
  • Loading branch information
ThatOneBro authored Nov 18, 2024
1 parent a50e27e commit b0ed07f
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 7 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "never"
}
},
"cSpell.words": ["Medplum", "FHIR"]
}
4 changes: 2 additions & 2 deletions packages/definitions/dist/fhir/r4/fhir.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion packages/definitions/dist/fhir/r4/profiles-medplum.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/definitions/dist/fhir/r4/valuesets-medplum.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,11 @@
"code": "error",
"display": "Error",
"definition": "Error"
},
{
"code": "cancelled",
"display": "Cancelled",
"definition": "Cancelled"
}
]
}
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/static/data/medplumDefinitions/asyncjob.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/fhirtypes/dist/AsyncJob.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/fhirtypes/dist/BulkDataExport.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
138 changes: 138 additions & 0 deletions packages/server/src/fhir/operations/asyncjobcancel.test.ts
Original file line number Diff line number Diff line change
@@ -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<OperationOutcome>(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<AsyncJob>({
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<OperationOutcome>(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<AsyncJob>({
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<OperationOutcome>(
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'));
});
});
48 changes: 48 additions & 0 deletions packages/server/src/fhir/operations/asyncjobcancel.ts
Original file line number Diff line number Diff line change
@@ -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>('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);
});
4 changes: 4 additions & 0 deletions packages/server/src/fhir/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = [
Expand Down

0 comments on commit b0ed07f

Please sign in to comment.