# HG changeset patch # User Sebastien Jodogne # Date 1697874805 -7200 # Node ID 8d876a4f541bca710138ae4272ae86a8b608699b # Parent 6b9433432ee0b340624f1607c35261a5525f1fb2 added sample FHIR server diff -r 6b9433432ee0 -r 8d876a4f541b .hgignore --- a/.hgignore Thu Oct 19 12:20:46 2023 +0200 +++ b/.hgignore Sat Oct 21 09:53:25 2023 +0200 @@ -17,3 +17,6 @@ Samples/Dcm4Che/.idea/ Samples/Dcm4Che/OrthancStorage/ Samples/Dcm4Che/target/ +Samples/FHIR/.idea/ +Samples/FHIR/OrthancStorage/ +Samples/FHIR/target/ diff -r 6b9433432ee0 -r 8d876a4f541b .reuse/dep5 --- a/.reuse/dep5 Thu Oct 19 12:20:46 2023 +0200 +++ b/.reuse/dep5 Sat Oct 21 09:53:25 2023 +0200 @@ -3,7 +3,7 @@ Upstream-Contact: Sebastien Jodogne Source: https://orthanc.uclouvain.be/ -Files: NEWS README Samples/Basic/NOTES.txt Samples/Dcm4Che/NOTES.txt +Files: NEWS README Samples/Basic/NOTES.txt Samples/Dcm4Che/NOTES.txt Samples/FHIR/NOTES.txt Copyright: 2023 Sebastien Jodogne, UCLouvain, Belgium License: CC0-1.0 diff -r 6b9433432ee0 -r 8d876a4f541b Samples/Basic/pom.xml --- a/Samples/Basic/pom.xml Thu Oct 19 12:20:46 2023 +0200 +++ b/Samples/Basic/pom.xml Sat Oct 21 09:53:25 2023 +0200 @@ -7,7 +7,9 @@ --> - + 4.0.0 diff -r 6b9433432ee0 -r 8d876a4f541b Samples/Dcm4Che/pom.xml --- a/Samples/Dcm4Che/pom.xml Thu Oct 19 12:20:46 2023 +0200 +++ b/Samples/Dcm4Che/pom.xml Sat Oct 21 09:53:25 2023 +0200 @@ -7,7 +7,9 @@ --> - + 4.0.0 diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/NOTES.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/NOTES.txt Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,26 @@ + +This sample Java plugin runs a FHIR server based on the HAPI library. +Note that contrarily to the other samples that can run using Java 1.8, +this Java plugin requires Java 11. + +(1) Make sure to build the C++ plugin: + +# cd ../../Plugin/ +# mkdir Build +# cd Build +# cmake .. -DCMAKE_BUILD_TYPE=Release +# make -j4 + + +(2) Compile the Java plugin using Maven: + +# cd ../../Samples/FHIR +# mvn package + + +(3) Start Orthanc: + +On Ubuntu 22.04: + +# LD_LIBRARY_PATH=/usr/lib/jvm/java-11-openjdk-amd64/lib/server/ Orthanc ./configuration.json + diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/pom.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/pom.xml Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,100 @@ + + + + + + 4.0.0 + + + + org.apache.maven.plugins + maven-compiler-plugin + + 11 + 11 + -Xlint:deprecation + + + + + + maven-assembly-plugin + + + package + + single + + + + + + jar-with-dependencies + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/../../JavaSDK/be/uclouvain/orthanc + + + + + + + + + + OrthancJava + OrthancFHIR + mainline + + + + + org.json + json + 20230618 + + + ca.uhn.hapi.fhir + hapi-fhir-structures-r5 + 6.4.3 + + + ca.uhn.hapi.fhir + hapi-fhir-server + 6.4.3 + + + org.springframework + spring-test + 5.3.29 + + + javax.servlet + javax.servlet-api + 3.1.0 + + + + diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/EndpointProvider.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/EndpointProvider.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,84 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import ca.uhn.fhir.rest.annotation.IdParam; +import ca.uhn.fhir.rest.annotation.Read; +import ca.uhn.fhir.rest.annotation.Search; +import ca.uhn.fhir.rest.server.IResourceProvider; +import org.hl7.fhir.r5.model.Endpoint; +import org.hl7.fhir.r5.model.IdType; + +import java.util.ArrayList; +import java.util.List; + +public class EndpointProvider implements IResourceProvider { + public static final String ID = "wado-rs"; + private IOrthancConnection orthanc; + private FhirConfiguration configuration; + + EndpointProvider(IOrthancConnection orthanc) { + this.orthanc = orthanc; + this.configuration = new FhirConfiguration(); + } + + private Endpoint createEndpoint() { + Endpoint endpoint = new Endpoint(); + + // https://build.fhir.org/endpoint-example-wadors.json.html + + endpoint.setAddress(configuration.getDicomWebBaseUrl()); + endpoint.setId(ID); + endpoint.setStatus(Endpoint.EndpointStatus.ACTIVE); + endpoint.addConnectionType(Toolbox.createCodeableConcept("http://terminology.hl7.org/CodeSystem/endpoint-connection-type", "dicom-wado-rs")); + endpoint.setName("Orthanc DICOMweb server"); + + return endpoint; + } + + @Override + public Class getResourceType() { + return Endpoint.class; + } + + @Read() + public Endpoint getResourceById(@IdParam IdType theId) { + if (theId.getIdPart().equals(ID) && + IOrthancConnection.hasPluginInstalled(orthanc, "dicom-web")) { + return createEndpoint(); + } else { + return null; + } + } + + @Search() + public List getEndpoints() { + List endpoints = new ArrayList<>(); + + if (IOrthancConnection.hasPluginInstalled(orthanc, "dicom-web")) { + endpoints.add(createEndpoint()); + } + + return endpoints; + } +} diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/FhirConfiguration.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/FhirConfiguration.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,82 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import be.uclouvain.orthanc.Functions; + +import org.json.JSONObject; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; + +public class FhirConfiguration { + static private final String KEY_FHIR = "FHIR"; + static private final String KEY_DICOM_WEB = "DicomWeb"; + static private final String KEY_ROOT = "Root"; + static private final String KEY_BASE_URL = "BaseUrl"; + + private String serverBaseUrl = "http://localhost:8042/fhir"; + private String dicomWebRelativeUri = "../dicom-web"; + + FhirConfiguration() { + JSONObject configuration = new JSONObject(Functions.getConfiguration()); + + if (configuration.has(KEY_FHIR)) { + JSONObject fhir = configuration.getJSONObject(KEY_FHIR); + if (fhir.has(KEY_BASE_URL)) { + serverBaseUrl = fhir.getString(KEY_BASE_URL); + } + } + + if (configuration.has(KEY_DICOM_WEB)) { + JSONObject dicomWeb = configuration.getJSONObject(KEY_DICOM_WEB); + if (dicomWeb.has(KEY_ROOT)) { + dicomWebRelativeUri = "../" + dicomWeb.getString(KEY_ROOT); + } + } + + if (!serverBaseUrl.endsWith("/")) { + serverBaseUrl = serverBaseUrl + "/"; + } + } + + public String getServerBaseUrl() { + return serverBaseUrl; + } + + public String getDicomWebBaseUrl() { + /** + * From URL: ://?# + */ + + try { + URL url = new URL(serverBaseUrl); + URI uri = new URI(url.getPath()); + return url.getProtocol() + "://" + url.getAuthority() + uri.resolve(dicomWebRelativeUri); + } catch (MalformedURLException | URISyntaxException e) { + throw new IllegalArgumentException("Bad URL for DICOMweb: " + serverBaseUrl + dicomWebRelativeUri); + } + } +} diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/FhirRequestHandler.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/FhirRequestHandler.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,101 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import ca.uhn.fhir.rest.server.RestfulServer; +import be.uclouvain.orthanc.Callbacks; +import be.uclouvain.orthanc.RestOutput; +import be.uclouvain.orthanc.HttpMethod; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import javax.servlet.ServletException; +import java.io.IOException; +import java.util.Map; + +public class FhirRequestHandler implements Callbacks.OnRestRequest { + private static RestfulServer server; + + FhirRequestHandler(RestfulServer server) { + this.server = server; + } + + @Override + public void call(RestOutput output, + HttpMethod method, + String uri, + String[] regularExpressionGroups, + Map headers, + Map getParameters, byte[] body) { + MockHttpServletRequest request = new MockHttpServletRequest(); + + if (regularExpressionGroups.length == 0) { + request.setRequestURI("/"); + } else { + request.setRequestURI(regularExpressionGroups[0]); + } + request.setContent(body); + + switch (method) { + case GET: + request.setMethod("GET"); + break; + case POST: + request.setMethod("POST"); + break; + case PUT: + request.setMethod("PUT"); + break; + case DELETE: + request.setMethod("DELETE"); + break; + default: + throw new IllegalArgumentException("Unknown HTTP method"); + } + + for (Map.Entry entry : headers.entrySet()) { + request.addHeader(entry.getKey(), entry.getValue()); + } + + for (Map.Entry entry : getParameters.entrySet()) { + request.setParameter(entry.getKey(), entry.getValue()); + } + + try { + MockHttpServletResponse response = new MockHttpServletResponse(); + server.service(request, response); + + for (String header : response.getHeaderNames()) { + if (header != "Content-Type") { + output.setHttpHeader(header, response.getHeader(header)); + } + } + + output.answerBuffer(response.getContentAsByteArray(), response.getContentType()); + } catch (IOException e) { + output.sendHttpStatusCode((short) 500); + } catch (ServletException e) { + output.sendHttpStatusCode((short) 500); + } + } +} diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/IOrthancConnection.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/IOrthancConnection.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,87 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +public interface IOrthancConnection { + public byte[] doGet(String uri, + Map parameters); + + public byte[] doPost(String uri, + byte[] body); + + public static String formatGetParameters(Map parameters) { + String result = ""; + + for (Map.Entry entry : parameters.entrySet()) { + String item; + if (entry.getValue().isEmpty()) { + item = entry.getKey(); + } else { + item = entry.getKey() + "=" + entry.getValue(); + } + + if (result.isEmpty()) { + result = "?" + item; + } else { + result = result + "&" + item; + } + } + + return result; + } + + public static JSONObject getJSONObject(IOrthancConnection orthanc, + String uri, + Map parameters) { + byte[] a = orthanc.doGet(uri, parameters); + return new JSONObject(new String(a, StandardCharsets.UTF_8)); + } + + public static JSONArray getJSONArray(IOrthancConnection orthanc, + String uri, + Map parameters) { + byte[] a = orthanc.doGet(uri, parameters); + return new JSONArray(new String(a, StandardCharsets.UTF_8)); + } + + public static boolean hasPluginInstalled(IOrthancConnection orthanc, + String plugin) { + Map empty = new HashMap<>(); + JSONArray plugins = IOrthancConnection.getJSONArray(orthanc, "/plugins", empty); + + for (int i = 0; i < plugins.length(); i++) { + if (plugins.getString(i).equals(plugin)) { + return true; + } + } + + return false; + } +} diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/ImagingStudyProvider.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/ImagingStudyProvider.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,94 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import ca.uhn.fhir.rest.annotation.IdParam; +import ca.uhn.fhir.rest.annotation.OptionalParam; +import ca.uhn.fhir.rest.annotation.Read; +import ca.uhn.fhir.rest.annotation.Search; +import ca.uhn.fhir.rest.param.ReferenceParam; +import ca.uhn.fhir.rest.param.StringParam; +import ca.uhn.fhir.rest.server.IResourceProvider; +import org.hl7.fhir.r5.model.IdType; +import org.hl7.fhir.r5.model.ImagingStudy; +import org.hl7.fhir.r5.model.Patient; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ImagingStudyProvider implements IResourceProvider { + private IOrthancConnection orthanc; + + ImagingStudyProvider(IOrthancConnection orthanc) { + this.orthanc = orthanc; + } + + @Override + public Class getResourceType() { + return ImagingStudy.class; + } + + @Read + public ImagingStudy getResourceById(@IdParam IdType theId) { + Map tags = new HashMap<>(); + tags.put(Toolbox.TAG_STUDY_INSTANCE_UID, theId.getIdPart()); + + List resources = OrthancResource.find(orthanc, be.uclouvain.orthanc.ResourceType.STUDY, tags, true); + if (resources.size() == 0) { + return null; + } else if (resources.size() > 1) { + throw new RuntimeException("Too many matching resources"); + } else { + return resources.get(0).getFhirStudy(orthanc); + } + } + + @Search() + public List getImagingStudies(@OptionalParam(name = ImagingStudy.SP_IDENTIFIER) StringParam theIdentifier, + @OptionalParam(name = ImagingStudy.SP_SUBJECT) ReferenceParam theSubject) { + Map tags = new HashMap<>(); + + if (theIdentifier != null) { + tags.put(Toolbox.TAG_STUDY_INSTANCE_UID, theIdentifier.getValue()); + } + + if (theSubject != null) { + // https://hapifhir.io/hapi-fhir/docs/server_plain/rest_operations_search.html#search-parameters-resource-reference + tags.put(Toolbox.TAG_PATIENT_ID, theSubject.getValue()); + } + + List resources = OrthancResource.find(orthanc, be.uclouvain.orthanc.ResourceType.STUDY, tags, false); + + List studies = new ArrayList<>(); + for (OrthancResource resource : resources) { + ImagingStudy study = resource.getFhirStudy(orthanc); + if (study.hasId()) { + studies.add(study); + } + } + + return studies; + } +} diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/Main.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/Main.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import be.uclouvain.orthanc.Callbacks; +import be.uclouvain.orthanc.Functions; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.server.HardcodedServerAddressStrategy; +import ca.uhn.fhir.rest.server.IResourceProvider; +import ca.uhn.fhir.rest.server.RestfulServer; +import org.json.JSONArray; +import org.json.JSONObject; +import org.springframework.mock.web.MockServletConfig; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.servlet.ServletException; + + +public class Main extends RestfulServer { + Main() throws ServletException { + setFhirContext(FhirContext.forR5()); + + FhirConfiguration configuration = new FhirConfiguration(); + setServerAddressStrategy(new HardcodedServerAddressStrategy(configuration.getServerBaseUrl())); + + configuration.getDicomWebBaseUrl(); + + IOrthancConnection connection = new OrthancPluginConnection(); + + List resourceProviders = new ArrayList(); + resourceProviders.add(new PatientProvider(connection)); + resourceProviders.add(new ImagingStudyProvider(connection)); + resourceProviders.add(new EndpointProvider(connection)); + setResourceProviders(resourceProviders); + } + + static { + Main main; + + try { + main = new Main(); + main.init(new MockServletConfig()); + } catch (ServletException e) { + throw new RuntimeException("Cannot start the HAPI FHIR server"); + } + + Callbacks.register("/fhir(/.*)", new FhirRequestHandler(main)); + Callbacks.register("/fhir", new FhirRequestHandler(main)); + } +} diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/OrthancPluginConnection.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/OrthancPluginConnection.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import java.util.Map; + +public class OrthancPluginConnection implements IOrthancConnection { + + @Override + public byte[] doGet(String uri, Map parameters) { + return be.uclouvain.orthanc.Functions.restApiGet(uri + IOrthancConnection.formatGetParameters(parameters)); + } + + @Override + public byte[] doPost(String uri, byte[] body) { + return be.uclouvain.orthanc.Functions.restApiPost(uri, body); + } +} diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/OrthancResource.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/OrthancResource.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,296 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import be.uclouvain.orthanc.ResourceType; +import org.hl7.fhir.r5.model.*; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class OrthancResource { + private ResourceType type; + private String id; + private String lastUpdate; + private Map tags; + private List children; + + public OrthancResource(JSONObject info) { + String s = info.getString("Type"); + if (s.equals("Patient")) { + type = ResourceType.PATIENT; + } else if (s.equals("Study")) { + type = ResourceType.STUDY; + } else if (s.equals("Series")) { + type = ResourceType.SERIES; + } else if (s.equals("Instance")) { + type = ResourceType.INSTANCE; + } else { + throw new RuntimeException("Unknown resource type"); + } + + id = info.getString("ID"); + lastUpdate = info.optString("LastUpdate"); + tags = new HashMap<>(); + addToDictionary(tags, info.getJSONObject("MainDicomTags")); + + if (type != ResourceType.INSTANCE) { + String childKey; + switch (type) { + case PATIENT: + childKey = "Studies"; + break; + case STUDY: + childKey = "Series"; + addToDictionary(tags, info.getJSONObject("PatientMainDicomTags")); + break; + case SERIES: + childKey = "Instances"; + break; + default: + throw new RuntimeException(); + } + + children = new ArrayList<>(); + addToListOfStrings(children, info.getJSONArray(childKey)); + } + } + + static public void addToDictionary(Map target, + JSONObject source) { + for (String key : source.keySet()) { + target.put(key, source.getString(key)); + } + } + + static public void addToListOfStrings(List target, + JSONArray source) { + for (int i = 0; i < source.length(); i++) { + target.add(source.getString(i)); + } + } + + public ResourceType getType() { + return type; + } + + public String getId() { + return id; + } + + public String getLastUpdate() { + return lastUpdate; + } + + public Map getTags() { + return tags; + } + + public List getChildren() { + if (type == ResourceType.INSTANCE) { + throw new RuntimeException("A DICOM instance has no child"); + } else { + return children; + } + } + + public static List find(IOrthancConnection connection, + ResourceType type, + Map tags, + boolean caseSensitive) { + JSONObject query = new JSONObject(); + for (Map.Entry entry : tags.entrySet()) { + query.put(entry.getKey(), entry.getValue()); + } + + JSONObject request = new JSONObject(); + request.put("Expand", true); + request.put("Query", query); + request.put("Short", true); + request.put("CaseSensitive", caseSensitive); + + switch (type) { + case PATIENT: + request.put("Level", "Patient"); + break; + case STUDY: + request.put("Level", "Study"); + break; + case SERIES: + request.put("Level", "Series"); + break; + case INSTANCE: + request.put("Level", "Instance"); + break; + default: + throw new RuntimeException(); + } + + byte[] response = connection.doPost("/tools/find", request.toString().getBytes(StandardCharsets.UTF_8)); + + JSONArray arr = new JSONArray(new String(response, StandardCharsets.UTF_8)); + + List result = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) { + result.add(new OrthancResource(arr.getJSONObject(i))); + } + + return result; + } + + + public Patient getFhirPatient() { + if (type != ResourceType.PATIENT) { + throw new RuntimeException("Not a patient"); + } + + Patient patient = new Patient(); + patient.setId(getTags().getOrDefault(Toolbox.TAG_PATIENT_ID, "")); + + String birthDate = getTags().getOrDefault(Toolbox.TAG_PATIENT_BIRTH_DATE, ""); + if (birthDate != null) { + patient.setBirthDate(Toolbox.parseDicomDate(birthDate)); + } + + String patientName = getTags().getOrDefault(Toolbox.TAG_PATIENT_NAME, ""); + if (!patientName.isEmpty()) { + patient.addName(); + HumanName name = patient.getName().get(0); + + String[] parts = patientName.split("\\^"); + + // https://dicom.nema.org/medical/dicom/current/output/chtml/part19/sect_10.2.html + // https://www.hl7.org/fhir/datatypes.html#HumanName + if (parts.length > 0) { + name.setFamily(parts[0]); + } + for (int i = 1; i < parts.length; i++) { + name.addGiven(parts[i]); + } + } + + String sex = getTags().getOrDefault(Toolbox.TAG_PATIENT_SEX, ""); + if (sex.equals("M")) { + patient.setGender(Enumerations.AdministrativeGender.MALE); + } else if (sex.equals("F")) { + patient.setGender(Enumerations.AdministrativeGender.FEMALE); + } + + return patient; + } + + public ImagingStudy getFhirStudy(IOrthancConnection orthanc) { + if (type != ResourceType.STUDY) { + throw new RuntimeException("Not a study"); + } + + boolean hasDicomWeb = IOrthancConnection.hasPluginInstalled(orthanc, "dicom-web"); + + // https://build.fhir.org/imagingstudy-example.json.html + + ImagingStudy study = new ImagingStudy(); + study.setId(getTags().getOrDefault(Toolbox.TAG_STUDY_INSTANCE_UID, "")); + study.setStatus(ImagingStudy.ImagingStudyStatus.AVAILABLE); + + if (hasDicomWeb) { + study.addEndpoint(Toolbox.createLocalReference("Endpoint", EndpointProvider.ID)); + } + + study.setSubject(Toolbox.createLocalReference("Patient", getTags().getOrDefault(Toolbox.TAG_PATIENT_ID, ""))); + + String studyDate = getTags().getOrDefault(Toolbox.TAG_STUDY_DATE, ""); + if (!studyDate.isEmpty()) { + study.setStarted(Toolbox.parseDicomDate(studyDate)); + } + + study.addIdentifier(); + study.getIdentifier().get(0).setSystem("urn:dicom:uid"); + study.getIdentifier().get(0).setValue("urn:oid:" + study.getId()); + + study.setNumberOfSeries(getChildren().size()); + + int countInstances = 0; + + Map shortTags = new HashMap<>(); + shortTags.put("short", ""); + + Map expand = new HashMap<>(); + expand.put("expand", ""); + + for (int i = 0; i < getChildren().size(); i++) { + String seriesUri = "/series/" + getChildren().get(i); + OrthancResource orthancSeries = new OrthancResource(IOrthancConnection.getJSONObject(orthanc, seriesUri, shortTags)); + + ImagingStudy.ImagingStudySeriesComponent fhirSeries = study.addSeries(); + fhirSeries.setUid(orthancSeries.getTags().getOrDefault(Toolbox.TAG_SERIES_INSTANCE_UID, "")); + + String modality = orthancSeries.getTags().getOrDefault(Toolbox.TAG_MODALITY, ""); + if (!modality.isEmpty()) { + fhirSeries.setModality(Toolbox.createDicomCodeableConcept(modality)); + } + + String seriesDescription = orthancSeries.getTags().getOrDefault(Toolbox.TAG_SERIES_DESCRIPTION, ""); + if (!seriesDescription.isEmpty()) { + fhirSeries.setDescription(seriesDescription); + } + + String seriesNumber = orthancSeries.getTags().getOrDefault(Toolbox.TAG_SERIES_NUMBER, ""); + if (!seriesNumber.isEmpty()) { + fhirSeries.setNumber(Integer.parseInt(seriesNumber)); + } + + fhirSeries.setNumberOfInstances(orthancSeries.getChildren().size()); + + for (int j = 0; j < orthancSeries.getChildren().size(); j++) { + String instanceUri = "/instances/" + orthancSeries.getChildren().get(j); + OrthancResource orthancInstance = new OrthancResource(IOrthancConnection.getJSONObject(orthanc, instanceUri, shortTags)); + + JSONObject instanceMetadata = IOrthancConnection.getJSONObject(orthanc, instanceUri + "/metadata", expand); + + ImagingStudy.ImagingStudySeriesInstanceComponent fhirInstance = fhirSeries.addInstance(); + fhirInstance.setUid(orthancInstance.getTags().getOrDefault(Toolbox.TAG_SOP_INSTANCE_UID, "")); + + String instanceNumber = orthancInstance.getTags().getOrDefault(Toolbox.TAG_INSTANCE_NUMBER, ""); + if (!instanceNumber.isEmpty()) { + fhirInstance.setNumber(Integer.parseInt(instanceNumber)); + } + + String sopClassUid = instanceMetadata.optString("SopClassUid", ""); + if (!sopClassUid.isEmpty()) { + fhirInstance.setSopClass(new Coding("urn:ietf:rfc:3986", "urn:oid:" + sopClassUid, "")); + } + } + + countInstances += orthancSeries.getChildren().size(); + } + + study.setNumberOfInstances(countInstances); + + return study; + } +} diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/PatientProvider.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/PatientProvider.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,104 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import be.uclouvain.orthanc.ResourceType; +import ca.uhn.fhir.rest.annotation.IdParam; +import ca.uhn.fhir.rest.annotation.OptionalParam; +import ca.uhn.fhir.rest.annotation.Read; +import ca.uhn.fhir.rest.annotation.Search; +import ca.uhn.fhir.rest.param.DateParam; +import ca.uhn.fhir.rest.param.StringParam; +import ca.uhn.fhir.rest.server.IResourceProvider; +import org.hl7.fhir.r5.model.IdType; +import org.hl7.fhir.r5.model.Patient; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class PatientProvider implements IResourceProvider { + private IOrthancConnection orthanc; + + PatientProvider(IOrthancConnection orthanc) { + this.orthanc = orthanc; + } + + @Override + public Class getResourceType() { + return Patient.class; + } + + @Read() + public Patient getResourceById(@IdParam IdType theId) { + Map tags = new HashMap<>(); + tags.put(Toolbox.TAG_PATIENT_ID, theId.getIdPart()); + + List resources = OrthancResource.find(orthanc, ResourceType.PATIENT, tags, true); + if (resources.size() == 0) { + return null; + } else if (resources.size() > 1) { + throw new RuntimeException("Too many matching resources"); + } else { + return resources.get(0).getFhirPatient(); + } + } + + @Search() + public List getPatients(@OptionalParam(name = Patient.SP_FAMILY) StringParam theFamilyName, + @OptionalParam(name = Patient.SP_GIVEN) StringParam theGivenName, + @OptionalParam(name = Patient.SP_IDENTIFIER) StringParam theIdentifier, + @OptionalParam(name = Patient.SP_BIRTHDATE) DateParam theBirthDate) { + Map tags = new HashMap<>(); + + if (theFamilyName != null && + theGivenName != null) { + tags.put(Toolbox.TAG_PATIENT_NAME, "*" + theFamilyName.getValue() + "*" + theGivenName.getValue() + "*"); + } else if (theFamilyName != null) { + tags.put(Toolbox.TAG_PATIENT_NAME, "*" + theFamilyName.getValue() + "*"); + } else if (theGivenName != null) { + tags.put(Toolbox.TAG_PATIENT_NAME, "*" + theGivenName.getValue() + "*"); + } + + if (theIdentifier != null) { + tags.put(Toolbox.TAG_PATIENT_ID, theIdentifier.getValue()); + } + + if (theBirthDate != null) { + tags.put(Toolbox.TAG_PATIENT_BIRTH_DATE, Toolbox.formatDicomDate(theBirthDate.getValue())); + } + + List resources = OrthancResource.find(orthanc, ResourceType.PATIENT, tags, false); + + List patients = new ArrayList<>(); + for (OrthancResource resource : resources) { + Patient patient = resource.getFhirPatient(); + if (patient.hasId()) { + patients.add(patient); + } + } + + return patients; + } +} diff -r 6b9433432ee0 -r 8d876a4f541b Samples/FHIR/src/main/java/Toolbox.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Samples/FHIR/src/main/java/Toolbox.java Sat Oct 21 09:53:25 2023 +0200 @@ -0,0 +1,112 @@ +/** + * SPDX-FileCopyrightText: 2023 Sebastien Jodogne, UCLouvain, Belgium + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * Java plugin for Orthanc + * Copyright (C) 2023 Sebastien Jodogne, UCLouvain, Belgium + * + * This program is free software: you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + **/ + + +import org.hl7.fhir.r5.model.CodeableConcept; +import org.hl7.fhir.r5.model.Reference; + +import java.time.LocalDate; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Toolbox { + public static String TAG_PATIENT_NAME = "0010,0010"; + public static String TAG_PATIENT_ID = "0010,0020"; + public static String TAG_PATIENT_BIRTH_DATE = "0010,0030"; + public static String TAG_PATIENT_SEX = "0010,0040"; + public static String TAG_STUDY_INSTANCE_UID = "0020,000d"; + public static String TAG_STUDY_DATE = "0008,0020"; + public static String TAG_SERIES_DESCRIPTION = "0008,103e"; + public static String TAG_SERIES_INSTANCE_UID = "0020,000e"; + public static String TAG_MODALITY = "0008,0060"; + public static String TAG_SERIES_NUMBER = "0020,0011"; + public static String TAG_SOP_INSTANCE_UID = "0008,0018"; + public static String TAG_INSTANCE_NUMBER = "0020,0013"; + + public static Date parseDicomDate(String date) { + Pattern pattern = Pattern.compile("^([0-9]{4})([0-9]{2})([0-9]{2})$"); + Matcher matcher = pattern.matcher(date); + if (matcher.matches()) { + GregorianCalendar calendar = new GregorianCalendar( + Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2)) - 1, // Month is 1-based in DICOM + Integer.parseInt(matcher.group(3))); + return calendar.getTime(); + } else { + throw new IllegalArgumentException("Badly formatted DICOM date: " + date); + } + } + + public static Date parseFhirDate(String date) { + Pattern pattern = Pattern.compile("^([0-9]{4})-([0-9]{2})-([0-9]{2})$"); + Matcher matcher = pattern.matcher(date); + if (matcher.matches()) { + Calendar c = Calendar.getInstance(); + c.set(Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2)) - 1, // Month is 1-based in FHIR + Integer.parseInt(matcher.group(3))); + return c.getTime(); + } else { + throw new IllegalArgumentException("Badly formatted FHIR date: " + date); + } + } + + public static String formatDicomDate(Date date) { + Calendar calendar = new GregorianCalendar(); + calendar.setTime(date); + return String.format("%04d%02d%02d", calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH) + 1, // Month is 1-based in DICOM + calendar.get(Calendar.DAY_OF_MONTH)); + } + + public static String formatFhirDate(Date date) { + Calendar calendar = new GregorianCalendar(); + calendar.setTime(date); + return String.format("%04d-%02d-%02d", calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH) + 1, // Month is 1-based in FHIR + calendar.get(Calendar.DAY_OF_MONTH)); + } + + public static CodeableConcept createCodeableConcept(String system, + String code) { + CodeableConcept codeable = new CodeableConcept(); + codeable.addCoding(); + codeable.getCoding().get(0).setSystem(system); + codeable.getCoding().get(0).setCode(code); + return codeable; + } + + public static CodeableConcept createDicomCodeableConcept(String code) { + return createCodeableConcept("http://dicom.nema.org/resources/ontology/DCM", code); + } + + public static Reference createLocalReference(String type, + String id) { + Reference reference = new Reference(); + reference.setReference(type + "/" + id); + return reference; + } +}