iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0

本文同步發表於個人部落格:第一個 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::PatientFHIR::R4::Patient 兩種都會出現,只好去比對 class 的 base name。

第二個坑是 LINE 直接回我錯誤。訊息是 must be non-empty text。原因很蠢也很真實。卡片上有個欄位是空的,而 LINE 的 Flex Message 不接受空字串。於是又寫了一支 format_value_for_display(),把空值換成「未提供」。

第三個坑最離譜。隨機撈一位病人這件事,聽起來就是一行查詢的事。實際上撈到的病人可能根本沒有名字。卡片一整張全是「未提供」,看起來就像程式壞了。

最後加了 has_valid_name? 判斷有沒有 family 或 given。再配上最多重試十次的邏輯,把沒名字的過濾掉。

三個坑,一天。這一篇要做的事情跟那天一樣:把資料弄到畫面上。所以三個坑今天都會換一種樣子再出現一次。

火線超人三個坑與本篇程式碼的對應圖,標題「火線超人的三個坑,對上今天的三處程式碼」,副標「左邊是 2025-11-15 那天撞到的,右邊是這篇程式碼裡的同一件事」。米色底,左右兩欄各三列,中間三條淺褐色箭頭由左指向右。左欄標頭「火線超人,Rails,2025-11-15」,三個深藍色大色塊由上而下排列,每一塊的標題前有白色圓形編號。第一塊編號 1,標題「函式庫把資源包了一層」,下方灰字寫每個 HTTP 回應被裝進 ClientReply 物件,再下方是較淺的藍色小方塊,白字寫 extract_resource_from_reply(),灰字註明自己拆包裝,還要比對 class base name。第二塊編號 2,標題「空欄位讓 LINE 回錯」,灰字寫 Flex Message 回 must be non-empty text,小方塊白字寫 format_value_for_display(),灰字註明空值換成「未提供」。第三塊編號 3,標題「隨機撈到的病人沒有名字」,灰字寫卡片整張都是「未提供」,看起來像程式壞了,小方塊白字寫 has_valid_name?,灰字註明沒有 family 或 given 就重撈,最多 10 次。右欄標頭「這篇的 patient.js 與 app.js」,三張白色卡片,各自與左邊同一列的色塊以箭頭相連。第一張深藍粗體寫 summarize(patient),下方灰字說 client.patient.read() 回來的資源要先整理成能顯示的四個欄位。第二張寫 row(label, value),下方灰字說 null 換成「未提供」,不留一個空格子,其中「未提供」以珊瑚色粗體標示。第三張寫 displayName(),下方灰字說四層都取不到名字,最後 return null,其中 return null 以珊瑚色粗體標示。圖片下方一行註記:三個坑出自火線超人 2025-11-15 同一天的三個 commit。右邊是本篇的 JavaScript,不是同一份專案

先說主線:從今天開始都交給 fhirclient

day06 到 day09 我們手刻了整條授權流程。自己組 authorize URL、自己算 PKCE、自己用 fetch 換 token。day12 換成 fhirclient,day14 講 refresh 時又回到 HTTP 層剖了一次。

從第三幕開始,所有 FHIR 請求都交給 fhirclient,不再手刻 fetch

理由很實際。手刻的價值在於看懂每個參數為什麼存在,那件事第二幕做完了。接下來要處理的是分頁跟 token 到期自動換新,這兩件 fhirclient 已經做好。自己重寫一次只是把篇幅從臨床資料上挪走。請求失敗之後要不要重試不在這個範圍裡,fhirclient 不會替你決定,那是 app 自己的判斷。

後面只有兩處會再回到 HTTP 層,而且都會事先講明為什麼。day18 寫入時要看清楚 POSTPUT 的差別。day20 講分頁時要把 Bundle 的 link 攤開來看。

day14 留下了什麼

第二幕結束時,專案是三個檔案:index.htmlapp.jsvendor/fhir-client.pure.min.js

跑起來會授權、會拿到 token、會 refresh。畫面上有什麼?一行字:

Abdul Koepp,生日 1956-08-03

其他東西全在 console 裡:patient id、fhirUserscope、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 裡的定義是「整個名字應該怎麼顯示」。它不是拆不開的時候才拿來墊檔的備胎。先組 givenfamily 等於預設全世界都是名在前、姓在後。

拿一筆資料去跑就看得出來。family 是「王」、given 是「大明」、text 是「王大明」。先組合的版本會排成「大明 王」,先看 text 的版本得到「王大明」。

那個「先找 official」真的會用到,不是多寫的。我抓了兩百位病人來數。use 標成 official 的有兩百筆,標成 maiden 的還有 68 筆,那是婚前姓名。

換句話說,兩百位裡有 68 位帶著兩個名字。直接取 name[0] 在這批資料上碰巧不會出錯,因為 official 都排在前面。但 official 排在前面只是這批資料的排法,FHIR 沒有規定順序。

null 而不是回「未提供」是刻意的。這一層只負責取值,「沒有值要顯示成什麼」是畫面的事,不是資料層的事。在這裡就換成中文字串的話會出事。「未提供」那三個字會變成一筆看起來很正常的值。呼叫端再也分不出它是伺服器給的資料,還是這一層自己塞進去的預設文案。

我把九種輸入都跑過一次。前面六種都回得出一個名字,只有 family 或只有 text 的那兩種也回得出來。後面三種回 null。沒有一種會丟例外。

取姓名 fallback 的結構圖,標題「取一個姓名,四層 fallback」,副標「先挑一個 name 物件,再從它取出字串」。米色底,上半部左右並排兩個深藍色大色塊,中間一條淺褐色箭頭由左指向右。左邊色塊的標題是白色圓形編號 1 加「挑一個 name 物件」,裡面三列:第一列淺色底寫 use === 'official',右側灰字註明「正式名稱」;第二列寫的是取 names 陣列第一項,右側註明「沒標就拿第一個」;第三列是珊瑚色底,左邊寫「兩個都沒有」,右邊以珊瑚色寫 null。右邊色塊的標題是圓形編號 2 加「從它取出字串」,裡面同樣三列:第一列寫 official.text,右側註明「完整顯示字串」;第二列寫 ${given} ${family},右側註明「沒有 text 才自己組」;第三列珊瑚色底,左邊寫「兩個都是空的」,右邊以珊瑚色寫 null。下半部是九種輸入的實際回傳,分成左右兩欄。左欄標題「九種輸入,六種取到值」,六張白色卡片,左邊是輸入、右邊是深藍色的回傳值:use 是 official 回 Abdul Koepp;official 不是第一個回 Da Ming Wang;沒有 use 標記回 Yi Chen;只有 family 回 Lin;只有 text 回無名氏;text 與結構化欄位都有回王大明。右欄標題「另外三種,一路退到 null」,三張米灰色卡片,回傳值都是珊瑚色的 null,輸入分別是 name 是空陣列、完全沒有 name 欄位、name 陣列裡是空物件。圖片下方一行註記:九種輸入以 node 直接呼叫 displayName() 逐一驗過,不是 sandbox 撈回來的病人

順帶一提,本系列用的這台 sandbox 病人資料很完整。我抓了兩百位,name 欄位缺漏的有零位。所以這段防禦你在這裡跑不出效果。這幾層 fallback 是為了火線超人在另一台伺服器上真的撞到的那個情況寫的。

跟著做:把 console 搬到畫面上

起點是 day14 結束時的專案,三個檔案:index.htmlapp.jsvendor/fhir-client.pure.min.js。跑得起來、授權過、console 印得出 token。

今天要新增一個 patient.js,改寫 index.htmlapp.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 應該長這樣。

瀏覽器視窗的示意畫面,標題「第一個 SMART app 跑起來的樣子」,副標「四個欄位在畫面上,Console 只剩兩行」。淺綠灰底,中央是一個白色的瀏覽器視窗。畫面上有三個綠色圓形編號,分別掛在視窗右上角、性別那一列的左側、Console 標題列的左側,與圖片下方三條註記一一對應。視窗最上方是網址列,顯示 http://localhost:5174/,右上角是編號 1。頁面內容依序是粗體標題「我的健康資料」,接著一塊淺綠底、左緣有深綠直線的區塊,寫著「Abdul Koepp 的基本資料」。再往下是四列欄位,左邊灰色標籤、右邊值:姓名 Abdul Koepp、性別 男、生日 1956-08-03、病歷號 018f428e-34f6-4707-8009-5ad742f901e7。性別那一列的值以珊瑚色粗體標示,左緣是編號 2。下方灰底一條 Console 標題列,左緣是編號 3,再往下兩行輸出:patient id: 018f428e-34f6-4707-8009-5ad742f901e7,以及 scope: launch/patient patient/Patient.r openid fhirUser offline_access。圖片下方三條編號註記:埠號 5174 是實跑時起的靜態伺服器,換一個埠不影響;性別欄的 male 由 GENDER_LABEL 換成「男」,生日與病歷號照原樣;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,值可能長在兩個完全不同的地方。


上一篇
Day14 - Token 的生命週期
下一篇
Day16 - 呈現臨床資料(一)
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言