├── .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 |
--------------------------------------------------------------------------------