Initial Release
Training on FHIR and Python based on IRIS for Health.
This repository contains the material for the training.
The objective of the training is to provide the participants with the following skills:
Schema of the training:
Workflow of the training:
This training aims to provide the participants with the following skills:
To install the training environment, you need to have Docker and Docker Compose installed on your machine.
You can install Docker and Docker Compose by following the instructions on the Docker website.
Once you have Docker and Docker Compose installed, you can clone this repository and run the following command:
docker-compose up -d
This command will start the IRIS for Health container and the Web Gateway container to expose the FHIR server over HTTPS.
Once the containers are started, you can access the FHIR server at the following URL:
https://localhost:4443/fhir/r5/
You can access the InterSystems IRIS Management Portal at the following URL:
http://localhost:8089/csp/sys/UtilHome.csp
The default username and password are SuperUser
and SYS
.
To configure the OAuth2 Authorization Server, you need to connect to the InterSystems IRIS Management Portal and navigate to the System Administration > Security > OAuth 2.0 > Servers.
Next, we will fulfill the form to create a new OAuth2 Authorization Server.
First we start with the General tab.
The parameters are as follows:
Next we move to the Scope tab.
We will create 3 scopes:
Next we move to the JWT tab.
Here we simply select the algorithm to use for the JWT.
We will use the RS256 algorithm.
If needed, we can select encryption for the JWT. We will not use encryption for this training.
Next we move to the Customization tab.
Here is all the customization classes for the OAuth2 Authorization Server.
We change the following classes:
We can now save the OAuth2 Authorization Server.
Great, we have now configured the OAuth2 Authorization Server. 🥳
To configure the client, you need to connect to the InterSystems IRIS Management Portal and navigate to the System Administration > Security > OAuth 2.0 > Client.
To create a new client, we need first to register the OAuth2 Authorization Server.
On the client page, click on the Create Server Description
button.
In the Server Description form, we need to fulfill the following parameters:
Click on the Discover and Save
button.
Neat, we have now registered the OAuth2 Authorization Server.
Next, we can create a new client.
On the client page, we have a new button Client Configuration
.
Click on the Client Configuration
button link to the Server Description.
We can now Create a new client.
First we start with the General tab.
The parameters are as follows:
Now we can click the Dynamic Registration
button.
Congratulations, we have now created the client. 🥳
If we go to the Client Credentials
tab, we can see the client credentials.
Notice that the client credentials are the Client ID
and the Client Secret
.
⚠️ WARNING ⚠️ : Make sure to be on the FHIRSERVER
namespace.
To configure the FHIR server, you need to connect to the InterSystems IRIS Management Portal and navigate to the Health > FHIR Configuration > Servers.
Next, we will create a new FHIR server.
Click on the Server Configuration
button.
In the Server Configuration form, we need to fulfill the following parameters:
FHIR.Python.InteractionsStrategy
interactions strategy.Click on the Add
button.
This can take a few minutes. 🕒 Let’s go grabe a coffee. ☕️
Great, we have now created the FHIR server. 🥳
Select the FHIR server and scroll down to the Edit
button.
In the FHIR Server form, we need to fulfill the following parameters:
Click on the Save
button.
Great, we have now bind the FHIR server to the OAuth2 Authorization Server. 🥳
To test the FHIR server, you can use the following command:
GET https://localhost:4443/fhir/r5/Patient
Without the Authorization
header, you will get a 401 Unauthorized
response.
To authenticate the request, you need to add the Authorization
header with the Bearer
token.
For that let’s claim a token from the OAuth2 Authorization Server.
POST https://localhost:4443/oauth2/token Content-Type: application/x-www-form-urlencoded Authorization: Basic :
grant_type=client_credentials&scope=user/Patient.read&aud=https://localhost:4443/fhir/r5
You will get a 200 OK
response with the access_token
and the token_type
.
Now you can use the access_token
to authenticate the request to the FHIR server.
GET https://localhost:4443/fhir/r5/Patient
Authorization: Bearer
Accept: application/fhir+json
Great, you have now authenticated the request to the FHIR server. 🥳
Ok, we now start a big topic.
The whole point of this topic will be to put in between the FHIR server and the client application the interoperability capabilities of IRIS for Health.
Here is a macro view of the architecture:
And here is the workflow:
What we notice here is that the EAI
(Interoperability capabilities of IRIS for Health) will act as a path through for incoming requests to the FHIR server.
Will filter the response from the FHIR server based on scopes and send the filtered response to the client application.
Before going further, let me make a quick introduction to the Interoperability capabilities of IRIS for Health.
This is the IRIS Framework.
The whole point of this framework is to provide a way to connect different systems together.
We have 4 main components:
For this training, we will use the following components:
Business Service
to receive the incoming request from the client application.Business Process
to filter the response from the FHIR server based on scopes.Business Operation
to send messages to the FHIR server.For this training, we will be using a pre-built interoperability production.
And we will only focus on the Business Process
to filter the response from the FHIR server based on scopes.
For this part, we will use the IoP
tool. IoP
stands for Interoperability on Python.
You can install the IoP
tool by following the instructions on the IoP repository
IoP
is pre-installed in the training environment.
Connect to the running container:
docker exec -it formation-fhir-python-iris-1 bash
And run the following command:
iop --init
This will install iop
on the IRIS for Health container.
Still in the container, run the following command:
iop --migrate /irisdev/app/src/python/EAI/settings.py
This will create the interoperability production.
Now you can access the interoperability production at the following URL:
http://localhost:8089/csp/healthshare/eai/EnsPortal.ProductionConfig.zen?$NAMESPACE=EAI&$NAMESPACE=EAI&
You can now start the production.
Great, you have now created the interoperability production. 🥳
Get a token from the OAuth2 Authorization Server.
POST https://localhost:4443/oauth2/token Content-Type: application/x-www-form-urlencoded Authorization : Basic :
grant_type=client_credentials&scope=user/Patient.read&aud=https://webgateway/fhir/r5
⚠️ WARNING ⚠️ : we change the aud
parameter to the URL of the Web Gateway to expose the FHIR server over HTTPS.
Get a patient through the interoperability production.
GET https://localhost:4443/fhir/Patient
Authorization : Bearer
Accept: application/fhir+json
You can see the trace of the request in the interoperability production.
http://localhost:8089/csp/healthshare/eai/EnsPortal.MessageViewer.zen?SOURCEORTARGET=Python.EAI.bp.MyBusinessProcess
All the code for the Business Process
is in this file : https://github.com/grongierisc/formation-fhir-python/blob/main/src/python/EAI/bp.py
For this training, we will be as a TTD
(Test Driven Development) approach.
All the tests for the Business Process
are in this file : https://github.com/grongierisc/formation-fhir-python/blob/main/src/python/tests/EAI/test_bp.py
To prepare your development environment, we need to create a virtual environment.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
To run the tests, you can use the following command:
pytest
Tests are failing.
We have 4 functions to implement:
check_token
on_fhir_request
filter_patient_resource
filter_resources
You can implement the code in the https://github.com/grongierisc/formation-fhir-python/blob/main/src/python/EAI/bp.py
file.
This function will check if the token is valid and if the scope contains the VIP
scope.
If the token is valid and the scope contains the VIP
scope, the function will return True
, otherwise it will return False
.
We will use the jwt
library to decode the token.
def check_token(self, token:str) -> bool:
# decode the token try: decoded_token= jwt.decode(token, options={"verify_signature": False}) except jwt.exceptions.DecodeError: return False # check if the token is valid if 'VIP' in decoded_token['scope']: return True else: return False
This function will filter the patient resource.
It will remove the name
, address
, telecom
and birthdate
fields from the patient resource.
The function will return the filtered patient resource as a string.
We will use the fhir.resources
library to parse the patient resource.
Notice the signature of the function.
The function takes a string as input and returns a string as output.
So we need to parse the input string to a fhir.resources.patient.Patient
object and then parse the fhir.resources.patient.Patient
object to a string.
def filter_patient_resource(self, patient_str:str) -> str: # filter the patient p = patient.Patient(**json.loads(patient_str)) # remove the name p.name = [] # remove the address p.address = [] # remove the telecom p.telecom = [] # remove the birthdate p.birthDate = None
return p.json()
This function will filter the resources.
We need to check the resource type and filter the resource based on the resource type.
If the resource type is Bundle
, we need to filter all the entries of the bundle that are of type Patient
.
If the resource type is Patient
, we need to filter the patient resource.
The function will return the filtered resource as a string.
We will use the fhir.resources
library to parse the resource.
def filter_resources(self, resource_str:str) -> str: # parse the payload payload_dict = json.loads(resource_str)
# what is the resource type? resource_type = payload_dict['resourceType'] if 'resourceType' in payload_dict else 'None' self.log_info('Resource type: ' + resource_type) # is it a bundle? if resource_type == 'Bundle': obj = bundle.Bundle(**payload_dict) # filter the bundle for entry in obj.entry: if entry.resource.resource_type == 'Patient': self.log_info('Filtering a patient') entry.resource = patient.Patient(**json.loads(self.filter_patient_resource(entry.resource.json()))) elif resource_type == 'Patient': # filter the patient obj = patient.Patient(**json.loads(self.filter_patient_resource(resource_str))) return obj.json()
This function will be the entry point of the Business Process
.
It will receive the request from the Business Service
, check the token, filter the response from the FHIR server based on scopes and send the filtered response to the Business Service
.
The function will return the response from the FHIR server.
We will use the iris
library to send the request to the FHIR server.
The message will be a iris.HS.FHIRServer.Interop.Request
object.
This object contains the request to the FHIR server.
This includes the Method
, the URL
, the Headers
and the Payload
.
To check the token, we will use the check_token
function and use the header USER:OAuthToken
to get the token.
To filter the response, we will use the filter_resources
function and use the QuickStream
to read the response from the FHIR server.
def on_fhir_request(self, request:'iris.HS.FHIRServer.Interop.Request'): # Do something with the request self.log_info('Received a FHIR request')
# pass it to the target rsp = self.send_request_sync(self.target, request) # Try to get the token from the request token = request.Request.AdditionalInfo.GetAt("USER:OAuthToken") or "" # Do something with the response if self.check_token(token): self.log_info('Filtering the response') # Filter the response payload_str = self.quick_stream_to_string(rsp.QuickStreamId) # if the payload is empty, return the response if payload_str == '': return rsp filtered_payload_string = self.filter_resources(payload_str) # write the json string to a quick stream quick_stream = self.string_to_quick_stream(filtered_payload_string) # return the response rsp.QuickStreamId = quick_stream._Id() return rsp
To run the tests, you can use the following command:
pytest
Tests are passing. 🥳
You can now test the Business Process
with the interoperability production.
Last part of the training. 🏁
We will create a custom operation on the FHIR server.
The custom operation will be a Patient
merge operation, the result will be the diff of the 2 patients.
example:
POST https://localhost:4443/fhir/r5/Patient/1/$merge Authorization : Bearer Accept: application/fhir+json Content-Type: application/fhir+json
{
"resourceType": "Patient",
"id": "2",
"meta": {
"versionId": "2"
}
}
The response will be the diff of the 2 patients.
{
"values_changed": {
"root['address'][0]['city']": {
"new_value": "fdsfd",
"old_value": "Lynnfield"
},
"root['meta']['lastUpdated']": {
"new_value": "2024-02-24T09:11:00Z",
"old_value": "2024-02-28T13:50:27Z"
},
"root['meta']['versionId']": {
"new_value": "1",
"old_value": "2"
}
}
}
Before going further, let me make a quick introduction to the custom operation on the FHIR server.
There is 3 types of custom operation:
For this training, we will use the Instance Operation
to create the custom operation.
A custom operation must inherit from the OperationHandler
class from the FhirInteraction
module.
Here is the signature of the OperationHandler
class:
class OperationHandler(object):
@abc.abstractmethod def add_supported_operations(self,map:dict) -> dict: """ @API Enumerate the name and url of each Operation supported by this class @Output map : A map of operation names to their corresponding URL. Example: return map.put("restart","http://hl7.org/fhir/OperationDefinition/System-restart") """ @abc.abstractmethod def process_operation( self, operation_name:str, operation_scope:str, body:dict, fhir_service:'iris.HS.FHIRServer.API.Service', fhir_request:'iris.HS.FHIRServer.API.Data.Request', fhir_response:'iris.HS.FHIRServer.API.Data.Response' ) -> 'iris.HS.FHIRServer.API.Data.Response': """ @API Process an Operation request. @Input operation_name : The name of the Operation to process. @Input operation_scope : The scope of the Operation to process. @Input fhir_service : The FHIR Service object. @Input fhir_request : The FHIR Request object. @Input fhir_response : The FHIR Response object. @Output : The FHIR Response object. """
As we did in the previous part, we will use a TTD
(Test Driven Development) approach.
All the tests for the custom operation are in this file : https://github.com/grongierisc/formation-fhir-python/blob/main/src/python/tests/FhirInteraction/test_custom.py
This function will add the Patient
merge operation to the supported operations.
The function will return a dictionary with the name of the operation and the URL of the operation.
Be aware that the input dict can be empty.
The expected output is:
{
"resource":
{
"Patient":
[
{
"name": "merge",
"definition": "http://hl7.org/fhir/OperationDefinition/Patient-merge"
}
]
}
}
This json document will be added to the CapabilityStatement
of the FHIR server.
def add_supported_operations(self,map:dict) -> dict: """ @API Enumerate the name and url of each Operation supported by this class @Output map : A map of operation names to their corresponding URL. Example: return map.put("restart","http://hl7.org/fhir/OperationDefinition/System-restart") """
# verify the map has attribute resource if not 'resource' in map: map['resource'] = {} # verify the map has attribute patient in the resource if not 'Patient' in map['resource']: map['resource']['Patient'] = [] # add the operation to the map map['resource']['Patient'].append({"name": "merge" , "definition": "http://hl7.org/fhir/OperationDefinition/Patient-merge"}) return map
This function will process the Patient
merge operation.
The function will return the diff of the 2 patients.
We will make use of deepdiff
library to get the diff of the 2 patients.
The input parameters are:
operation_name
: The name of the operation to process.operation_scope
: The scope of the operation to process.body
: The body of the operation.fhir_service
: The FHIR Service object.
resource_type
: The type of the resource to read.resource_id
: The id of the resource to read.%DynamicObject
object.fhir_request
: The FHIR Request object.
%DynamicObject
object.fhir_response
: The FHIR Response object.
%DynamicObject
object.%DynamicObject
is a class to manipulate JSON objects.
It’s the same as a Python dictionary but for ObjectScript.
Load a JSON object:
json_str = fhir_request.Json._ToJSON()
json_obj = json.loads(json_str)
Set a JSON object:
json_str = json.dumps(json_obj)
fhir_response.Json._FromJSON(json_str)
Make sure went process_operation
is called to check if the operation_name
is merge
, the operation_scope
is Instance
and the RequestMethod
is POST
.
def process_operation( self, operation_name:str, operation_scope:str, body:dict, fhir_service:'iris.HS.FHIRServer.API.Service', fhir_request:'iris.HS.FHIRServer.API.Data.Request', fhir_response:'iris.HS.FHIRServer.API.Data.Response' ) -> 'iris.HS.FHIRServer.API.Data.Response': """ @API Process an Operation request. @Input operation_name : The name of the Operation to process. @Input operation_scope : The scope of the Operation to process. @Input fhir_service : The FHIR Service object. @Input fhir_request : The FHIR Request object. @Input fhir_response : The FHIR Response object. @Output : The FHIR Response object. """ if operation_name == "merge" and operation_scope == "Instance" and fhir_request.RequestMethod == "POST": # get the primary resource primary_resource = json.loads(fhir_service.interactions.Read(fhir_request.Type, fhir_request.Id)._ToJSON()) # get the secondary resource secondary_resource = json.loads(fhir_request.Json._ToJSON()) # retun the diff of the two resources # make use of deepdiff to get the difference between the two resources diff = DeepDiff(primary_resource, secondary_resource, ignore_order=True).to_json()
# create a new %DynamicObject to store the result result = iris.cls('%DynamicObject')._FromJSON(diff) # set the result to the response fhir_response.Json = result return fhir_response
Test it :
POST https://localhost:4443/fhir/r5/Patient/1/$merge Authorization : Bearer Accept: application/fhir+json
{
"resourceType": "Patient",
"id": "2",
"meta": {
"versionId": "2"
}
}
You will get the diff of the 2 patients.
{
"values_changed": {
"root['address'][0]['city']": {
"new_value": "fdsfd",
"old_value": "Lynnfield"
},
"root['meta']['lastUpdated']": {
"new_value": "2024-02-24T09:11:00Z",
"old_value": "2024-02-28T13:50:27Z"
},
"root['meta']['versionId']": {
"new_value": "1",
"old_value": "2"
}
}
}
Great, you have now created the custom operation. 🥳
In %SYS
set ^%ISCLOG = 5
zw ^ISCLOG
from grongier.pex import BusinessProcess import iris import jwt import json from fhir.resources import patient, bundle
class MyBusinessProcess(BusinessProcess):
def on_init(self): if not hasattr(self, 'target'): self.target = 'HS.FHIRServer.Interop.HTTPOperation' return def on_fhir_request(self, request:'iris.HS.FHIRServer.Interop.Request'): # Do something with the request self.log_info('Received a FHIR request') # pass it to the target rsp = self.send_request_sync(self.target, request) # Try to get the token from the request token = request.Request.AdditionalInfo.GetAt("USER:OAuthToken") or "" # Do something with the response if self.check_token(token): self.log_info('Filtering the response') # Filter the response payload_str = self.quick_stream_to_string(rsp.QuickStreamId) # if the payload is empty, return the response if payload_str == '': return rsp filtered_payload_string = self.filter_resources(payload_str) if filtered_payload_string == '': return rsp # write the json string to a quick stream quick_stream = self.string_to_quick_stream(filtered_payload_string) # return the response rsp.QuickStreamId = quick_stream._Id() return rsp def check_token(self, token:str) -> bool: # decode the token decoded_token= jwt.decode(token, options={"verify_signature": False}) # check if the token is valid if 'VIP' in decoded_token['scope']: return True else: return False def quick_stream_to_string(self, quick_stream_id) -> str: quick_stream = iris.cls('HS.SDA3.QuickStream')._OpenId(quick_stream_id) json_payload = '' while quick_stream.AtEnd == 0: json_payload += quick_stream.Read() return json_payload def string_to_quick_stream(self, json_string:str): quick_stream = iris.cls('HS.SDA3.QuickStream')._New() # write the json string to the payload n = 3000 chunks = [json_string[i:i+n] for i in range(0, len(json_string), n)] for chunk in chunks: quick_stream.Write(chunk) return quick_stream def filter_patient_resource(self, patient_str:str) -> str: # filter the patient p = patient.Patient(**json.loads(patient_str)) # remove the name p.name = [] # remove the address p.address = [] # remove the telecom p.telecom = [] # remove the birthdate p.birthDate = None return p.json() def filter_resources(self, resource_str:str) -> str: # parse the payload payload_dict = json.loads(resource_str) # what is the resource type? resource_type = payload_dict['resourceType'] if 'resourceType' in payload_dict else 'None' self.log_info('Resource type: ' + resource_type) # is it a bundle? if resource_type == 'Bundle': obj = bundle.Bundle(**payload_dict) # filter the bundle for entry in obj.entry: if entry.resource.resource_type == 'Patient': self.log_info('Filtering a patient') entry.resource = patient.Patient(**json.loads(self.filter_patient_resource(entry.resource.json()))) elif resource_type == 'Patient': # filter the patient obj = patient.Patient(**json.loads(self.filter_patient_resource(resource_str))) else: return resource_str return obj.json()