iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

本文同步發表於個人部落格:寫回 FHIR


火線超人的居家回報表單,送出去的是一個 transaction Bundle。

理由很簡單。一次量測可能同時有收縮壓、舒張壓、脈搏三個值。在 FHIR 裡它們是三筆獨立的 Observation。三筆要嘛全部寫進去,要嘛一筆都不要寫。寫到一半失敗,資料庫裡留下半組資料,比什麼都沒寫還糟。

今天先從單筆開始。Bundle 的時機文章最後會講。

先改一個字母

到 day17 為止的 scope 是 patient/*.rs,read 加 search,全部唯讀。

要寫入就加一個 c

const SCOPE = 'launch/patient patient/*.crs openid fhirUser offline_access'

一個字母,同意畫面就多一行。

scope 與同意畫面的對照圖,標題「加一個 c,同意畫面多一行」,副標「同一個 app,只動了 scope 裡的一個字母」。副標下方一行灰色等寬字寫著完整 scope 字串 launch/patient patient/.crs openid fhirUser offline_access,其中 patient/.crs 那一段是深藍色粗體。再下方一排四個色塊由左而右排開:淺藍底深灰字的 patient/、一個灰色的點、珊瑚色底白字的大寫 c、深藍底白字的 rs。珊瑚色的 c 底下用珊瑚色標「這次加的」,深藍色的 rs 底下用灰字標「day17 就有」。同一排的最右側靠右對齊兩行對照,day17 是 patient/.rs,day18 是深藍色粗體的 patient/*.crs。珊瑚色的 c 與深藍色的 rs 各往下拉一條垂直短線,接到底下那張白色卡片的上緣,c 那條是珊瑚色,rs 那條是灰色。白色卡片的標題列寫「Authorize App Launch,使用者實際看到的」,底下先是兩行淺灰字 The application will be able to access data until you revoke permission (offline access). 與 This application is requesting permission to:,接著是五行權限:沒有標記的 Read all data about the selected patient、灰色方形 r 標記的 Read * records、沒有標記的 Read our profile information、灰色方形 s 標記的 Search for * records,以及珊瑚色方形 c 標記且整行都是珊瑚色的 Create new * records。卡片右側兩條註記,前四行旁邊是灰字「這四行 day17 就在了」,最後一行旁邊是珊瑚色的「第五行是這次多的」。圖片下方兩行說明:多出來的只有一行,給出去的卻是整個 *,不是某一種資源;最上面那句 until you revoke permission 是 offline_access 換來的,畫面自己把它標在括號裡

多出來的那行使用者看得懂:這個 app 要在你的紀錄裡新增東西。day10 講 scope 語法時,那些字母對應到什麼還很抽象。現在它變成同意畫面上一句白話。

重新授權才會生效。 舊的 session 還帶著舊 scope,記得先 sessionStorage.clear() 再重整。否則你會拿到一個沒有寫入權限的 token。這台寬鬆,不會因此擋你,但真實 EHR 會回 403。

要寫的那筆資源

示範資源是病人自己在家量的血壓。

選這個不是隨便挑的。day16 剛畫完血壓趨勢圖,今天寫進去的那筆會出現在同一張圖的最後一個點上。寫入的效果當場看得見,不必另外做驗證畫面。

而且權限說得通。patient/Observation.c 這種寫入 scope 在真實世界最站得住腳的用途,就是病人回報自己的量測值。醫院的檢驗結果不會讓一個第三方 app 寫。

關鍵欄位是 performer

subject: { reference: `Patient/${patientId}` },
performer: [{ reference: `Patient/${patientId}` }],

subject 是「這筆資料在講誰」,performer 是「誰量的」。診間量的血壓,performer 會指向護理師或設備。這一筆兩個都指向病人自己,因為就是他在家量的。

這個區分不是形式。之後醫師看到這筆資料,能不能分辨它是診間量的還是病人自填的,差別就在這裡。

category 標成 vital-signscode 用跟 day16 一模一樣的 55284-4,這樣它才會被同一個查詢撈到。

缺一半也要送得出去

血壓計有時候只讀到收縮壓。這種情況下該送什麼?

不要送 0。血壓 0 在臨床上是死亡,不是「沒量到」。

正確做法是那個 component 根本不要放進去:

const component = []
if (Number.isFinite(systolic)) {
  component.push(pressureComponent('8480-6', 'Systolic Blood Pressure', systolic))
}
if (Number.isFinite(diastolic)) {
  component.push(pressureComponent('8462-4', 'Diastolic Blood Pressure', diastolic))
}

Number.isFinite 而不是 if (systolic),因為後者會把 0 也擋掉,而 0 在別的量測項目上是合法值。

寫完之後回頭看 day16 那個 componentValue(),它找不到目標代碼就回 null。兩邊剛好對上:這裡不送,那裡讀到 null,圖上那個點就是空的。缺值從頭到尾沒有被假造成 0。

POST 回來的東西,有一半你看不到

先用原生 fetch 送一次,看清楚 HTTP 層發生什麼事:

const response = await fetch(`${client.state.serverUrl}/Observation`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/fhir+json',
    Authorization: `Bearer ${client.state.tokenResponse.access_token}`,
  },
  body: JSON.stringify(resource),
})

POST/Observation 這個路徑,不帶 id。id 是伺服器指派的,你不能自己決定。

這台指派的 id 是 4828432 這種數字字串。sandbox 裡既有的 Synthea 資料則是 8cdb8640-3e65-… 那種 UUID。同一台上兩種形態並存,不要對 id 格式做假設。

成功回 201 Created。標準做法是從回應的 Location header 拿新資源的位址,ETag 拿版本號。

用 curl 送同一個請求,這兩個 header 都在:

HTTP/1.1 201 Created
Location: https://r4.smarthealthit.org/Observation/4828415/_history/1
ETag: W/"1"

在瀏覽器裡,這兩行你讀不到。 console 印出來是這樣:

HTTP 201
Location:
ETag:
讀得到的 header: ["content-length", "content-type"]
新資源 id: 4828432

兩個空字串。原因是 CORS,跨來源請求的回應 JavaScript 預設只讀得到少數幾個。

curl 與瀏覽器讀取回應 header 的對照圖,標題「同一個回應,curl 讀得到,瀏覽器讀不到」,副標「curl 送與瀏覽器 fetch 送,回應都是 201」。上方置中一塊深藍色圓角橫條,等寬白字寫著 POST /Observation,右側一個半透明小標籤寫 HTTP 201。橫條底下分出兩條線,左邊那條灰色線往左下轉彎接到左側卡片的上緣,右邊那條珊瑚色線往右下轉彎接到右側卡片的上緣,兩條各走一條通道不交會。左邊白色卡片標題 curl,副標「終端機直接送」,卡片內兩列,各有一個綠色圓形勾選標記,分別是 Location 與 Etag,兩列右側都標「看得到」;卡片底部隔一條虛線寫著灰字「終端機不受同源政策管,伺服器送什麼就看到什麼」。右邊白色卡片標題「瀏覽器的 fetch」,副標「同一個請求,跨來源送出」,卡片內兩列,各有一個灰色圓形叉號標記,分別是 location 與 etag,兩列右側都用珊瑚色標「空字串」;卡片下方一塊淺珊瑚底的方框,上行寫把 response.headers.keys() 展開之後只有,下行是珊瑚色等寬字,內容是含 content-length 與 content-type 兩個字串的陣列。兩張卡片底下是橫跨整張圖的深藍色色塊,左端一個米色標籤寫 CORS,中間白色等寬字寫 Access-Control-Expose-Headers,其下一行灰字「伺服器要另外送這個 header,其餘的才放行給 JavaScript」,最右端用珊瑚色寫「這台可讀 2 個」。圖片下方一行說明:差別不在伺服器少送了什麼,在瀏覽器願意交給 JavaScript 看的只有那兩個

所以在瀏覽器裡跑的 SMART app,新資源的 id 只能從回應 body 的 id。這不是比較好的做法,是唯一可行的做法。

連帶的影響是 ETag 拿不到。樂觀鎖那套(送 If-Match 避免覆蓋別人的修改)在純前端做不了,除非伺服器願意 expose。

順帶一提那個 Location。它指向 r4.smarthealthit.org,不是我們送過去的那個 sim 網址。這台 Launcher 是代理,新資源的正式位址落在後面那台。就算讀得到,也不該拿它當之後請求的基底。

寫完立刻查,查不到

寫入回 201 之後,程式馬上重跑趨勢圖的查詢。結果 total 還是 10,新那筆沒出現。

手動重整頁面再查,total 變 11,最後一個點就是剛剛存的 118 和 76。

這是搜尋索引的延遲,不是寫壞了。FHIR 規範沒有保證寫入之後立刻搜尋得到,很多伺服器的索引是非同步更新的。

所以 UI 不要靠「寫完重查一次」來確認成功。201 就是成功,直接把回應 body 裡那筆資源加進畫面上的清單,比重查可靠。

這台不擋你,真的 EHR 會

有兩件事這台 sandbox 很寬鬆,寫進文章是為了不要讓你養成錯的直覺。

它不檢查寫入權限。 拿一個只有唯讀 scope 的 token 去 POST,一樣寫得進去。真實 EHR 會回 403。你在這裡測不出權限問題,不代表你的 scope 設對了。

它不驗證必填欄位。 status 是 FHIR R4 規定必填的欄位。我試著送一筆拿掉它的 Observation,這台照樣回 201 建立成功。

它不做 profile 驗證。profile 是 FHIR 用來規定某一類資源必須長什麼樣的規格。真實伺服器多半會先用 $validate 這個內建的驗證操作問一下合不合規,或直接退 422。

還有一件事跟安全有關。這是公開的測試環境,資料是共用的,任何人寫進去的東西大家都看得到。不要放入真實病人資料,一筆都不要。

跟著做:把血壓存回去

起點是 day17 結束時的專案:index.htmlapp.jspatient.jsvitals.jsclinical.js,加上 vendor 裡的三個檔案。

第一步,改 scope 並重新授權

const SCOPE = 'launch/patient patient/*.crs openid fhirUser offline_access'

改完在 console 執行 sessionStorage.clear(),重整,重新走一次授權。同意畫面確認有 Create new * records 那一行。

第二步,新增 write.js

import { BLOOD_PRESSURE } from './vitals.js'

function pressureComponent(code, display, value) {
  return {
    code: { coding: [{ system: 'http://loinc.org', code, display }] },
    valueQuantity: {
      value,
      unit: 'mm[Hg]',
      system: 'http://unitsofmeasure.org',
      code: 'mm[Hg]',
    },
  }
}

export function selfMeasuredBloodPressure(patientId, systolic, diastolic, when) {
  const component = []

  if (Number.isFinite(systolic)) {
    component.push(pressureComponent('8480-6', 'Systolic Blood Pressure', systolic))
  }
  if (Number.isFinite(diastolic)) {
    component.push(pressureComponent('8462-4', 'Diastolic Blood Pressure', diastolic))
  }

  return {
    resourceType: 'Observation',
    status: 'final',
    category: [
      {
        coding: [
          {
            system: 'http://terminology.hl7.org/CodeSystem/observation-category',
            code: 'vital-signs',
            display: 'vital-signs',
          },
        ],
      },
    ],
    code: {
      coding: [
        { system: 'http://loinc.org', code: BLOOD_PRESSURE, display: 'Blood Pressure' },
      ],
      text: 'Blood Pressure',
    },
    subject: { reference: `Patient/${patientId}` },
    performer: [{ reference: `Patient/${patientId}` }],
    effectiveDateTime: when,
    issued: when,
    component,
  }
}

code 直接從 vitals.js 匯入那個常數,不要再打一次 55284-4。同一個代碼在兩個檔案各寫一次,改一邊忘了另一邊,趨勢圖就撈不到你剛寫的資料。

第三步,協定層的送出

export async function createRaw(client, resource) {
  const response = await fetch(`${client.state.serverUrl}/Observation`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/fhir+json',
      Authorization: `Bearer ${client.state.tokenResponse.access_token}`,
    },
    body: JSON.stringify(resource),
  })

  return {
    ok: response.ok,
    status: response.status,
    location: response.headers.get('location'),
    etag: response.headers.get('etag'),
    exposedHeaders: [...response.headers.keys()],
    body: await response.json(),
  }
}

exposedHeaders 那一行是刻意留的,跑起來你會親眼看到只有兩個。

第四步,畫面加一組輸入

兩個數字輸入框加一顆按鈕,按下去呼叫 createRaw,把回應印進 console:

const outcome = await createRaw(client, resource)
console.log('HTTP', outcome.status)
console.log('Location:', outcome.location)
console.log('ETag:', outcome.etag)
console.log('讀得到的 header:', outcome.exposedHeaders)
console.log('新資源 id:', outcome.body.id)

第五步,跑起來

輸入 118 和 76,按下存回伺服器。

瀏覽器視窗的示意畫面,標題「寫回去跑起來的樣子」,副標「輸入 118 與 76,畫面回一個 id,Console 五行」。畫面上有三個綠色圓形編號,分別掛在網址列右上角、綠色訊息框的左緣、Console 第一行的左緣,與圖片下方的三條註記一一對應。網址列顯示 http://localhost:5176/,右上角是編號 1。頁面內容區的小標是「自量血壓」,底下一排是收縮壓輸入框填 118、舒張壓輸入框填 76,右邊一顆深藍色按鈕寫「存回伺服器」。再下方是一塊淺綠底、左緣有深綠直線的訊息框,寫著「存好了,id 是 4828432。重整頁面就會出現在上面的趨勢圖」,左緣是編號 2。訊息框下方是 Console 面板,左緣是編號 3,依序列出五行輸出:HTTP 201;Location 冒號後面是一段珊瑚色虛線代表空的;ETag 冒號後面同樣是一段珊瑚色虛線;讀得到的 header 後面是珊瑚色的陣列,內容是 content-length 與 content-type 兩個字串;新資源 id 後面是 4828432。圖片下方三條編號註記:埠號 5176 是實跑當下起的靜態伺服器,換一個號碼不影響;重整之後趨勢圖 11 個點,最後一點是 118 與 76,體重那條在該點是 null;冒號後面是空的,不是印壞了,那兩個 header 瀏覽器讀不到

id 每個人不一樣。體重那條線在最後那一點是斷的。

那個斷點是真的缺值,不是假造的。你今天只送了血壓沒送體重。

如果之前有人也寫進去沒刪,你看到的點會更多。

第六步,把測試資料刪掉

這是大家共用的 sandbox,測完請把剛剛那筆刪掉。DELETE /Observation/{id} 回 200 就是刪掉了。刪一個不存在的 id 會回 404,代表你已經刪過了。

平常就用 client.create()

上面那段 fetch 是為了看清楚 HTTP 層。真的要寫程式,fhirclient 一行就夠:

const created = await client.create(resource)

它幫你組網址、帶 token、處理 token 過期重試。回傳的就是伺服器存下來的那筆資源。

更新用 client.update(resource)。差別是資源上要帶 id,HTTP 動詞是 PUT,成功回 200 不是 201。

PUT 還有一個容易踩到的行為。把它送到一個不存在的 id 上,這台不會回 404。FHIR 管這叫 update as create。

PUT 送到不存在的 id 之後四種狀態碼的對照圖,標題「PUT 到一個不存在的 id,這台回 201」,副標「FHIR 管這叫 update as create,伺服器可以支援,也可以拒絕」。上方一條淺藍灰色橫條,左端一個深藍色小標籤寫 PUT,接著深藍色等寬字的 /Observation/act3-put-test-11208,橫條最右側靠右對齊兩行灰字「body 的 id 與路徑相同」與「該 id 事前不存在」。橫條底下拉出一條珊瑚色線,往下再往右轉彎,接到右側 201 那一塊的上緣。中間一列四個方塊,左邊三個罩在同一塊淺灰底的區域裡,區域左上角標著「這三個都沒發生」:第一塊灰底灰字的 200,說明是「不是。這個要資源本來就在才會回」;第二塊灰底灰字的 404,說明是「不是。直覺會以為找不到就報這個」;第三塊白底加細邊框、深灰字的 405,說明是「規範允許伺服器用這個拒絕」。第四塊在淺灰區域之外,是最大的一塊,珊瑚色底白字的 201,底下寫「這台實際回的」。最下方一條深藍色色塊,左邊寫「回傳資源的 id」與珊瑚色等寬字的 act3-put-test-11208,中間寫 meta.versionId 與白字的 1,最右側靠右對齊兩行淺藍字「id 是我在路徑上指定的那個」與「伺服器沒有另外編一個」。圖片下方一行說明:回的是 201 不是 200,所以「PUT 一定是更新」這個假設在 FHIR 上不成立

所以兩個動詞的差別不只是新增跟更新。POST 是「你給我一個 id」,PUT 是「我決定 id 叫什麼」。要自己指定 id 就得用 PUT,代價是可能覆蓋掉別人的資料。擋這件事要靠 ETag,前面說過那個在瀏覽器裡讀不到。

最後回到 Bundle。火線超人那個表單一次可能寫三筆,用的是 transaction Bundle。type 設成 transaction,每筆資源一個 POST entry,整包送到伺服器根路徑。伺服器保證全成功或全失敗。

判斷標準很單純:多筆資源之間有沒有「不能只成功一半」的關係。一次量測的收縮壓與舒張壓有,兩次不同時間的量測沒有。

完整可跑的版本在 GitHub 上的 day18-write-back,想先看跑起來的樣子可以直接開線上版

小結

寫入本身只是換個 HTTP 動詞。真正花時間的是三件事。CORS 讓你讀不到 Location、寫完立刻查查不到、sandbox 的寬鬆讓你測不出權限問題。

明天處理失敗。今天所有請求都成功了,但真實世界不會這麼客氣。FHIR 的錯誤回應長得比你想像的更不一致。


上一篇
Day17 - 呈現臨床資料(二)
下一篇
Day19 - FHIR 伺服器的處理錯誤
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言