# HAPI FHIR 使用遠端 Validator 服務 ## 前言 因為在實務上使用 HAPI FHIR 的 $validate API 時,遇到眾多問題,而使用原生官方的 validator 時,卻又無錯誤,所以決定將 HAPI FHIR 的 $validate 更改成呼叫遠端的驗證服務,並回傳 OperationOutcome ![架構圖](https://hackmd.io/_uploads/BydP9Y0Bye.png) :::danger 實作前,請先準備好使用 docker compose 架設的 HAPI FHIR,且套用 tx server 能力: [讓 HAPI FHIR 作為 Official Validator 的 tx server](/MScT7P5nSZOipo-SC427ZQ) ::: ## Validator 服務選擇 - 在 validator 的選擇上,有官方的 [validator wrapper](https://github.com/hapifhir/org.hl7.fhir.validator-wrapper) 以及 inferno 社群做的 [fhir-validator-wrapper](https://github.com/inferno-framework/fhir-validator-wrapper) - 筆者選擇 inferno 的 validator 是因為以下原因 - 可以放置自訂的 ig package - 回傳的資料為 OperationOutcome ## 架設 inferno validator ### docker-compose.yaml - 本文章使用 docker compose 部屬 ```yaml!= name: inferno services: inferno-validator: image: infernocommunity/fhir-validator-service container_name: inferno-validator restart: always volumes: - ./igs:/home/igs - ./fhir-cache:/root/.fhir environment: - TX_SERVER_URL=${HAPI_FHIR_URL}/fhir ports: - 4567:4567 ``` :::info - 裡面映射的 igs 是放置自訂 ig package 的資料夾 - fhir-cache 是放置下載的 ig package 快取的資料夾,避免重建 container 又要重新下載 - ${HAPI_FHIR_URL} 請務必更改成自己的 HAPI FHIR 網址 ::: ## 部屬 - 設定完畢後,用以下指令部屬 ```bash!= docker compose up -d ``` - 查看 log,如果有出現安裝 package 跟讀取自訂的 package 就代表成功囉! ![inferno validator 啟動 log](https://hackmd.io/_uploads/r1bloFCBke.png) ## 利用 interceptor 更改 $validate 操作 - 以下是筆者創建的 3 個檔案,主要做的事情是 1. 判斷 request 的操作是否為 $validate 2. 若符合,就把 request body 轉成 json string 傳送到 inferno validator 3. 將 inferno validator 回傳的資料轉成 OperationOutcome 4. 判斷 OperationOutcome 有沒有 fatal 或 error,若有則要將 status code 更改成 422 5. 在 OperationOutcome 最上面加上一條說明,此驗證由 inferno validator 驗證,告訴使用者 $validate 並非原生 HAPI FHIR 的驗證 ### ValidationResponseResult.java - 用於取得 response 的 body 和 status 的 class ```java!= package org.cylab; public class ValidationResponseResult { private String body; private int status; public ValidationResponseResult(String body, int status) { this.body = body; this.status = status; } public String getBody() { return body; } public int getStatus() { return status; } @Override public String toString() { return "Status: " + status + ", Body: " + body; } } ``` ### ValidatorCaller - 呼叫 inferno validator 的 class ```java!= package org.cylab; import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; public class ValidatorCaller { public ValidationResponseResult sendJsonRequest(String targetUrl, String jsonPayload) { HttpURLConnection connection = null; StringBuilder response = new StringBuilder(); int statusCode = -1; try { // 建立 URL 連線 URL url = new URL(targetUrl); connection = (HttpURLConnection) url.openConnection(); // 設定請求屬性 connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json; utf-8"); connection.setRequestProperty("Accept", "application/json"); connection.setDoOutput(true); // 傳送 JSON 請求 try (OutputStream os = connection.getOutputStream()) { byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8); os.write(input, 0, input.length); } // 取得回應狀態碼 statusCode = connection.getResponseCode(); // 接收回應 try (BufferedReader br = new BufferedReader( new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { String responseLine; while ((responseLine = br.readLine()) != null) { response.append(responseLine.trim()); } } } catch (Exception e) { e.printStackTrace(); return new ValidationResponseResult("Error: " + e.getMessage(), statusCode); } finally { if (connection != null) { connection.disconnect(); } } return new ValidationResponseResult(response.toString(), statusCode); } } ``` ### ValidateOpCustomize - 修改 $validate 操作的 interceptor ```java!= package org.cylab; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.interceptor.api.Hook; import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.rest.api.RequestTypeEnum; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; import ca.uhn.fhir.util.OperationOutcomeUtil; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.r4.model.OperationOutcome; import org.hl7.fhir.r4.model.Parameters; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Collections; public class ValidateOpCustomizer { private static final String JSON_CONTENT_TYPE = "application/fhir+json"; private static final String XML_CONTENT_TYPE = "application/fhir+xml"; @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLER_SELECTED) public boolean onIncomingRequest(ServletRequestDetails theRequestDetails, HttpServletRequest theServletRequest, HttpServletResponse theServletResponse) throws IOException { if (!isValidateOperation(theRequestDetails)) { return true; } String contentType = theServletRequest.getContentType(); boolean isJson = contentType.contains("json"); boolean isXml = contentType.contains("xml"); if (!isJson && !isXml) { return true; } return processValidationRequest(theRequestDetails, theServletRequest, theServletResponse, isJson); } private boolean isValidateOperation(ServletRequestDetails theRequestDetails) { return theRequestDetails != null && theRequestDetails.getOperation() != null && theRequestDetails.getOperation().equals("$validate") && theRequestDetails.getRequestType() == RequestTypeEnum.POST; } private boolean processValidationRequest(ServletRequestDetails theRequestDetails, HttpServletRequest theServletRequest, HttpServletResponse theServletResponse, boolean isJson) throws IOException { ModifiableHttpServletRequest modifiableRequest = new ModifiableHttpServletRequest(theServletRequest); byte[] requestBody = modifiableRequest.getBody(); if (requestBody == null || requestBody.length == 0) { return true; } // Parse request and convert to JSON if needed String requestResourceJson = new String(requestBody, StandardCharsets.UTF_8); // Validate using external service ValidationResponseResult response = validateResource(requestResourceJson); if (response.getStatus() != 200) { handleValidationError(theRequestDetails, theServletResponse, response, isJson); return false; } // Process successful validation OperationOutcome outcome = processValidationResponse(theRequestDetails, response); // Send response sendResponse(theRequestDetails, theServletResponse, outcome, response.getStatus(), isJson); return false; } private OperationOutcome processValidationResponse(ServletRequestDetails theRequestDetails, ValidationResponseResult response) { IParser parser = theRequestDetails.getFhirContext().newJsonParser(); OperationOutcome outcome = (OperationOutcome) parser.parseResource(response.getBody()); // Add custom validation message outcome.addIssue( new OperationOutcome.OperationOutcomeIssueComponent() .setSeverity(OperationOutcome.IssueSeverity.INFORMATION) .setCode(OperationOutcome.IssueType.INFORMATIONAL) .setDiagnostics("Validated by inferno validator wrapper") ); Collections.swap(outcome.getIssue(), 0, outcome.getIssue().size() - 1); return outcome; } private void sendResponse(ServletRequestDetails requestDetails, HttpServletResponse theServletResponse, OperationOutcome outcome, int status, boolean isJson) throws IOException { if (hasErrors(requestDetails.getFhirContext(), outcome)) { theServletResponse.setStatus(422); } else { theServletResponse.setStatus(status); } IParser responseParser = getAppropriateParser(requestDetails, isJson); responseParser.setPrettyPrint(true); String outcomeJsonString = responseParser.encodeResourceToString(outcome); theServletResponse.setHeader("Content-Type", isJson ? JSON_CONTENT_TYPE : XML_CONTENT_TYPE); theServletResponse.setCharacterEncoding(StandardCharsets.UTF_8.name()); theServletResponse.getWriter().write(outcomeJsonString); theServletResponse.getWriter().flush(); } private String parseRequestToJson(ServletRequestDetails theRequestDetails, byte[] requestBody, boolean isJson) { IParser parser = getAppropriateParser(theRequestDetails, isJson); IBaseResource requestResource = parser.parseResource(new ByteArrayInputStream(requestBody)); return theRequestDetails.getFhirContext().newJsonParser() .setPrettyPrint(true) .encodeResourceToString(requestResource); } private ValidationResponseResult validateResource(String requestResourceJson) { ValidatorCaller validatorCaller = new ValidatorCaller(); return validatorCaller.sendJsonRequest(getInfernoUrl(), requestResourceJson); } private boolean hasErrors(FhirContext ctx, OperationOutcome outcome) { return OperationOutcomeUtil.hasIssuesOfSeverity(ctx, outcome, OperationOutcome.IssueSeverity.FATAL.toCode()) || OperationOutcomeUtil.hasIssuesOfSeverity(ctx, outcome, OperationOutcome.IssueSeverity.ERROR.toCode()); } private void handleValidationError(ServletRequestDetails theRequestDetails, HttpServletResponse theServletResponse, ValidationResponseResult response, boolean isJson) throws IOException { OperationOutcome errorOutcome = createErrorOutcome(response.getBody()); IParser parser = getAppropriateParser(theRequestDetails, isJson); theServletResponse.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); theServletResponse.setCharacterEncoding(StandardCharsets.UTF_8.name()); theServletResponse.setHeader("Content-Type", isJson ? JSON_CONTENT_TYPE : XML_CONTENT_TYPE); theServletResponse.getWriter().write(parser.encodeResourceToString(errorOutcome)); theServletResponse.getWriter().flush(); } private OperationOutcome createErrorOutcome(String errorMessage) { OperationOutcome errorOutcome = new OperationOutcome(); errorOutcome.addIssue( new OperationOutcome.OperationOutcomeIssueComponent() .setSeverity(OperationOutcome.IssueSeverity.ERROR) .setCode(OperationOutcome.IssueType.EXCEPTION) .setDiagnostics("Error validating request: " + errorMessage) ); return errorOutcome; } private IParser getAppropriateParser(ServletRequestDetails theRequestDetails, boolean isJson) { return isJson ? theRequestDetails.getFhirContext().newJsonParser() : theRequestDetails.getFhirContext().newXmlParser(); } String getInfernoUrl() { if (System.getenv("INFERNO_URL") != null) { return System.getenv("INFERNO_URL"); } else { return "http://127.0.0.1:4567/validate"; } } } ``` ### 編譯 - 程式碼都寫好後,就在 IDE 上點擊 compile 吧 ![do compile](https://hackmd.io/_uploads/SJhvjY0rke.png) ### 將 interceptor 放到 HAPI FHIR - 編譯後,你會在專案的 target 資料夾下找到 classes 資料夾 - 放置到你的 HAPI FHIR Server 的專案目錄,應該會長這樣 ```text!= ├── classes ├── docker-compose.yml ``` - 修改 docker-compose.yml,在 volumes 區段加入映射 classes ```yaml!= volumes: - ./classes:/app/extra-classes ``` - 修改 docker-compose.yml 的 environment 區段 (僅在你的 Inferno 網址需要更改時加入) ```yaml!= environment: - INFERNO_URL=http://127.0.0.1:4567/validate ``` - 修改 application.yaml ```yaml!= custom-bean-packages: org.cylab custom-interceptor-classes: org.cylab.ValidateOpCustomizer ``` ## 測試 - 啟動你的 HAPI FHIR - 呼叫 $validate 的 API - 如果回傳的 OperationOutcome 第一個 Issue 說明這是用 inferno validator 驗證的,就代表成功囉 ![first issue in operation outcome](https://hackmd.io/_uploads/B1Dy2FRS1x.png) ## Support Me 文件創作花費了很多心血製作,如果你覺得很有幫助 不妨贊助我一下喝杯咖啡唄,[Support Me](https://portaly.cc/Li070/support)