Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merging smart-ehr-launcher changes into main #38

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed .source_version
Empty file.
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:18
FROM node:18-alpine

WORKDIR /app

Expand All @@ -17,4 +17,4 @@ RUN npm run build

EXPOSE 80

CMD ["/app/node_modules/.bin/ts-node", "--skipProject", "--transpile-only", "./backend/index.ts"]
CMD ["/app/node_modules/.bin/ts-node", "--skipProject", "--transpile-only", "./backend/index.ts"]
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,63 @@
# Smart App Launch Proxy (fork of SMART Launcher)

## Fork Overview
This fork of the SMART Launcher has been slightly modified to be used as a proxy that enables SMART App Launch on top of a vanilla FHIR server.

It was used to support the launching of [Smart Forms](https://github.com/aehrc/smart-forms), a FHIR questionnaire rendering SMART app from a [EHR simulator](https://github.com/aehrc/smart-ehr-launcher) for demo and testing purposes.
Leveraging the existing internals of the SMART Launcher provides a way to indirectly enable the SMART App Launch functionality on top of any FHIR server, notably [HAPI](https://github.com/hapifhir/hapi-fhir-jpaserver-starter) in Smart Form's use case.

This proxy component is only tested on open servers so far, and it is not guaranteed to work with servers that require authorisation.

A live demo app is available at: https://ehr.smartforms.io

### Fork changes
- Removed the frontend portion of the launcher, moving it to the [Smart EHR Launcher project](https://github.com/aehrc/smart-ehr-launcher)
- Enabled support for the [fhirContext](https://build.fhir.org/ig/HL7/smart-app-launch/scopes-and-launch-context.html#fhircontext-exp) launch context, mainly to facilitate a questionnaire launch context for Smart Forms.
- Bug fixes for POSTing JSON payloads to the source FHIR server

If you don't need the above changes, you can use the original SMART Launcher project as is here: https://github.com/smart-on-fhir/smart-launcher-v2.

### Environment Configuration + Docker deployment
The fork made zero changes to the environment configuration, but it would be worth highlighting that at least one of `FHIR_SERVER_R2`, `FHIR_SERVER_R3` or `FHIR_SERVER_R4` must be set in the `.env` file.
Otherwise, all servers will default to SMART Health IT's servers.

#### Example docker usages:

Proxy sitting on top of https://proxy.smartforms.io/fhir, a HAPI FHIR R4 server:
```sh
docker run -p 8080:80 -e FHIR_SERVER_R4=https://proxy.smartforms.io/fhir aehrc/smart-launcher-v2:latest
```

Proxy without any configuration, defaulting to SMART Health IT's servers:
```sh
docker run -p 8080:80 aehrc/smart-launcher-v2:latest
```

You would be able to use the docker image from the original SMART Launcher project without any issues (and retain the original frontend as a bonus):
```sh
docker run -p 8080:80 -e FHIR_SERVER_R4=https://proxy.smartforms.io/fhir smartonfhir/smart-launcher-2:latest
```

```sh
docker run -p 8080:80 smartonfhir/smart-launcher-2:latest
```

### The frontend bit

The SMART Launcher project comes with a frontend to configure and launch SMART apps.

This fork removes the frontend and moves it to the Smart EHR Launcher project (https://github.com/aehrc/smart-ehr-launcher), which acts as a minimal EHR to display a Patient summary and it's associated resources while retaining its app-launching capabilities.

The SMART EHR Launcher is a single-page application (SPA) built with React and [Vite](https://vitejs.dev/).
If you are planning to use the SMART EHR Launcher as the frontend, you will need to make an additional SPA deployment and configure it to point to the proxy server. See [here](https://github.com/aehrc/SMART-EHR-Launcher/blob/main/README.md) for more details.

<br/>

See below for the original README content from the SMART Launcher project.

---
# SMART Launcher

This server acts as a proxy that intercepts requests to otherwise open FHIR
servers and requires those requests to be properly authorized. It also provides
a SMART implementation that is loose enough to allow for apps to launch against
Expand Down Expand Up @@ -67,4 +126,4 @@ and open http://localhost:8080
<!--
docker build -t smartonfhir/smart-launcher-2:latest .
docker push smartonfhir/smart-launcher-2:latest
-->
-->
51 changes: 47 additions & 4 deletions backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ import launcher from "./routes/launcher"
import pkg from "../package.json"
import { globalErrorHandler, ipBlackList } from "./middlewares"

export let customisedFhirServerR4 = ""


const app = express()

app.use(express.json());

// CORS everywhere :)
app.use(cors({ origin: true, credentials: true }))

// Block some IPs
app.use(ipBlackList(process.env.IP_BLACK_LIST || ""));

app.use(express.static(Path.join(__dirname, '../build/')));

app.get("/smart-style.json", (_, res) => {
res.json({
color_background : "#edeae3",
Expand Down Expand Up @@ -64,6 +66,42 @@ app.get("/public_key", (_, res) => {
});
});


/*
* The following endpoints are used to switch between different source FHIR servers without redeploying.
* It is now switched off to reduce complexity, and was only implemented as a temporary solution so it might have bugs.
* If anyone comes across this code and thinks it is useful, please feel free to create an issue on the GitHub repository, I'll look into re-enabling it.
*/

// app.post("/endpoint_switch", (req, res) => {
// if (req.body && req.body.url) {
// // Extract the "url" property from the request body
// const { url } = req.body;
//
// if (!isValidURL(url)) {
// res.status(400).json({ error: 'Invalid request, "url" is not a valid URL.' });
// return
// }
//
// customisedFhirServerR4 = url;
// res.status(200).json({ message: 'R4 endpoint switched successfully' });
// return
// }
//
// // If the request does not contain a "url" property, return an error response
// res.status(400).json({ error: 'Invalid request, missing "url" property' });
//
// });
//
// app.get("/endpoint_reset", (_, res) => {
// customisedFhirServerR4 = "";
// res.status(200).json({ message: 'R4 endpoint reset successfully' })
// })
//
// app.get("/endpoint_switch", (_, res) => {
// res.status(200).json({ url: customisedFhirServerR4 });
// })

// Provide some env variables to the frontend
app.use("/env.js", (_, res) => {
const out = {
Expand All @@ -81,8 +119,13 @@ app.use("/env.js", (_, res) => {
res.type("application/javascript").send(`var ENV = ${JSON.stringify(out, null, 4)};`);
});

// React app - redirect all to ./build/index.html
app.get("*", (_, res) => res.sendFile("index.html", { root: "./build" }));
// Handle all routes
app.get("*", (req, res) => {
const fhirR4ServerUrl = req.protocol + '://' + req.get('host') + '/v/r4/fhir';

res.send(
`This service is healthy, but this route does not exist (Something might have went wrong!).\nTo access the R4 server, visit ${fhirR4ServerUrl}.\nThis server is currently proxying the following FHIR servers:\nR2: ${config.fhirServerR2}\nR3: ${config.fhirServerR3}\nR4: ${config.fhirServerR4}`
)});

// Catch all errors
app.use(globalErrorHandler)
Expand Down
15 changes: 14 additions & 1 deletion backend/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import jwt from "jsonwebtoken"
import { NextFunction, Request, Response, RequestHandler } from "express"
import config from "./config";
import { HttpError, InvalidRequestError } from "./errors";

import {customisedFhirServerR4} from "./index";

/**
* Given a request object, returns its base URL
Expand Down Expand Up @@ -46,6 +46,11 @@ export function notSupported(message: string = "", code = 400) {

export function getFhirServerBaseUrl(req: Request) {
const fhirVersion = req.params.fhir_release.toUpperCase();

if (fhirVersion === 'R4' && customisedFhirServerR4 !== '') {
return customisedFhirServerR4
}

let fhirServer = config[`fhirServer${fhirVersion}` as keyof typeof config] as string;

// Env variables like FHIR_SERVER_R2_INTERNAL can be set to point the
Expand Down Expand Up @@ -106,3 +111,11 @@ export function humanizeArray(arr: string[], quote = false) {
const last = arr.pop();
return arr.join(", ") + " and " + last;
}

export function isValidURL(url: string) {
// Regular expression pattern to match URLs
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;

// Test the string against the pattern
return urlPattern.test(url);
}
32 changes: 28 additions & 4 deletions backend/routes/auth/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
InvalidScopeError,
OAuthError
} from "../../errors"
import FhirContext = SMART.FhirContext;


export interface AuthorizeParams {
Expand Down Expand Up @@ -382,6 +383,30 @@ export default class AuthorizeHandler {
}
}

// fhirContext
if (launchOptions.fhir_context.size() > 0) {
const fhirContexts: FhirContext[] = [];
for (const fhirContext of launchOptions.fhir_context.toJSON()) {
try {
fhirContexts.push(JSON.parse(fhirContext));
} catch {}
}

// If "launch" scope is present, add all fhirContexts
if (scope.has("launch")) {
code.context.fhirContext = fhirContexts;
} else {
// TODO support all resources - currently only supporting questionnaire
// Otherwise, add fhirContexts based on launch/+ scopes provided
if (scope.has("launch/questionnaire")) {
code.context.fhirContext = fhirContexts.filter((fhirContext) =>
fhirContext.reference?.startsWith("Questionnaire/")
);
}
}
}


// user
if (scope.has("openid") && (scope.has("profile") || scope.has("fhirUser"))) {

Expand Down Expand Up @@ -453,7 +478,6 @@ export default class AuthorizeHandler {
}

if (launchOptions.pkce !== "none") {

// code_challenge_method must be 'S256' if set
if ((params.code_challenge_method || launchOptions.pkce === "always") && params.code_challenge_method !== 'S256') {
throw new InvalidRequestError("Invalid code_challenge_method. Must be S256.")
Expand Down Expand Up @@ -569,9 +593,9 @@ export default class AuthorizeHandler {
}

// ENCOUNTER
if (this.needToPickEncounter()) {
return this.renderEncounterPicker()
}
// if (this.needToPickEncounter()) {
// return this.renderEncounterPicker()
// }

// AUTH SCREEN
if (this.needToAuthorize()) {
Expand Down
1 change: 1 addition & 0 deletions backend/routes/fhir/.well-known/smart-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export default function getWellKnownSmartConfig(req: Request, res: Response) {
"launch",
"launch/patient",
"launch/encounter",
"launch/questionnaire",
"patient/*.*",
"user/*.*",
"offline_access"
Expand Down
4 changes: 3 additions & 1 deletion backend/routes/fhir/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Router, text } from "express"
import { json, Router, text } from "express"
import getWellKnownSmartConfig from "./.well-known/smart-configuration"
import getWellKnownOpenidConfig from "./.well-known/openid-configuration"
import getCapabilityStatement from "./metadata"
Expand All @@ -8,6 +8,8 @@ import { asyncRouteWrap } from "../../lib"

const router = Router({ mergeParams: true })

router.use(json({ limit: '50mb' }));

router.get("/.well-known/smart-configuration" , getWellKnownSmartConfig)
router.get("/.well-known/openid-configuration", getWellKnownOpenidConfig)
router.get("/metadata", asyncRouteWrap(getCapabilityStatement))
Expand Down
7 changes: 6 additions & 1 deletion backend/routes/fhir/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default async function proxy(req: Request, res: Response) {

// Add the body in case of POST or PUT ---------------------------------
if (req.method === "POST" || req.method === "PUT" || req.method === "PATCH") {
fhirRequestOptions.body = req.body + "";
fhirRequestOptions.body = req.body;
}

// Build request headers -----------------------------------------------
Expand All @@ -48,6 +48,11 @@ export default async function proxy(req: Request, res: Response) {
);
}

// Stringify JSON body ---------------------------------
if (fhirRequestOptions.headers.get("content-type") === "application/json" && fhirRequestOptions.body) {
fhirRequestOptions.body = JSON.stringify(fhirRequestOptions.body)
}

// Proxy ---------------------------------------------------------------
const response = await fetch(new URL(fhirServer + req.url).href, fhirRequestOptions);

Expand Down
8 changes: 8 additions & 0 deletions deployment/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
*.js
!jest.config.js
*.d.ts
node_modules

# CDK asset staging directory
.cdk.staging
cdk.out
6 changes: 6 additions & 0 deletions deployment/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.ts
!*.d.ts

# CDK asset staging directory
.cdk.staging
cdk.out
14 changes: 14 additions & 0 deletions deployment/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Welcome to your CDK TypeScript project

This is a blank project for CDK development with TypeScript.

The `cdk.json` file tells the CDK Toolkit how to execute your app.

## Useful commands

* `npm run build` compile typescript to js
* `npm run watch` watch for changes and compile
* `npm run test` perform the jest unit tests
* `cdk deploy` deploy this stack to your default AWS account/region
* `cdk diff` compare deployed stack with current state
* `cdk synth` emits the synthesized CloudFormation template
21 changes: 21 additions & 0 deletions deployment/bin/deployment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { DeploymentStack } from '../lib/deployment-stack';

const app = new cdk.App();
new DeploymentStack(app, 'DeploymentStack', {
/* If you don't specify 'env', this stack will be environment-agnostic.
* Account/Region-dependent features and context lookups will not work,
* but a single synthesized template can be deployed anywhere. */

/* Uncomment the next line to specialize this stack for the AWS Account
* and Region that are implied by the current CLI configuration. */
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },

/* Uncomment the next line if you know exactly what Account and Region you
* want to deploy the stack to. */
// env: { account: '123456789012', region: 'us-east-1' },

/* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});
Loading
Loading