# HAPI FHIR 使用遠端 Validator 服務
## 前言
因為在實務上使用 HAPI FHIR 的 $validate API 時,遇到眾多問題,而使用原生官方的 validator 時,卻又無錯誤,所以決定將 HAPI FHIR 的 $validate 更改成呼叫遠端的驗證服務,並回傳 OperationOutcome

:::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 就代表成功囉!

## 利用 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 吧

### 將 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 驗證的,就代表成功囉

## Support Me
文件創作花費了很多心血製作,如果你覺得很有幫助
不妨贊助我一下喝杯咖啡唄,[Support Me](https://portaly.cc/Li070/support)