第一個 SMART app
火線超人的專案是 2025 年 11 月 12 日開的。
前三天一行 FHIR 都沒碰。做的是 LINE 登入、版面、部署設定。11 月 14 日才把專案改名叫火線超人。
11 月 15 日,它第一次讓 LINE 吐出一張真的病人卡片。使用者在對話框打 fhir patient,機器人回一張卡,上面有姓名、性別、生日、電話、地址。
那天之後回頭看,難的從來不是 FHIR 規格。難的是資料,它沒有我以為的那麼完整。
三個坑都在同一天
第一個坑是函式庫把 FHIR 資源包了一層。當時用的 FHIR client 版本會把每個 HTTP 回應塞進一個 ClientReply 物件。你要的 Patient 資源在那個物件裡面,不會直接交到你手上。所以得寫一支 extract_resource_from_reply() 專門拆包裝。
更麻煩的是驗證型別的時候。FHIR::Patient 跟 FHIR::R4::Patient 兩種都會出現,只好去比對 class 的 base name。
第二個坑是 LINE 直接回我錯誤。訊息是 must be non-empty text。原因很蠢也很真實。卡片上有個欄位是空的,而 LINE 的 Flex Message 不接受空字串。於是又寫了一支 format_value_for_display(),把空值換成「未提供」。
第三個坑最離譜。隨機撈一位病人這件事,聽起來就是一行查詢的事。實際上撈到的病人可能根本沒有名字。卡片一整張全是「未提供」,看起來就像程式壞了。
最後加了 has_valid_name? 判斷有沒有 family 或 given。再配上最多重試十次的邏輯,把沒名字的過濾掉。
三個坑,一天。這一篇要做的事情跟那天一樣:把資料弄到畫面上。所以三個坑今天都會換一種樣子再出現一次。

先說主線:從今天開始都交給 fhirclient
day06 到 day09 我們手刻了整條授權流程。自己組 authorize URL、自己算 PKCE、自己用 fetch 換 token。day12 換成 fhirclient,day14 講 refresh 時又回到 HTTP 層剖了一次。
從第三幕開始,所有 FHIR 請求都交給 fhirclient,不再手刻 fetch。
理由很實際。手刻的價值在於看懂每個參數為什麼存在,那件事第二幕做完了。接下來要處理的是分頁跟 token 到期自動換新,這兩件 fhirclient 已經做好。自己重寫一次只是把篇幅從臨床資料上挪走。請求失敗之後要不要重試不在這個範圍裡,fhirclient 不會替你決定,那是 app 自己的判斷。
後面只有兩處會再回到 HTTP 層,而且都會事先講明為什麼。day18 寫入時要看清楚 POST 跟 PUT 的差別。day20 講分頁時要把 Bundle 的 link 攤開來看。
day14 留下了什麼
第二幕結束時,專案是三個檔案:index.html、app.js、vendor/fhir-client.pure.min.js。
跑起來會授權、會拿到 token、會 refresh。畫面上有什麼?一行字:
Abdul Koepp,生日 1956-08-03
其他東西全在 console 裡:patient id、fhirUser、scope、token 尾八碼。一般使用者一輩子不會打開那個地方。
今天要做的就是把它們搬出來。順便生一個 patient.js,專門把 Patient 資源整理成能直接顯示的欄位。也就是姓名、性別、生日、病歷號這四個。
Patient 資源上幾乎沒有必填欄位
先看 client.patient.read() 回傳的 Patient 資源。這個方法讀的是 token 裡 patient context 指到的那個人。不用自己組網址,也不用自己帶 Authorization header。
回來的資源大概長這樣,這裡只留跟畫面有關的欄位:
{
"resourceType": "Patient",
"id": "018f428e-34f6-4707-8009-5ad742f901e7",
"name": [
{ "use": "official", "family": "Koepp", "given": ["Abdul"], "prefix": ["Mr."] }
],
"gender": "male",
"birthDate": "1956-08-03"
}
看起來很好取。patient.name[0].given[0] 加 patient.name[0].family 就有姓名了。
但這樣寫會在真實資料上炸掉。FHIR 規範裡 Patient 的欄位幾乎都是選填的。name 可以不存在,gender 可以不存在,連 birthDate 都可以不存在。規範上連 id 都是選填,只是從伺服器讀回來的那一份一定會帶著它。
而且 name 是陣列。一個人可以有好幾個名字,本名、曾用名、暱稱,用 use 欄位區分。given 也是陣列,一個人可以有好幾個 given name。中間名就放在裡面,陣列的順序就是顯示的順序。
取名字的四層 fallback
所以取姓名這件事得寫成一串 fallback:
export function displayName(patient) {
const names = patient.name ?? []
const official = names.find((one) => one.use === 'official') ?? names[0]
if (!official) return null
const text = official.text?.trim()
if (text) return text
const given = official.given?.join(' ') ?? ''
const family = official.family ?? ''
return `${given} ${family}`.trim() || null
}
四層 fallback 分成兩段,對應下面那張圖的兩個色塊。第一段是挑出一個 name 物件:一是找 use 標成 official 的那個,那才是正式名稱;二是找不到就拿陣列第一個。
第二段是從那個物件取出字串:三是有 text 就直接用;四是沒有 text 才把 given 用空白接起來、補上 family。兩段各自落空都回 null。
第二段那個順序不要反過來寫。text 在 FHIR 裡的定義是「整個名字應該怎麼顯示」。它不是拆不開的時候才拿來墊檔的備胎。先組 given 加 family 等於預設全世界都是名在前、姓在後。
拿一筆資料去跑就看得出來。family 是「王」、given 是「大明」、text 是「王大明」。先組合的版本會排成「大明 王」,先看 text 的版本得到「王大明」。
那個「先找 official」真的會用到,不是多寫的。我抓了兩百位病人來數。use 標成 official 的有兩百筆,標成 maiden 的還有 68 筆,那是婚前姓名。
換句話說,兩百位裡有 68 位帶著兩個名字。直接取 name[0] 在這批資料上碰巧不會出錯,因為 official 都排在前面。但 official 排在前面只是這批資料的排法,FHIR 沒有規定順序。
回 null 而不是回「未提供」是刻意的。這一層只負責取值,「沒有值要顯示成什麼」是畫面的事,不是資料層的事。在這裡就換成中文字串的話會出事。「未提供」那三個字會變成一筆看起來很正常的值。呼叫端再也分不出它是伺服器給的資料,還是這一層自己塞進去的預設文案。
我把九種輸入都跑過一次。前面六種都回得出一個名字,只有 family 或只有 text 的那兩種也回得出來。後面三種回 null。沒有一種會丟例外。

順帶一提,本系列用的這台 sandbox 病人資料很完整。我抓了兩百位,name 欄位缺漏的有零位。所以這段防禦你在這裡跑不出效果。這幾層 fallback 是為了火線超人在另一台伺服器上真的撞到的那個情況寫的。
跟著做:把 console 搬到畫面上
起點是 day14 結束時的專案,三個檔案:index.html、app.js、vendor/fhir-client.pure.min.js。跑得起來、授權過、console 印得出 token。
今天要新增一個 patient.js,改寫 index.html 跟 app.js。
第一步,新增 patient.js
const GENDER_LABEL = {
male: '男',
female: '女',
other: '其他',
unknown: '不明',
}
export function displayName(patient) {
const names = patient.name ?? []
const official = names.find((one) => one.use === 'official') ?? names[0]
if (!official) return null
const text = official.text?.trim()
if (text) return text
const given = official.given?.join(' ') ?? ''
const family = official.family ?? ''
return `${given} ${family}`.trim() || null
}
export function summarize(patient) {
return {
id: patient.id,
name: displayName(patient),
gender: GENDER_LABEL[patient.gender] ?? null,
birthDate: patient.birthDate ?? null,
}
}
gender 那個對照表也要留 fallback。FHIR 規定的值只有四個,但真實伺服器什麼都可能吐給你。查不到就回 null,讓畫面決定。
第二步,把畫面挖好
index.html 加三個位置:一是一行狀態文字,二是一顆連線按鈕,三是一個放欄位的 dl。
<body>
<h1>我的健康資料</h1>
<p id="status">載入中…</p>
<button id="connect" hidden>連線到 FHIR 伺服器</button>
<dl id="patient" hidden></dl>
<script src="vendor/fhir-client.pure.min.js"></script>
<script type="module" src="app.js"></script>
</body>
按鈕跟 dl 都先 hidden。授權完成前不該看到欄位,沒授權時才需要按鈕。
第三步,把資料放上去
app.js 有兩個地方要動。一是 ready() 那一行怎麼接失敗,二是拿到 client 之後怎麼把欄位放上去。
先看 ready() 那一行:
FHIR.oauth2.ready().then(showPatient, offerConnect)
then() 的第二個參數只接 ready() 自己的失敗,也就是還沒授權這件事。如果寫成 .then(showPatient).catch(offerConnect),那 showPatient() 裡面讀病人失敗也會掉進 offerConnect()。畫面會顯示「還沒授權,按下面的按鈕開始」,然後叫使用者去按一顆按鈕。而那顆按鈕在 showPatient() 第一行就被 remove() 掉了。讀取失敗跟沒授權是兩件事,接的地方要分開。
接著是放資料那一段:
import { summarize } from './patient.js'
async function showPatient(client) {
connectButton.remove()
status.textContent = '讀取中…'
try {
const patient = await client.patient.read()
const summary = summarize(patient)
details.replaceChildren(
...row('姓名', summary.name),
...row('性別', summary.gender),
...row('生日', summary.birthDate),
...row('病歷號', summary.id)
)
details.hidden = false
status.textContent = summary.name
? `${summary.name} 的基本資料`
: '這位病人沒有登記姓名'
} catch (error) {
status.textContent = '讀不到這位病人的資料'
console.error(error)
}
}
function row(label, value) {
const dt = document.createElement('dt')
dt.textContent = label
const dd = document.createElement('dd')
dd.textContent = value ?? '未提供'
return [dt, dd]
}
row() 就是火線超人那支 format_value_for_display() 的最小版本。空欄位不要留白格。白格看起來像畫面壞了,寫出「未提供」才知道這筆資料本來就沒有。
值一律用 textContent 寫進去,不要自己組 HTML 字串再塞給 innerHTML。姓名是伺服器給的資料,不是你自己打的字。萬一那串字裡面有 HTML 標籤,innerHTML 會把它當成畫面的一部分執行。textContent 只會把它當字顯示出來。
第四步,跑起來
先起靜態伺服器,再用瀏覽器打開頁面,最後按下按鈕走完授權。畫面與 console 應該長這樣。

同意畫面這次只有三行權限。我們的 scope 還是 day14 那組,只要 Patient 不要全部:
Read all data about the selected patient
Read Patient records
Read our profile information
第一次有一個畫面,是可以拿給沒看過 console 的人看的。
完整檔案
這一段是給中途接上或哪裡壞掉的人。把專案清成下面四個檔案就能跑,不必回頭補做 day05 到 day14。
index.html:
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<title>SMART App</title>
</head>
<body>
<h1>我的健康資料</h1>
<p id="status">載入中…</p>
<button id="connect" hidden>連線到 FHIR 伺服器</button>
<dl id="patient" hidden></dl>
<script src="vendor/fhir-client.pure.min.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>
patient.js:
// Patient 資源上的欄位幾乎都是選填的。
// 拿到資源不等於拿到資料,每一個要顯示的欄位都得先問「沒有的話怎麼辦」。
const GENDER_LABEL = {
male: '男',
female: '女',
other: '其他',
unknown: '不明',
}
// name 是陣列,一個人可以有好幾個名字:本名、曾用名、暱稱。
// use 標成 official 的那個才是正式名稱。都沒標就退而求其次拿第一個。
export function displayName(patient) {
const names = patient.name ?? []
const official = names.find((one) => one.use === 'official') ?? names[0]
if (!official) return null
// text 是「這個名字該怎麼顯示」的完整字串。有就直接用,
// 自己把 given 接上 family 會把中文姓名的語序排反。
const text = official.text?.trim()
if (text) return text
// 沒有 text 才自己組。given 是陣列,一個人可以有多個 given name。
const given = official.given?.join(' ') ?? ''
const family = official.family ?? ''
return `${given} ${family}`.trim() || null
}
// 回傳的每一欄都可能是 null,呈現層自己決定 null 要顯示成什麼。
// 在這裡就換成「未提供」的話,那三個字會變成一筆看起來正常的值,
// 呼叫端再也分不出它是伺服器給的資料還是這裡塞的預設文案。
export function summarize(patient) {
return {
id: patient.id,
name: displayName(patient),
gender: GENDER_LABEL[patient.gender] ?? null,
birthDate: patient.birthDate ?? null,
}
}
app.js。只有 FHIR_BASE_URL 那一行要換成自己的,其餘照貼:
import { summarize } from './patient.js'
// 換成自己的:到 SMART Health IT Launcher 選 Patient Standalone
// Launch,複製 Server's FHIR Base URL 欄位那一整串(含 /sim/ 的那個)。
export const FHIR_BASE_URL =
'https://launch.smarthealthit.org/v/r4/sim/WzMsIiIs…/fhir'
const CLIENT_ID = 'my-smart-app'
const SCOPE = 'launch/patient patient/Patient.r openid fhirUser offline_access'
const status = document.querySelector('#status')
const connectButton = document.querySelector('#connect')
const details = document.querySelector('#patient')
// 第二個參數只接 ready() 自己的失敗,也就是還沒授權。
// 寫成 .then(showPatient).catch(offerConnect) 的話,
// showPatient() 裡的讀取失敗也會掉進 offerConnect(),
// 畫面會叫使用者去按一顆已經被移除的按鈕。
FHIR.oauth2.ready().then(showPatient, offerConnect)
function offerConnect() {
status.textContent = '還沒授權,按下面的按鈕開始'
connectButton.hidden = false
connectButton.addEventListener('click', () => {
FHIR.oauth2.authorize({
iss: FHIR_BASE_URL,
clientId: CLIENT_ID,
scope: SCOPE,
redirectUri: window.location.pathname,
})
})
}
async function showPatient(client) {
connectButton.remove()
status.textContent = '讀取中…'
try {
// client.patient.read() 讀的是 token 裡那個 patient context 指到的人,
// 不必自己組網址,也不必自己帶 Authorization header。
const patient = await client.patient.read()
const summary = summarize(patient)
details.replaceChildren(
...row('姓名', summary.name),
...row('性別', summary.gender),
...row('生日', summary.birthDate),
...row('病歷號', summary.id)
)
details.hidden = false
status.textContent = summary.name
? `${summary.name} 的基本資料`
: '這位病人沒有登記姓名'
console.log('patient id:', client.patient.id)
console.log('scope:', client.state.tokenResponse.scope)
} catch (error) {
status.textContent = '讀不到這位病人的資料'
console.error(error)
}
}
// 欄位是空的時候不要留一個空格子。
// 空格子看起來像畫面壞了,寫出來才知道是這筆資料本來就沒有。
//
// 值一律用 textContent 寫進去,不要組 HTML 字串。
// 姓名是伺服器給的資料,裡面若含有標籤會被瀏覽器當成 HTML 執行。
function row(label, value) {
const dt = document.createElement('dt')
dt.textContent = label
const dd = document.createElement('dd')
dd.textContent = value ?? '未提供'
return [dt, dd]
}
vendor/fhir-client.pure.min.js 照 day04 那行 curl 抓。貼完記得先 sessionStorage.clear() 再重整,理由跟 day14 第四步一樣。
完整可跑的版本在 GitHub 上的 day15-first-smart-app,除了 FHIR_BASE_URL 那一行之外與上面逐字相同。
小結
第一個能給人看的畫面出來了,但它只有四個欄位,而且都是基本資料。姓名生日不是病人打開 app 想看的東西。
明天開始放臨床資料。第一個是生命徵象。把血壓跟體重從 Observation 裡挖出來,畫成一張看得出趨勢的圖。挖的過程你會發現,同樣是 Observation,值可能長在兩個完全不同的地方。