寫回 FHIR
火線超人的居家回報表單,送出去的是一個 transaction Bundle。
理由很簡單。一次量測可能同時有收縮壓、舒張壓、脈搏三個值。在 FHIR 裡它們是三筆獨立的 Observation。三筆要嘛全部寫進去,要嘛一筆都不要寫。寫到一半失敗,資料庫裡留下半組資料,比什麼都沒寫還糟。
今天先從單筆開始。Bundle 的時機文章最後會講。
先改一個字母
到 day17 為止的 scope 是 patient/*.rs,read 加 search,全部唯讀。
要寫入就加一個 c:
const SCOPE = 'launch/patient patient/*.crs openid fhirUser 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-signs,code 用跟 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 預設只讀得到少數幾個。

所以在瀏覽器裡跑的 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.html、app.js、patient.js、vitals.js、clinical.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,按下存回伺服器。

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。

所以兩個動詞的差別不只是新增跟更新。POST 是「你給我一個 id」,PUT 是「我決定 id 叫什麼」。要自己指定 id 就得用 PUT,代價是可能覆蓋掉別人的資料。擋這件事要靠 ETag,前面說過那個在瀏覽器裡讀不到。
最後回到 Bundle。火線超人那個表單一次可能寫三筆,用的是 transaction Bundle。type 設成 transaction,每筆資源一個 POST entry,整包送到伺服器根路徑。伺服器保證全成功或全失敗。
判斷標準很單純:多筆資源之間有沒有「不能只成功一半」的關係。一次量測的收縮壓與舒張壓有,兩次不同時間的量測沒有。
完整可跑的版本在 GitHub 上的 day18-write-back,想先看跑起來的樣子可以直接開線上版。
小結
寫入本身只是換個 HTTP 動詞。真正花時間的是三件事。CORS 讓你讀不到 Location、寫完立刻查查不到、sandbox 的寬鬆讓你測不出權限問題。
明天處理失敗。今天所有請求都成功了,但真實世界不會這麼客氣。FHIR 的錯誤回應長得比你想像的更不一致。