iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0

摘要
Day 1 只確認 JSON 與 FHIR Resource 能不能被解析。Day 2 往下一層前進:用 HAPI FHIR R4 Validator 執行 base validation,並把 OperationOutcome issue 顯示在頁面上。

Parser 成功,不代表 Validation 成功

Day 1 做的是 parsing layer:輸入是不是合法 JSON、能不能被 HAPI FHIR 讀成 R4 Resource,以及目前入口是不是只接受 Bundle

但 parser 只回答一件事:

這份資料能不能被讀懂?

它不等於:

這份資料是否符合 FHIR R4 規則?

例如一份 JSON 可以被解析成 Bundle,但少了 Bundle.type。這時 HAPI parser 仍可能把它讀成 Resource;真正指出「缺少必要欄位」的是 FHIR Validator。

所以 Day 2 的目標,是把 Day 1 的流程往下接一層:

Parser:資料能不能被讀懂
        ↓
FHIR R4 Validator:資料是否符合 FHIR base rule
        ↓
OperationOutcome:把驗證訊息結構化顯示

今天仍然不做 TW Core Profile validation,也不做交換契約規則。這兩件事之後會分開處理,避免把「FHIR base rule」、「TW Core Profile」與「合作方契約」混在同一層。

OperationOutcome 是什麼?

FHIR 的 OperationOutcome 是用來描述處理結果或錯誤的 Resource。它不是單純的錯誤字串,而是會把問題整理成一筆一筆 issue。

在 Day 2 的頁面中,先顯示三個最實用的欄位:

欄位 說明
severity 問題嚴重程度,例如 information、warning、error、fatal
location 問題大約發生在哪個 Resource 或欄位
diagnostics HAPI Validator 回傳的診斷文字

這樣做的好處是,後續不管是 FHIR R4、TW Core,或交換契約規則,都可以朝同一種「可讀、可測、可展示」的結果格式靠攏。

學習重點

  • HAPI FHIR:使用 FhirValidator 執行 FHIR R4 base validation。
  • OperationOutcome:把 validator issue 轉成頁面可以顯示的結果。
  • 錯誤分層:JSON parse、FHIR parse、Resource Type Gate 與 FHIR validation 分開呈現。
  • 狀態設計:只有 errorfatal 讓 FHIR validation 變成 FAILEDwarning 仍顯示,但不直接視為失敗。
  • MVP 邊界:Day 2 不載入 TW Core package,TW Core 層明確顯示 NOT_EVALUATED

核心流程

Day 2 的流程如下:

使用者貼上或上傳 JSON
        ↓
Jackson 檢查 JSON grammar
        ↓
JSON 合法?
  ├─ 否 → JSON parse FAILED,FHIR validation NOT_EVALUATED
  └─ 是
        ↓
HAPI FHIR R4 parser 解析 Resource
        ↓
FHIR Resource 可解析?
  ├─ 否 → FHIR R4 parse FAILED,FHIR validation NOT_EVALUATED
  └─ 是
        ↓
是 Bundle?
  ├─ 否 → Resource Type Gate FAILED,FHIR validation NOT_EVALUATED
  └─ 是
        ↓
HAPI FHIR R4 Validator
        ↓
整理 OperationOutcome issues
        ↓
顯示四層結果

這裡的重點不是讓所有資料都通過,而是讓每個失敗停在正確的層級。

實作

今天的最小架構比 Day 1 多了一個 validator:

             Browser
                |
          Multipart JSON
                |
        Spring Controller
                |
       BundleParseService
      /       |        \
 Jackson   HAPI      FHIR R4
 Parser    Parser    Validator
      \       |        /
       ValidationResult
                |
          Thymeleaf UI

Day 2 仍然先維持單一 BundleParseService,因為目前流程還很短。等 TW Core、Reference rule 與契約規則加入後,再把 validator 或 rule engine 拆成更明確的 service。

專案目錄新增或調整如下:

src/main/java/com/twlab/qualitygate
├─ config
│  └─ FhirConfig.java
├─ validation
│  ├─ BundleParseService.java
│  ├─ OperationOutcomeIssue.java
│  ├─ ParseStatus.java
│  └─ ValidationResult.java
└─ web
   └─ ParseController.java

src/main/resources/templates
└─ index.html
  1. 加入 HAPI validation dependency

Day 1 只需要 parser 與 R4 structure。Day 2 要跑 validator,所以加入 HAPI validation module 與 R4 validation resources。

<dependency>
  <groupId>ca.uhn.hapi.fhir</groupId>
  <artifactId>hapi-fhir-validation</artifactId>
  <version>${hapi-fhir.version}</version>
</dependency>

<dependency>
  <groupId>ca.uhn.hapi.fhir</groupId>
  <artifactId>hapi-fhir-validation-resources-r4</artifactId>
  <version>${hapi-fhir.version}</version>
</dependency>

其中 hapi-fhir-validation-resources-r4 很重要。如果只加入 validator module,validator 可能找不到 R4 base StructureDefinition,結果連 Bundle 都被當成 unknown resource。

  1. 建立 FhirValidator Bean

FhirContext 仍然維持 Spring singleton。今天另外建立 FhirValidator,並註冊 FhirInstanceValidator

@Bean
public FhirValidator fhirValidator(FhirContext fhirContext) {
  FhirInstanceValidator instanceValidator = new FhirInstanceValidator(
      new DefaultProfileValidationSupport(fhirContext)
  );
  instanceValidator.setErrorForUnknownProfiles(false);
  instanceValidator.setNoTerminologyChecks(true);

  FhirValidator validator = fhirContext.newValidator();
  validator.registerValidatorModule(instanceValidator);
  return validator;
}

這裡先關閉 terminology checks,因為 Day 2 的範圍是 FHIR R4 base validation,不是 LOINC、UCUM 或 TW Core terminology binding。之後做 TW Core 時,會另外處理 package 與 terminology 問題。

  1. 擴充結果模型

Day 1 的 ValidationResult 只有 parse 狀態與基本錯誤訊息。Day 2 新增 fhirValidationStatusoperationOutcomeIssues

public record ValidationResult(
    ParseStatus jsonStatus,
    ParseStatus fhirR4Status,
    ParseStatus resourceTypeStatus,
    ParseStatus fhirValidationStatus,
    List<OperationOutcomeIssue> operationOutcomeIssues,
    Integer resourceCount,
    String resourceType,
    String errorMessage
) {}

OperationOutcomeIssue 是頁面顯示用的簡化 DTO:

public record OperationOutcomeIssue(
    String severity,
    String location,
    String diagnostics
) {}

沒有直接把 HAPI 的 SingleValidationMessage 傳到 Thymeleaf,是為了讓 UI 不依賴 HAPI class。後續若 TW Core 或自訂契約規則也要顯示問題,可以轉成類似格式。

  1. 在 Bundle 通過 parser 後執行 validation

核心邏輯是:只有在 JSON 合法、FHIR parser 成功,而且 Resource 是 Bundle 時,才進入 FHIR R4 validation。

IBaseResource resource = fhirContext.newJsonParser().parseResource(bundleJson);
if (!(resource instanceof Bundle bundle)) {
  return new ValidationResult(
      ParseStatus.PASSED,
      ParseStatus.PASSED,
      ParseStatus.FAILED,
      ParseStatus.NOT_EVALUATED,
      List.of(),
      null,
      resourceType,
      "FHIR R4 parse succeeded, but resourceType is not Bundle."
  );
}

ca.uhn.fhir.validation.ValidationResult validationResult =
    fhirValidator.validateWithResult(bundle);

List<OperationOutcomeIssue> issues = validationResult.getMessages().stream()
    .map(this::toIssue)
    .toList();

return new ValidationResult(
    ParseStatus.PASSED,
    ParseStatus.PASSED,
    ParseStatus.PASSED,
    hasErrors(validationResult.getMessages()) ? ParseStatus.FAILED : ParseStatus.PASSED,
    issues,
    bundle.getEntry().size(),
    "Bundle",
    null
);

hasErrors() 只把 errorfatal 視為失敗:

private boolean hasErrors(List<SingleValidationMessage> messages) {
  return messages.stream()
      .map(SingleValidationMessage::getSeverity)
      .anyMatch(severity -> severity == ResultSeverityEnum.ERROR
          || severity == ResultSeverityEnum.FATAL);
}

這樣可以保留 HAPI 的 warning,但不會讓 best practice warning 直接擋住 Day 2 的 base validation 結果。

  1. 更新頁面

畫面新增 FHIR R4 validationOperationOutcome issues 區塊:

<dt>FHIR R4 validation</dt>
<dd>
  <span class="status"
        th:classappend="${result.fhirValidationStatus.name() == 'PASSED'} ? ' passed' : (${result.fhirValidationStatus.name() == 'FAILED'} ? ' failed' : '')"
        th:text="${result.fhirValidationStatus}">PASSED</span>
</dd>

OperationOutcome 先用表格呈現:

<table th:if="${!#lists.isEmpty(result.operationOutcomeIssues)}">
  <thead>
  <tr>
    <th>Severity</th>
    <th>Location</th>
    <th>Diagnostics</th>
  </tr>
  </thead>
  <tbody>
  <tr th:each="issue : ${result.operationOutcomeIssues}">
    <td th:text="${issue.severity}">error</td>
    <td th:text="${issue.location}">Bundle.type</td>
    <td th:text="${issue.diagnostics}">Required element is missing.</td>
  </tr>
  </tbody>
</table>

TW Core 層目前明確寫成:

NOT_EVALUATED
Day 2 尚未載入 tw.gov.mohw.twcore#1.0.0。

這比先假裝通過更重要。因為本系列的核心就是避免把「沒有檢查」誤寫成「檢查通過」。

執行結果

Day 2 最重要的展示,是同一套頁面能清楚區分不同層級的結果。

  1. 合法 Bundle

https://ithelp.ithome.com.tw/upload/images/20260803/20177913YLQW5DdTjL.png

使用首頁預設 sample,按下 Parse Bundle。結果會看到:

JSON parse: PASSED
FHIR R4 parse: PASSED
Resource Type Gate: PASSED
FHIR R4 validation: PASSED
TW Core validation: NOT_EVALUATED

https://ithelp.ithome.com.tw/upload/images/20260803/20177913K5TYBLnq75.png

這裡即使有 warning,也會在 OperationOutcome 區塊顯示;但只要沒有 errorfatal,FHIR R4 validation 狀態仍視為 PASSED

  1. 缺少 Bundle.type

貼上這份資料:

{
  "resourceType": "Bundle"
}

這份資料可以被 parser 讀成 Bundle,所以:

JSON parse: PASSED
FHIR R4 parse: PASSED
Resource Type Gate: PASSED

但它缺少 FHIR R4 base rule 要求的 Bundle.type,所以:

FHIR R4 validation: FAILED

OperationOutcome issues 會顯示問題位置與診斷文字。

https://ithelp.ithome.com.tw/upload/images/20260803/20177913haHJV6LbdC.png

這就是 Day 2 要證明的重點:parse 成功,不代表 validation 成功。

  1. 非法 JSON

貼上這份資料:

{ not-json

這會停在 JSON parse layer:

JSON parse: FAILED
FHIR R4 parse: FAILED
Resource Type Gate: FAILED
FHIR R4 validation: NOT_EVALUATED

https://ithelp.ithome.com.tw/upload/images/20260803/20177913xU1iS2lZ6I.png

這個結果也很重要。非法 JSON 不是 FHIR validation error,它甚至還沒有進入 FHIR parser。系統應該清楚告訴使用者錯在哪一層,而不是全部包成一個籠統的「驗證失敗」。

Day 1 與 Day 2 的差異

Day 1 的結果表大概是這樣:

情境 JSON FHIR Parser Resource Type Gate
合法 Bundle PASS PASS PASS
JSON 壞掉 FAIL - -
Patient Resource PASS PASS FAIL
普通 JSON PASS FAIL -

Day 2 新增 FHIR R4 validation 後,變成:

情境 JSON FHIR Parser Resource Type Gate FHIR R4 Validation
合法 Bundle PASS PASS PASS PASS
缺少 Bundle.type PASS PASS PASS FAIL
JSON 壞掉 FAIL - - NOT_EVALUATED
Patient Resource PASS PASS FAIL NOT_EVALUATED

這張表也會成為後續分層驗證頁面的基礎。等 TW Core 加進來後,會再多一欄 TW Core validation;等交換契約規則加進來後,會再多一欄 Exchange Contract

測試

Day 2 補了 service 與 controller 測試,確保新增 validator 後沒有破壞 Day 1 行為。

測試重點:

  • parsesBundleJson():合法 Bundle 會通過 JSON parse、FHIR R4 parse、Resource Type Gate 與 FHIR R4 validation。
  • reportsFhirValidationIssuesForInvalidBundle():缺少 Bundle.type 的 Bundle 會回傳 FHIR validation issue。
  • reportsInvalidJsonWithoutThrowing():非法 JSON 不會造成系統 500,且 FHIR validation 是 NOT_EVALUATED
  • reportsNonBundleFhirResource()Patient 可被 parser 讀懂,但 Resource Type Gate 失敗,FHIR validation 不執行。
  • Controller 測試確認頁面能顯示 Day 2 標題、OperationOutcome 區塊與 validation failed 訊息。

指令:

./mvnw test

測試結果:

Tests run: 10, Failures: 0, Errors: 0, Skipped: 0

常見錯誤 & 排查

  1. 只加入 hapi-fhir-validation,沒有加入 R4 validation resources

這會讓 validator 缺少 FHIR R4 base StructureDefinition。實作時曾遇到 validator 回傳 unknown resource 的問題,補上 hapi-fhir-validation-resources-r4 後才正常。

  1. 用 Live Server 直接開 Thymeleaf template

src/main/resources/templates/index.html 不能直接用 Live Server 當成最終畫面測試。th:textth:ifth:each 都需要由 Spring Boot + Thymeleaf 渲染。

正確測試入口是:

http://localhost:8080/
  1. Parser 成功但 validation 失敗

這是正常現象。Day 2 正是在展示這件事。不要把 parser success 寫成 validation success。

  1. Warning 是否等於失敗

HAPI 可能回傳 best practice warning,例如建議 Resource 應該有 narrative。Day 2 先顯示 warning,但只有 errorfatal 會讓 FHIR R4 validation 狀態變成 FAILED

  1. urn:uuid:patient-1 不是合法 UUID

Day 1 sample 可以被 parser 接受,但 Day 2 base validation 會檢查 UUID 格式。因此 sample 改成:

urn:uuid:123e4567-e89b-12d3-a456-426614174000

今天完成了什麼

  • 加入 HAPI FHIR R4 Validator。
  • 加入 R4 validation resources。
  • 建立 Spring FhirValidator bean。
  • 擴充 ValidationResult 狀態模型。
  • 新增 OperationOutcomeIssue DTO。
  • 在 Bundle parser 成功後執行 FHIR R4 base validation。
  • 在頁面顯示 FHIR R4 validation 狀態。
  • 在頁面顯示 OperationOutcome issue 的 severity、location、diagnostics。
  • 明確將 TW Core 標示為 NOT_EVALUATED
  • 補上 Day 2 對應測試。

Day 2 尚未處理:

  • TW Core package tw.gov.mohw.twcore#1.0.0 載入。
  • TW Core Patient、Observation、DiagnosticReport Profile validation。
  • terminology binding,例如 LOINC、UCUM。
  • Bundle 內 Reference integrity。
  • 合作方交換契約規則。
  • Quality Gate 的 Passed / Warning / Blocked 判定。

後續分層大概會變成:

quality-gate
├─ parser
├─ fhir-r4-validator
├─ tw-core-validator
├─ reference-checker
├─ contract-rule
└─ report

Repository:twcore-data-quality-gate


上一篇
Day1 - 建立 Spring Boot + HAPI FHIR Bundle Parsing Layer
下一篇
Day3 - 盤點 Bundle 裡有哪些 Resource
系列文
醫療資料通過標準驗證,就真的能交換嗎?——30 天打造 TW Core 資料品質閘門3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言