├── .gitignore ├── Dockerfile ├── FhirAADUploader ├── .gitignore ├── FhirAADUploader.csproj ├── Program.cs └── appsettings.json ├── README.md ├── azuredeploy.json ├── azuredeploy.parameters.json └── generate_and_upload.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | *.hansen.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM microsoft/dotnet:2.1-sdk 2 | 3 | WORKDIR /app 4 | 5 | #Installing JAVA and Synthea 6 | RUN apt-get update && apt-get install -y software-properties-common python3-software-properties 7 | RUN echo debconf shared/accepted-oracle-license-v1-1 select true | debconf-set-selections 8 | RUN echo debconf shared/accepted-oracle-license-v1-1 seen true | debconf-set-selections 9 | RUN echo "deb http://ppa.launchpad.net/webupd8team/java/ubuntu xenial main" | tee /etc/apt/sources.list.d/webupd8team-java.list 10 | RUN echo "deb-src http://ppa.launchpad.net/webupd8team/java/ubuntu xenial main" | tee -a /etc/apt/sources.list.d/webupd8team-java.list 11 | RUN apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys EEA14886 12 | RUN apt-get update 13 | RUN apt-get install -yq openjdk-8-jdk 14 | 15 | RUN git clone https://github.com/synthetichealth/synthea.git && \ 16 | cd synthea && \ 17 | ./gradlew build check test 18 | 19 | # copy csproj and restore as distinct layers 20 | COPY FhirAADUploader/*.csproj . 21 | RUN dotnet restore 22 | 23 | # copy everything else and build app 24 | COPY FhirAADUploader ./ 25 | RUN dotnet publish -c release 26 | 27 | COPY generate_and_upload.sh ./ 28 | RUN chmod +x generate_and_upload.sh 29 | ENTRYPOINT ["bash", "-c", "./generate_and_upload.sh"] 30 | -------------------------------------------------------------------------------- /FhirAADUploader/.gitignore: -------------------------------------------------------------------------------- 1 | obj/ 2 | .vscode/ 3 | bin/ 4 | out/ -------------------------------------------------------------------------------- /FhirAADUploader/FhirAADUploader.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | netcoreapp2.1 6 | 6ab5b2dd-3698-4417-bebd-422639bbe5aa 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /FhirAADUploader/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net.Http; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.IdentityModel.Clients.ActiveDirectory; 8 | using Newtonsoft.Json; 9 | using Newtonsoft.Json.Linq; 10 | 11 | namespace FhirAADUploader 12 | { 13 | class Program 14 | { 15 | public static IConfiguration Configuration { get; set; } 16 | private static AuthenticationContext authContext = null; 17 | 18 | static void Main(string[] args) 19 | { 20 | Task.Run(() => MainAsync(args)).Wait(); 21 | } 22 | 23 | static async Task MainAsync(string[] args) 24 | { 25 | var builder = new ConfigurationBuilder() 26 | .SetBasePath(Directory.GetCurrentDirectory()) 27 | .AddJsonFile("appsettings.json") 28 | .AddEnvironmentVariables() 29 | .AddUserSecrets(); 30 | 31 | Configuration = builder.Build(); 32 | 33 | 34 | //Parse some command line arguments 35 | if (args.Length != 2) 36 | { 37 | PrintUsage(); 38 | return; 39 | } 40 | 41 | string fhirResourcePath = Path.GetFullPath(args[0]); 42 | string fhirServerUrl = args[1]; 43 | 44 | Console.WriteLine($"FHIR Resource Path : {fhirResourcePath}"); 45 | Console.WriteLine($"FHIR Server URL : {fhirServerUrl}"); 46 | Console.WriteLine($"Azure AD Authority : {Configuration["AzureAD_Authority"]}"); 47 | Console.WriteLine($"Azure AD Client ID : {Configuration["AzureAD_ClientId"]}"); 48 | Console.WriteLine($"Azure AD Audience : {Configuration["AzureAD_Audience"]}"); 49 | 50 | DirectoryInfo dir = new DirectoryInfo(fhirResourcePath); 51 | FileInfo[] files = null; 52 | try 53 | { 54 | files = dir.GetFiles("*.json"); 55 | } 56 | catch (UnauthorizedAccessException e) 57 | { 58 | Console.WriteLine(e.Message); 59 | } 60 | catch (DirectoryNotFoundException e) 61 | { 62 | Console.WriteLine(e.Message); 63 | return; 64 | } 65 | 66 | authContext = new AuthenticationContext(Configuration["AzureAD_Authority"]); 67 | ClientCredential clientCredential = new ClientCredential(Configuration["AzureAD_ClientId"], Configuration["AzureAD_ClientSecret"]); ; 68 | var client = new HttpClient(); 69 | client.BaseAddress = new Uri(fhirServerUrl); 70 | 71 | AuthenticationResult authResult = null; 72 | try 73 | { 74 | authResult = authContext.AcquireTokenAsync(Configuration["AzureAD_Audience"], clientCredential).Result; 75 | } 76 | catch (Exception ee) 77 | { 78 | Console.WriteLine( 79 | String.Format("An error occurred while acquiring a token\nTime: {0}\nError: {1}\n", 80 | DateTime.Now.ToString(), 81 | ee.ToString())); 82 | return; 83 | } 84 | 85 | foreach (FileInfo f in files) 86 | { 87 | Console.WriteLine("Processing file: " + f.FullName); 88 | using (StreamReader reader = File.OpenText(f.FullName)) 89 | { 90 | JObject o = (JObject)JToken.ReadFrom(new JsonTextReader(reader)); 91 | 92 | JArray entries = (JArray)o["entry"]; 93 | 94 | Console.WriteLine("Number of entries: " + entries.Count); 95 | 96 | for (int i = 0; i < entries.Count; i++) 97 | { 98 | string entry_json = (((JObject)entries[i])["resource"]).ToString(); 99 | string resource_type = (string)(((JObject)entries[i])["resource"]["resourceType"]); 100 | string id = (string)(((JObject)entries[i])["resource"]["id"]); 101 | 102 | //Rewrite subject reference 103 | if (((JObject)entries[i])["resource"]["subject"] != null) { 104 | string subject_reference = (string)(((JObject)entries[i])["resource"]["subject"]["reference"]); 105 | if (!String.IsNullOrEmpty(subject_reference)) 106 | { 107 | for (int j = 0; j < entries.Count; j++) 108 | { 109 | if ((string)(((JObject)entries[j])["fullUrl"]) == subject_reference) 110 | { 111 | subject_reference = (string)(((JObject)entries[j])["resource"]["resourceType"]) + "/" + (string)(((JObject)entries[j])["resource"]["id"]); 112 | break; 113 | } 114 | } 115 | } 116 | ((JObject)entries[i])["resource"]["subject"]["reference"] = subject_reference; 117 | entry_json = (((JObject)entries[i])["resource"]).ToString(); 118 | } 119 | 120 | if (((JObject)entries[i])["resource"]["context"] != null) 121 | { 122 | string context_reference = (string)(((JObject)entries[i])["resource"]["context"]["reference"]); 123 | if (!String.IsNullOrEmpty(context_reference)) 124 | { 125 | for (int j = 0; j < entries.Count; j++) 126 | { 127 | if ((string)(((JObject)entries[j])["fullUrl"]) == context_reference) 128 | { 129 | context_reference = (string)(((JObject)entries[j])["resource"]["resourceType"]) + "/" + (string)(((JObject)entries[j])["resource"]["id"]); 130 | break; 131 | } 132 | } 133 | } 134 | ((JObject)entries[i])["resource"]["context"]["reference"] = context_reference; 135 | entry_json = (((JObject)entries[i])["resource"]).ToString(); 136 | } 137 | 138 | 139 | 140 | //If we already have a token, we should get the cached one, otherwise, refresh 141 | authResult = authContext.AcquireTokenAsync(Configuration["AzureAD_Audience"], clientCredential).Result; 142 | client.DefaultRequestHeaders.Clear(); 143 | client.DefaultRequestHeaders.Add("Authorization", "Bearer " + authResult.AccessToken); 144 | StringContent content = new StringContent(entry_json, Encoding.UTF8, "application/json"); 145 | 146 | HttpResponseMessage uploadResult = null; 147 | 148 | 149 | if (String.IsNullOrEmpty(id)) 150 | { 151 | uploadResult = await client.PostAsync($"/{resource_type}", content); 152 | } 153 | else 154 | { 155 | uploadResult = await client.PutAsync($"/{resource_type}/{id}", content); 156 | } 157 | 158 | if (!uploadResult.IsSuccessStatusCode) 159 | { 160 | string resultContent = await uploadResult.Content.ReadAsStringAsync(); 161 | Console.WriteLine(resultContent); 162 | } 163 | } 164 | } 165 | } 166 | } 167 | 168 | private static void PrintUsage() 169 | { 170 | Console.WriteLine("Usage: "); 171 | Console.WriteLine(" dotnet run "); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /FhirAADUploader/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AzureAD_Authority": "https://login.microsoftonline.com/TENANT-ID/", 3 | "AzureAD_ClientId": "ClientAppId", 4 | "AzureAD_ClientSecret": "SECRET", 5 | "AzureAD_Audience": "FHIR Server App ID" 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Automated Synthea Generator and FHIR Uploader 2 | --------------------------------------------- 3 | 4 | This repository builds a Docker image with a Synthea patient generator and a FHIR uploader that can be used with FHIR servers using Azure Active Directory as the OAuth2 provider. Specifically, when started, the Docker container will: 5 | 6 | 1. Generate the desired number of patients. 7 | 2. Authenticate with Azure Active Directory to obtain a token. 8 | 3. Upload patients to FHIR server. 9 | 10 | The Azure Active Directory authentication and FHIR server upload is handled by the [`FhirAADUploader`](FhirAADUploader) app, which is a .NET Core command line application. 11 | 12 | To generate 100 patients and upload them to a FHIR server with URL `https://my-fhir-server.com`: 13 | 14 | ``` 15 | docker run --name synthea --rm -t \ 16 | -e AzureAD_ClientSecret='AAD-CLIENT-SECRET' \ 17 | -e AzureAD_ClientId='AAD-CLIENT-ID' \ 18 | -e AzureAD_Authority='https://login.microsoftonline.com/TENANT-ID/' \ 19 | -e AzureAD_Audience='AAD-FHIR-API-APP-ID' -e NUMBER_OF_PATIENTS='100' \ 20 | -e FHIR_SERVER_URL='https://my-fhir-server/com/' hansenms/synthegenerator 21 | ``` 22 | 23 | To build your own version of the Docker image: 24 | 25 | ``` 26 | docker build -t yourrepo/yourtagname . 27 | ``` 28 | 29 | You can use an [Azure Container Instance](https://azure.microsoft.com/en-us/services/container-instances/) to generate and upload the patients. To run this image using the Azure CLI: 30 | 31 | ```bash 32 | az container create --resource-group RgName \ 33 | --image hansenms/syntheagenerator --name ContainerInstanceName \ 34 | --cpu 2 --memory 4 --restart-policy Never \ 35 | -e AzureAD_ClientSecret='CLIENT-SECRET' \ 36 | AzureAD_ClientId='CLIENT-ID' \ 37 | AzureAD_Authority='https://login.microsoftonline.com/TENANT-ID/' \ 38 | AzureAD_Audience='FHIR-SERVER-API-APP' \ 39 | FHIR_SERVER_URL='https://my-fhir-server.com/' \ 40 | NUMBER_OF_PATIENTS='100' 41 | ``` 42 | 43 | There is also a template included in this repository, which you can deploy with PowerShell or Azure CLI. Edit the `azuredeploy.parameters.json` to match your setup. 44 | 45 | Deploy with PowerShell like this: 46 | 47 | ```PowerShell 48 | $rg = New-AzureRmResourceGroup -Name RgName -Location eastus 49 | 50 | New-AzureRmResourceGroupDeployment -Name synthdeploy ` 51 | -ResourceGroupName $rg.ResourceGroupName ` 52 | -TemplateFile .\azuredeploy.json ` 53 | -TemplateParameterFile .\azuredeploy.parameters.json 54 | ``` 55 | 56 | Or you can deploy with the Azure CLI: 57 | 58 | ```bash 59 | #Create a resource group: 60 | RGNAME=MyResourceGroup 61 | az group create --name ${RGNAME} --location eastus 62 | 63 | #Deploy using the template 64 | az group deployment create --resource-group ${RGNAME} \ 65 | --template-file ./azuredeploy.json \ 66 | --parameters @azuredeploy.parameters.json 67 | ``` 68 | 69 | You can also deploy through the portal. Simply hit the button below and fill in the details: 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /azuredeploy.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "Authority": { 6 | "type": "string", 7 | "metadata": { 8 | "description": "Azure Active Directory Authority" 9 | }, 10 | "defaultValue": "https://login.microsoftonline.com/microsoft.onmicrosoft.com/" 11 | }, 12 | "ClientId": { 13 | "type": "string", 14 | "metadata": { 15 | "description": "Azure Active Directory Client Id" 16 | } 17 | }, 18 | "ClientSecret": { 19 | "type": "string", 20 | "metadata": { 21 | "description": "Azure Active Directory Client Secret" 22 | } 23 | }, 24 | "Audience": { 25 | "type": "string", 26 | "metadata": { 27 | "description": "Azure Active Directory FHIR API App (ID or URI)" 28 | } 29 | }, 30 | "NumberOfPatients": { 31 | "type": "int", 32 | "metadata": { 33 | "description": "Number of Patients to Generate" 34 | }, 35 | "defaultValue": 100 36 | }, 37 | "AdditionalSyntheaArgs": { 38 | "type": "string", 39 | "defaultValue": "", 40 | "metadata": { 41 | "description": "Additional Syntea args, e.g., '-a 0-18'" 42 | } 43 | }, 44 | "FhirServerUrl": { 45 | "type": "string", 46 | "metadata": { 47 | "description": "FHIR Server URL" 48 | } 49 | }, 50 | "DockerImage": { 51 | "type": "string", 52 | "metadata": { 53 | "description": "Name of Docker Image" 54 | }, 55 | "defaultValue": "hansenms/syntheagenerator:latest" 56 | } 57 | }, 58 | "resources": [ 59 | { 60 | "name": "SyntheaGenerator", 61 | "type": "Microsoft.ContainerInstance/containerGroups", 62 | "apiVersion": "2018-02-01-preview", 63 | "location": "[resourceGroup().location]", 64 | "properties": { 65 | "containers": [ 66 | { 67 | "name": "syntheagenerator", 68 | "properties": { 69 | "image": "[parameters('DockerImage')]", 70 | "command": [ 71 | "bash", "-c", "./generate_and_upload.sh" 72 | ], 73 | "environmentVariables": [ 74 | { 75 | "name": "AzureAD_ClientId", 76 | "value": "[parameters('ClientId')]" 77 | }, 78 | { 79 | "name": "AzureAD_ClientSecret", 80 | "value": "[parameters('ClientSecret')]" 81 | }, 82 | { 83 | "name": "AzureAD_Audience", 84 | "value": "[parameters('Audience')]" 85 | }, 86 | { 87 | "name": "AzureAD_Authority", 88 | "value": "[parameters('Authority')]" 89 | }, 90 | { 91 | "name": "NUMBER_OF_PATIENTS", 92 | "value": "[parameters('NumberOfPatients')]" 93 | }, 94 | { 95 | "name": "ADDITIONAL_SYNTHEA_ARGS", 96 | "value": "[parameters('AdditionalSyntheaArgs')]" 97 | }, 98 | { 99 | "name": "FHIR_SERVER_URL", 100 | "value": "[parameters('FhirServerUrl')]" 101 | } 102 | ], 103 | "resources": { 104 | "requests": { 105 | "cpu": 2, 106 | "memoryInGB": 4 107 | } 108 | } 109 | } 110 | } 111 | ], 112 | "restartPolicy": "OnFailure", 113 | "osType": "Linux" 114 | } 115 | } 116 | ] 117 | } -------------------------------------------------------------------------------- /azuredeploy.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "Authority": { 3 | "value": "https://login.microsoftonline.com/microsoft.onmicrosoft.com/" 4 | }, 5 | "ClientId": { 6 | "value": "CLIENT-ID" 7 | }, 8 | "ClientSecret": { 9 | "value": "CLIENT-SECRET" 10 | }, 11 | "Audience": { 12 | "value": "https://my-fhir-server.com" 13 | }, 14 | "NumberOfPatients": { 15 | "value": 100 16 | }, 17 | "FhirServerUrl": { 18 | "value": "https://my-fhir-server.com/" 19 | } 20 | } -------------------------------------------------------------------------------- /generate_and_upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /app/synthea 4 | ./run_synthea -p $NUMBER_OF_PATIENTS $ADDITIONAL_SYNTHEA_ARGS 5 | 6 | cd /app 7 | dotnet run /app/synthea/output/fhir $FHIR_SERVER_URL 8 | 9 | --------------------------------------------------------------------------------