Skip to content
This repository has been archived by the owner on Apr 13, 2023. It is now read-only.

Commit

Permalink
feat: Implement 3.0 interface (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
nguyen102 committed Nov 12, 2020
1 parent 9318894 commit 4c5c310
Show file tree
Hide file tree
Showing 21 changed files with 715 additions and 43 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to this project will be documented in this file.

## [2.0.0] - 2020-11-11

### Added
- Support for DB export by routing export requests to the corresponding BulkDataAccess interfaces as defined in `fhir-works-on-aws-interface` v3.0.0
- Supporting capability statement configuration for OAuth as defined in `fhir-works-on-aws-interface` v3.0.0
- Improved error handling to allow matching of same error objects across different `fhir-works-on-aws-interface` versions
- Support for configuring CORs header

## [1.1.0] - 2020-09-25
- feat: Pass down allowed resource types to search service

Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fhir-works-on-aws-routing",
"version": "1.1.0",
"version": "2.0.0",
"description": "FHIR Works on AWS routing implementation",
"main": "lib/index.js",
"types": "lib/index.d.ts",
Expand Down Expand Up @@ -33,7 +33,7 @@
"cors": "^2.8.5",
"errorhandler": "^1.5.1",
"express": "^4.17.1",
"fhir-works-on-aws-interface": "^2.0.0",
"fhir-works-on-aws-interface": "^3.0.0",
"flat": "^5.0.0",
"http-errors": "^1.8.0",
"lodash": "^4.17.15",
Expand All @@ -50,6 +50,7 @@
"@types/jest": "^25.1.1",
"@types/lodash": "^4.14.157",
"@types/mime-types": "^2.1.0",
"@types/mock-req-res": "^1.1.2",
"@types/node": "^12",
"@types/uuid": "^3.4.7",
"@typescript-eslint/eslint-plugin": "^2.18.0",
Expand All @@ -61,7 +62,9 @@
"eslint-plugin-prettier": "^3.1.2",
"jest": "^25.1.0",
"jest-mock-extended": "^1.0.8",
"mock-req-res": "^1.2.0",
"prettier": "^1.19.1",
"sinon": "^9.0.3",
"ts-jest": "^25.1.0",
"typescript": "^3.7.5"
},
Expand Down
5 changes: 4 additions & 1 deletion sampleData/r4FhirConfigWithExclusions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ const config: FhirConfig = {
orgName: 'Organization Name',
auth: {
strategy: {
oauthUrl: 'http://example.com',
oauth: {
authorizationUrl: 'http://example.com/authorization',
tokenUrl: 'http://example.com/oauth2/token',
},
service: 'SMART-on-FHIR',
},
authorization: stubs.passThroughAuthz,
Expand Down
5 changes: 4 additions & 1 deletion sampleData/stu3FhirConfigWithExclusions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ const config: FhirConfig = {
orgName: 'Organization Name',
auth: {
strategy: {
oauthUrl: 'http://example.com',
oauth: {
authorizationUrl: 'http://example.com/authorization',
tokenUrl: 'http://example.com/oauth2/token',
},
service: 'OAuth',
},
authorization: stubs.passThroughAuthz,
Expand Down
22 changes: 15 additions & 7 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import MetadataRoute from './router/routes/metadataRoute';
import ResourceHandler from './router/handlers/resourceHandler';
import RootRoute from './router/routes/rootRoute';
import { applicationErrorMapper, httpErrorHandler, unknownErrorHandler } from './router/routes/errorHandling';
import ExportRoute from './router/routes/exportRoute';

const configVersionSupported: ConfigVersion = 1;

Expand Down Expand Up @@ -53,24 +54,31 @@ export function generateServerlessRouter(
try {
const requestInformation = getRequestInformation(req.method, req.path);
const accessToken: string = cleanAuthHeader(req.headers.authorization);
const isAllowed: boolean = await fhirConfig.auth.authorization.isAuthorized({
await fhirConfig.auth.authorization.isAuthorized({
...requestInformation,
accessToken,
});
if (isAllowed) {
next();
} else {
res.status(403).json({ message: 'Forbidden' });
}
res.locals.requesterUserId = fhirConfig.auth.authorization.getRequesterUserId(accessToken);
next();
} catch (e) {
res.status(403).json({ message: `Forbidden. ${e.message}` });
next(e);
}
});

// Metadata
const metadataRoute: MetadataRoute = new MetadataRoute(fhirVersion, configHandler, hasCORSEnabled);
app.use('/metadata', metadataRoute.router);

// Export
if (fhirConfig.profile.bulkDataAccess) {
const exportRoute = new ExportRoute(
serverUrl,
fhirConfig.profile.bulkDataAccess,
fhirConfig.auth.authorization,
);
app.use('/', exportRoute.router);
}

// Special Resources
if (fhirConfig.profile.resources) {
Object.entries(fhirConfig.profile.resources).forEach(async resourceEntry => {
Expand Down
13 changes: 12 additions & 1 deletion src/regExpressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { captureFullUrlParts } from './regExpressions';
import { captureFullUrlParts, dateTimeWithTimeZoneRegExp } from './regExpressions';

describe('captureFullUrlParts', () => {
test('Capture rootUrl, resourceType, id, versionId', () => {
Expand Down Expand Up @@ -52,4 +52,15 @@ describe('captureFullUrlParts', () => {
// @ts-ignore
expect([...actualMatch]).toEqual([...expectedMatch]);
});
test('dateTimeWithTimeZoneRegExp', () => {
const utcTimeZone = '2020-09-02T00:00:00Z';
const estTimeZone = '2020-09-02T00:00:00-05:00';
const invalidUtcTimeZone = '2020-09-02T00:00:00R';
const timeWithoutTimeZone = '2020-09-02T00:00:00';

expect(dateTimeWithTimeZoneRegExp.test(utcTimeZone)).toBeTruthy();
expect(dateTimeWithTimeZoneRegExp.test(estTimeZone)).toBeTruthy();
expect(dateTimeWithTimeZoneRegExp.test(invalidUtcTimeZone)).toBeFalsy();
expect(dateTimeWithTimeZoneRegExp.test(timeWithoutTimeZone)).toBeFalsy();
});
});
3 changes: 2 additions & 1 deletion src/regExpressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
export const uuidRegExp = /\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/;
export const resourceTypeWithUuidRegExp = /\w+\/\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/;

export const utcTimeRegExp = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z/;
export const utcTimeRegExp = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?Z/;
export const dateTimeWithTimeZoneRegExp = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?(?:Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])/;
export const timeFromEpochInMsRegExp = /\d{13}/;

// Exp. Patient/de5b1d47-2780-4508-9273-4e0ec133ee3a
Expand Down
14 changes: 14 additions & 0 deletions src/router/__mocks__/dynamoDbDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
generateMeta,
GenericResponse,
clone,
InitiateExportRequest,
GetExportStatusResponse,
} from 'fhir-works-on-aws-interface';
import validPatient from '../../../sampleData/validV4Patient.json';

Expand Down Expand Up @@ -104,5 +106,17 @@ const DynamoDbDataService: Persistence = class {
): Promise<GenericResponse> {
throw new Error('Method not implemented.');
}

static initiateExport(request: InitiateExportRequest): Promise<string> {
throw new Error('Method not implemented.');
}

static cancelExport(jobId: string): Promise<void> {
throw new Error('Method not implemented');
}

static getExportStatus(jobId: string): Promise<GetExportStatusResponse> {
throw new Error('Method not implemented');
}
};
export default DynamoDbDataService;
10 changes: 4 additions & 6 deletions src/router/bundle/bundleHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ export default class BundleHandler implements BundleHandlerInterface {
this.bundleService = bundleService;
this.serverUrl = serverUrl;
this.authService = authService;
this.validator = new Validator(fhirVersion);
this.supportedGenericResources = supportedGenericResources;
this.genericResource = genericResource;
this.resources = resources;
this.supportedGenericResources = supportedGenericResources;

this.validator = new Validator(fhirVersion);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down Expand Up @@ -124,13 +125,10 @@ export default class BundleHandler implements BundleHandlerInterface {
throw new createError.BadRequest(e.message);
}

const isAllowed: boolean = await this.authService.isBundleRequestAuthorized({
await this.authService.isBundleRequestAuthorized({
accessToken,
requests,
});
if (!isAllowed) {
throw new createError.Forbidden('Forbidden');
}

if (requests.length > MAX_BUNDLE_ENTRIES) {
throw new createError.BadRequest(
Expand Down
52 changes: 52 additions & 0 deletions src/router/handlers/exportHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import {
AccessBulkDataJobRequest,
Authorization,
BulkDataAccess,
GetExportStatusResponse,
InitiateExportRequest,
} from 'fhir-works-on-aws-interface';
import createError from 'http-errors';

export default class ExportHandler {
private bulkDataAccess: BulkDataAccess;

private authService: Authorization;

constructor(bulkDataAccess: BulkDataAccess, authService: Authorization) {
this.bulkDataAccess = bulkDataAccess;
this.authService = authService;
}

async initiateExport(initiateExportRequest: InitiateExportRequest): Promise<string> {
return this.bulkDataAccess.initiateExport(initiateExportRequest);
}

async getExportJobStatus(jobId: string, requesterUserId: string): Promise<GetExportStatusResponse> {
const jobDetails = await this.bulkDataAccess.getExportStatus(jobId);
await this.checkIfRequesterHasAccessToJob(jobDetails, requesterUserId);
return jobDetails;
}

async cancelExport(jobId: string, requesterUserId: string): Promise<void> {
const jobDetails = await this.bulkDataAccess.getExportStatus(jobId);
await this.checkIfRequesterHasAccessToJob(jobDetails, requesterUserId);
if (['completed', 'failed'].includes(jobDetails.jobStatus)) {
throw new createError.BadRequest(
`Job cannot be canceled because job is already in ${jobDetails.jobStatus} state`,
);
}

await this.bulkDataAccess.cancelExport(jobId);
}

private async checkIfRequesterHasAccessToJob(jobDetails: GetExportStatusResponse, requesterUserId: string) {
const { jobOwnerId } = jobDetails;
const accessBulkDataJobRequest: AccessBulkDataJobRequest = { requesterUserId, jobOwnerId };
await this.authService.isAccessBulkDataJobAllowed(accessBulkDataJobRequest);
}
}
14 changes: 14 additions & 0 deletions src/router/handlers/resourceHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
ResourceNotFoundError,
ResourceVersionNotFoundError,
InvalidResourceError,
InitiateExportRequest,
GetExportStatusResponse,
} from 'fhir-works-on-aws-interface';
import ResourceHandler from './resourceHandler';
import invalidPatient from '../../../sampleData/invalidV4Patient.json';
Expand Down Expand Up @@ -200,6 +202,18 @@ describe('ERROR CASES: Testing create, read, update, delete of resources', () =>
throw new Error('Method not implemented.');
}

static initiateExport(request: InitiateExportRequest): Promise<string> {
throw new Error('Method not implemented.');
}

static cancelExport(jobId: string): Promise<void> {
throw new Error('Method not implemented');
}

static getExportStatus(jobId: string): Promise<GetExportStatusResponse> {
throw new Error('Method not implemented');
}

static conditionalDeleteResource(
request: ConditionalDeleteResourceRequest,
queryParams: any,
Expand Down
6 changes: 3 additions & 3 deletions src/router/metadata/cap.rest.security.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function makeSecurity(authConfig: Auth, hasCORSEnabled: boolean =
},
],
};
if (authConfig.strategy.oauthUrl) {
if (authConfig.strategy.oauth) {
security = {
...security,
...{
Expand All @@ -30,11 +30,11 @@ export default function makeSecurity(authConfig: Auth, hasCORSEnabled: boolean =
extension: [
{
url: 'token',
valueUri: `${authConfig.strategy.oauthUrl}/token`,
valueUri: authConfig.strategy.oauth.tokenUrl,
},
{
url: 'authorize',
valueUri: `${authConfig.strategy.oauthUrl}/authorize`,
valueUri: authConfig.strategy.oauth.authorizationUrl,
},
],
},
Expand Down
21 changes: 20 additions & 1 deletion src/router/metadata/cap.rest.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import { SystemOperation } from 'fhir-works-on-aws-interface';
import { makeOperation } from './cap.rest.resource.template';

export default function makeRest(resource: any[], security: any, globalOperations: SystemOperation[]) {
export default function makeRest(
resource: any[],
security: any,
globalOperations: SystemOperation[],
bulkDataAccessEnabled: boolean,
) {
const rest: any = {
mode: 'server',
documentation: 'Main FHIR endpoint',
Expand All @@ -24,5 +29,19 @@ export default function makeRest(resource: any[], security: any, globalOperation
},
];
}
if (bulkDataAccessEnabled) {
rest.operation = [
{
name: 'export',
definition:
'This FHIR Operation initiates the asynchronous generation of data to which the client is authorized. Currently only system level export is supported. For more information please refer here: http://hl7.org/fhir/uv/bulkdata/export/index.html#bulk-data-kick-off-request',
},
{
name: 'export-poll-status',
definition:
'After a bulk data request has been started, the client MAY poll the status URL provided in the Content-Location header. For more details please refer here: http://hl7.org/fhir/uv/bulkdata/export/index.html#bulk-data-status-request',
},
];
}
return rest;
}
29 changes: 29 additions & 0 deletions src/router/metadata/metadataHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { clone, stubs } from 'fhir-works-on-aws-interface';
import MetadataHandler from './metadataHandler';
import { makeOperation } from './cap.rest.resource.template';
import r4FhirConfigGeneric from '../../../sampleData/r4FhirConfigGeneric';
Expand Down Expand Up @@ -470,3 +471,31 @@ test('R4: FHIR Config V4 no generic set-up & mix of STU3 & R4', async () => {
message: 'Success',
});
});
test('R4: FHIR Config V4 with bulkDataAccess', async () => {
const r4ConfigWithBulkDataAccess = clone(r4FhirConfigGeneric);
r4ConfigWithBulkDataAccess.profile.bulkDataAccess = stubs.bulkDataAccess;
const configHandler: ConfigHandler = new ConfigHandler(r4ConfigWithBulkDataAccess, SUPPORTED_R4_RESOURCES);
const metadataHandler: MetadataHandler = new MetadataHandler(configHandler);
const response = await metadataHandler.capabilities({ fhirVersion: '4.0.1', mode: 'full' });

expect(response.resource.rest[0].operation).toEqual([
{
name: 'export',
definition:
'This FHIR Operation initiates the asynchronous generation of data to which the client is authorized. Currently only system level export is supported. For more information please refer here: http://hl7.org/fhir/uv/bulkdata/export/index.html#bulk-data-kick-off-request',
},
{
name: 'export-poll-status',
definition:
'After a bulk data request has been started, the client MAY poll the status URL provided in the Content-Location header. For more details please refer here: http://hl7.org/fhir/uv/bulkdata/export/index.html#bulk-data-status-request',
},
]);
});

test('R4: FHIR Config V4 without bulkDataAccess', async () => {
const configHandler: ConfigHandler = new ConfigHandler(r4FhirConfigGeneric, SUPPORTED_R4_RESOURCES);
const metadataHandler: MetadataHandler = new MetadataHandler(configHandler);
const response = await metadataHandler.capabilities({ fhirVersion: '4.0.1', mode: 'full' });

expect(response.resource.rest[0].operation).toBeUndefined();
});
2 changes: 1 addition & 1 deletion src/router/metadata/metadataHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default class MetadataHandler implements Capabilities {

const generatedResources = this.generateResources(request.fhirVersion);
const security = makeSecurity(auth, this.hasCORSEnabled);
const rest = makeRest(generatedResources, security, profile.systemOperations);
const rest = makeRest(generatedResources, security, profile.systemOperations, !!profile.bulkDataAccess);
const capStatement = makeStatement(rest, orgName, server.url, request.fhirVersion);

return {
Expand Down
Loading

0 comments on commit 4c5c310

Please sign in to comment.