摘要
Day 1 只確認 JSON 與 FHIR Resource 能不能被解析。Day 2 往下一層前進:用 HAPI FHIR R4 Validator 執行 base validation,並把 OperationOutcome issue 顯示在頁面上。
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」與「合作方契約」混在同一層。
FHIR 的 OperationOutcome 是用來描述處理結果或錯誤的 Resource。它不是單純的錯誤字串,而是會把問題整理成一筆一筆 issue。
在 Day 2 的頁面中,先顯示三個最實用的欄位:
| 欄位 | 說明 |
|---|---|
| severity | 問題嚴重程度,例如 information、warning、error、fatal |
| location | 問題大約發生在哪個 Resource 或欄位 |
| diagnostics | HAPI Validator 回傳的診斷文字 |
這樣做的好處是,後續不管是 FHIR R4、TW Core,或交換契約規則,都可以朝同一種「可讀、可測、可展示」的結果格式靠攏。
FhirValidator 執行 FHIR R4 base validation。error 與 fatal 讓 FHIR validation 變成 FAILED;warning 仍顯示,但不直接視為失敗。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
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。
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 問題。
Day 1 的 ValidationResult 只有 parse 狀態與基本錯誤訊息。Day 2 新增 fhirValidationStatus 與 operationOutcomeIssues。
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 或自訂契約規則也要顯示問題,可以轉成類似格式。
核心邏輯是:只有在 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() 只把 error 與 fatal 視為失敗:
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 結果。
畫面新增 FHIR R4 validation 與 OperationOutcome 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 最重要的展示,是同一套頁面能清楚區分不同層級的結果。

使用首頁預設 sample,按下 Parse Bundle。結果會看到:
JSON parse: PASSED
FHIR R4 parse: PASSED
Resource Type Gate: PASSED
FHIR R4 validation: PASSED
TW Core validation: NOT_EVALUATED

這裡即使有 warning,也會在 OperationOutcome 區塊顯示;但只要沒有 error 或 fatal,FHIR R4 validation 狀態仍視為 PASSED。
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 會顯示問題位置與診斷文字。

這就是 Day 2 要證明的重點:parse 成功,不代表 validation 成功。
貼上這份資料:
{ not-json
這會停在 JSON parse layer:
JSON parse: FAILED
FHIR R4 parse: FAILED
Resource Type Gate: FAILED
FHIR R4 validation: NOT_EVALUATED

這個結果也很重要。非法 JSON 不是 FHIR validation error,它甚至還沒有進入 FHIR parser。系統應該清楚告訴使用者錯在哪一層,而不是全部包成一個籠統的「驗證失敗」。
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 不執行。指令:
./mvnw test
測試結果:
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
hapi-fhir-validation,沒有加入 R4 validation resources這會讓 validator 缺少 FHIR R4 base StructureDefinition。實作時曾遇到 validator 回傳 unknown resource 的問題,補上 hapi-fhir-validation-resources-r4 後才正常。
src/main/resources/templates/index.html 不能直接用 Live Server 當成最終畫面測試。th:text、th:if、th:each 都需要由 Spring Boot + Thymeleaf 渲染。
正確測試入口是:
http://localhost:8080/
這是正常現象。Day 2 正是在展示這件事。不要把 parser success 寫成 validation success。
HAPI 可能回傳 best practice warning,例如建議 Resource 應該有 narrative。Day 2 先顯示 warning,但只有 error 與 fatal 會讓 FHIR R4 validation 狀態變成 FAILED。
urn:uuid:patient-1 不是合法 UUIDDay 1 sample 可以被 parser 接受,但 Day 2 base validation 會檢查 UUID 格式。因此 sample 改成:
urn:uuid:123e4567-e89b-12d3-a456-426614174000
FhirValidator bean。ValidationResult 狀態模型。OperationOutcomeIssue DTO。NOT_EVALUATED。Day 2 尚未處理:
tw.gov.mohw.twcore#1.0.0 載入。後續分層大概會變成:
quality-gate
├─ parser
├─ fhir-r4-validator
├─ tw-core-validator
├─ reference-checker
├─ contract-rule
└─ report
Repository:twcore-data-quality-gate