iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

本文同步發表於個人部落格:解析 Launch Context


day05 提過火線超人有一支 Smart::LaunchContextService,專門解析 isslaunch

def self.parse_launch_params(params)
  {
    iss: params[:iss],
    launch: params[:launch],
    patient: params[:patient],
    # ...
  }
end

當時的重點是「這兩個參數只有被別人啟動時才會出現」。而火線超人住在 LINE 裡,走的是 standalone。這支服務的前半段用不到。

但它的回傳值裡還有一個 patient。那一半是用得到的,而且不管走哪條路都用得到。因為授權完成之後,兩種模式都會遇到同一個問題。你手上有一串 id,畫面上要的是一個人。

patient 只是一串 id

day11 拿到的那個欄位長這樣:

patient: "d48ac962-78c6-46cf-ba33-a24771bfa0e4"

它不是姓名、不是生日、不是任何可以顯示的東西,就是一個主鍵。要變成畫面上的「王小明,1968 年生」,你得拿它再去查一次:

GET {fhirBaseUrl}/Patient/d48ac962-78c6-46cf-ba33-a24771bfa0e4
Authorization: Bearer {access_token}

這一趟是必要的。token response 不會附上病人資料,它只告訴你「這次授權的是哪一位」。

順帶一提,這也是 patient/Patient.r 這個 scope 存在的理由。你以為拿到 patient id 就等於拿到病人。其實你還需要一個讀 Patient 資源的權限,兩件事是分開的。day10 那個「反推」的練習如果漏了這一項,程式會在這一步 403。

context 不只有病人

token response 上跟 context 有關的欄位,這台實際會回的有這些:

欄位 內容 什麼時候有
patient 病人 id 送了 launch/patientlaunch
encounter 就診事件 id 這次只有 EHR Launch 那趟回了
need_patient_banner 布林值 一直都有
smart_style_url 一個 JSON 網址 一直都有

need_patient_banner 講的是你要不要自己顯示病患基本資料列。這個詞的原文是 patient banner,指醫院系統畫面上方固定的那一條。上面寫著姓名、病歷號與警示標記,臨床人員靠它確認開的是哪位病人。

方向別記反。true 是你要自己顯示,false 是 EHR 已經顯示了,你可以省下那塊空間。規範對 false 的措辭留有餘地,寫的是「可能不需要」。

這台回的是 true。合理,因為我們走 standalone。app 開在自己的分頁裡,旁邊沒有 EHR 的畫面可以靠。

smart_style_url 才是給嵌在 EHR 裡的 app 用的。那個網址回傳一組色票,讓你的 app 看起來像 EHR 的一部分。standalone 用不上這一項,但你會一直看到它出現在欄位清單裡。

encounter 那一列才是重點,它牽出兩種模式真正的差別。

兩種模式帶進來的 context 不一樣

我把 EHR Launch 也實際跑了一次,跟 standalone 擺在一起:

standalone EHR Launch
送的 context scope launch/patient launch
patient
encounter 沒有
fhirUser 指向 Patient/<id> Practitioner/<id>

兩種啟動模式帶進來的 context 對照圖,標題「誰啟動,誰決定 context」,副標「encounter 只有 EHR launch 那一邊有」。左右兩張白色卡片,標頭都是深藍色底白字。左卡標題 standalone,底下小字「使用者自己開的」,卡內第一行是淺灰底的 scope: launch/patient,接著三列:綠色圓形勾號的 patient,右側值為 d48ac962 開頭;珊瑚色圓形叉號的 encounter,欄位名以珊瑚色標示,右側寫著「沒有這個 key」;綠色勾號的 fhirUser,右側是 Patient 斜線開頭。右卡標題 EHR launch,底下小字「醫師在病歷裡點開的」,scope 那行只有 launch,三列全是綠色勾號:patient 同一個值、encounter 值為 7ac26195 開頭且欄位名同樣以珊瑚色標示、fhirUser 是 Practitioner 斜線開頭。圖片下方兩行說明,standalone 這次只送了 launch/patient,使用者在自己家裡打開 app,不在任何一次就診裡面;EHR launch 送一個沒有斜線的 launch,這台就把 patient 與 encounter 都給了,因為那是醫師點下去那一刻就決定的

差別不在於誰比較完整,在於 context 是誰決定的

standalone 是使用者自己開的。這次我們只送了 launch/patient,所以回來的只有病人。規範另外有 launch/encounter,它沒有被限定只能用在 EHR Launch。不過就算送了,EHR 也可以忽略這類 hint。

實際上你不太會在 standalone 送 launch/encounter。使用者是在自己家裡打開 app,他不在任何一次就診裡面。

EHR Launch 送的是一個沒有斜線的 launch。規範給的是一項權限:從 EHR 啟動時可以取得 launch context。至於要哪幾個 context,規範的寫法是各自用 launch/patient 這類 scope 去要。

但這台只收到一個 launch,就把 patient 和 encounter 都給了。這就是 day11 說的協商,伺服器可以主動給你沒有要求的 context。

醫師點下去的那一刻,他正開著某位病人的某一次就診,這兩件事都在那個 launch 參數裡。所以 encounter跟著醫師的操作情境來的,不是 app 要來的。

fhirUser 那一列也一樣。standalone 登入的是病人本人,EHR Launch 登入的是醫師。同一個 claim,指向不同的資源型別。明天整篇講 fhirUser

交給 fhirclient

從 day06 到今天,授權流程的每一段我們都親手寫過。discovery 問端點、PKCE 產 verifier 與 challenge。組 authorize URL、驗 state、拿 code 換 token。

該收工了。今天開始改用 fhirclient

為什麼是現在

不是因為手刻很麻煩,是因為機制已經看過了。

如果一開始就用函式庫,code_challenge 對你來說就只是一個「照抄就會動」的參數。等到某天授權失敗,你會完全不知道從哪裡查起。現在不一樣,你知道 verifier 存在 sessionStorage。知道 state 是拿來比對的,知道 token 是 POST 到哪裡換的。這些理解不會因為換成函式庫而消失。

它接手了什麼

我直接翻了 vendor/ 裡那個檔案。也就是說:

我們手刻的 fhirclient 的處理
discovery.js 問端點 內建,你只要給 iss
pkce.js 產 verifier 與 challenge 內建,S256
sessionStorage 存 verifier 內建
state 產生與比對 內建
fetch 換 token 內建
每個 FHIR 請求手動加 header client.request() 自動帶

還多給你幾樣我們沒寫的。自動分頁(pageLimit)、自動解參照(resolveReferences)、token 過期時的處理。

fhirclient 接手了哪幾件手刻工作的對應圖,標題「手刻的那幾件事,檔案裡都有」,副標「不是讀文件推論,是打開 vendor/fhir-client.pure.min.js 數字串」。米色底。上半部分左右兩欄,左欄標頭是「day06 到 day09 我們手刻的」,右欄標頭是「檔案裡的字串出現次數」。左欄五個深藍色圓角色塊由上而下依序是 pkce.js 產 challenge、pkce.js 產 verifier、雜湊方法選 S256、state 產生與比對、組 authorize URL,每一塊都用一條灰藍色水平箭頭指向右欄對應的白色方框。右欄五個白框依序是 code_challenge 3 次、code_verifier 1 次、S256 5 次、state 55 次、authorize 19 次,其中 55 這個數字以珊瑚色標示,其餘次數是深藍色。圖片下方一整條深藍色色塊,左半段白字寫「這五件事我們親手寫過,它一個檔案全接走了,右邊四個是我們沒寫過的功能」,右半段是一個半透明的等寬字面板,列出 ready 7 次、getPatientId 2 次、getFhirUser 4 次、resolveReferences 6 次

那個從 day04 就躺著的檔案

還記得 day04 建專案時 index.html 裡有兩個 script 標籤嗎?

<script src="vendor/fhir-client.pure.min.js"></script>
<script type="module" src="app.js"></script>

那個 54 KB 的檔案躺了八天,day06 說「先留著,day12 會用回來」。就是今天。

跟著做:改用 fhirclient,把 id 變成姓名

起點:day11 之後的 smart-app,有 index.htmlapp.jsdiscovery.jspkce.jsauth.jsvendor/fhir-client.pure.min.js

產出app.js 改用 fhirclient 走完授權,畫面顯示病人姓名。discovery.jspkce.jsauth.js 不再被引用,但先不要刪。第三步的 EHR 模式只換啟動方式,檔案一個字都沒動。day13 接的是第二步那個 standalone 的畫面,不是 EHR 那一次。

第一步,把 app.js 換掉

整份替換成這樣:

export const FHIR_BASE_URL =
  'https://launch.smarthealthit.org/v/r4/sim/WzMsIiIs…/fhir'

const SCOPE = 'launch/patient patient/Patient.r openid fhirUser'
const CLIENT_ID = 'my-smart-app'

const result = document.querySelector('#app')
const connectButton = document.querySelector('#connect')

FHIR.oauth2
  .ready()
  .then(showPatient)
  .catch(() => {
    // 還沒授權過,ready() 會 reject,這時候才顯示按鈕
    result.textContent = '準備好了,按按鈕開始授權'
    connectButton.disabled = 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()

  console.log('patient id:', client.patient.id)
  console.log('encounter id:', client.encounter.id)
  console.log('fhirUser:', client.user.fhirUser)

  const patient = await client.patient.read()
  const name = patient.name?.[0]
  const display = name ? `${name.given?.join(' ')} ${name.family}` : '(這筆資料沒有姓名)'

  result.textContent = `${display},生日 ${patient.birthDate}`
}

三十行不到,取代了前面六天寫的四個檔案。注意選項名稱是 clientIdredirectUri 這種寫法,不是我們手刻時用的 client_id

SCOPE 也換了一項。day10 到 day11 練習用的是 patient/Observation.rs,今天要讀的是病人本身,所以改成 patient/Patient.r。這就是前面說的,拿到 patient id 跟讀得到 Patient 資源是兩件事。

FHIR.oauth2.ready() 是整段的樞紐。它會去看網址列上有沒有 code。有就自動完成換 token 並回傳一個 client,沒有就 reject。所以「還沒授權」這件事是走 catch 分支的,第一次讀會有點反直覺。

第二步,跑 standalone

起靜態伺服器,走完授權。畫面應該從 id 變成人:

Jerrell Gerlach,生日 1968-04-19

console 那三行:

patient id: d48ac962-78c6-46cf-ba33-a24771bfa0e4
encounter id: null
fhirUser: Patient/d48ac962-78c6-46cf-ba33-a24771bfa0e4

encounter idnull,因為 standalone 沒有就診事件。fhirUser 指向 Patient,因為登入的是病人本人。

第三步,換 EHR 模式跑一次

這一步不用改程式碼,只換啟動方式。

  1. SMART Health IT Launcher,Launch Type 保持預設的 Provider EHR Launch
  2. App's Launch URLhttp://localhost:5173/(你的本機位址)
  3. Launch,選一位醫師、選一位病人

你的 app 會被 Launcher 開起來,網址列上帶著 isslaunchfhirclient 會自己讀走這兩個參數,你不用寫任何一行去接。

這次 console 變成:

patient id: d48ac962-78c6-46cf-ba33-a24771bfa0e4
encounter id: 7ac26195-115e-4593-84d1-19170a042dfe
fhirUser: Practitioner/4826865

同一份程式碼、同一個 SCOPE,只因為啟動方式不同,拿到的 context 就不一樣。

EHR 模式跑完的執行結果圖,標題「EHR 模式跑完的樣子」,副標「console 三行,encounter 這次有值」。淺灰綠底,中間一個白色視窗卡片。卡片最上一列是三個灰色圓點與一行灰字註記「腳本直接打端點,沒有經過瀏覽器」,沒有網址列。第二列淺灰底寫「authorize 這次多帶的 launch 參數」,右邊以等寬字標出 124 字元。第三列淺灰底是 Console 標籤。卡片內容區三行等寬字輸出:第一行 patient id 冒號,值為 d48ac962-78c6-46cf-ba33-a24771bfa0e4;第二行 encounter id 冒號,值為 7ac26195-115e-4593-84d1-19170a042dfe,整行以淡粉紅底加左側珊瑚色直條標示,欄位名與值都是珊瑚色粗體;第三行 fhirUser 冒號,值為 Practitioner 斜線 4826865,其中 Practitioner 以深綠色粗體標示。卡片下方三條綠色圓形編號註記,第一條寫珊瑚色那一行是差別所在,encounter 在 standalone 的所有組合中都沒有出現過;第二條寫 fhirUser 指向 Practitioner,這次登入的是醫師;第三條寫程式碼一行沒改,只換了啟動方式

預期結果:standalone 顯示病人姓名、encounternullfhirUser 指向 Patient;EHR 模式下 encounter 有值、fhirUser 指向 Practitioner。

如果 EHR 模式跑不起來,先確認 Launch URL 填的是 http://localhost:5173/ 而不是 file://。也要確認靜態伺服器正在跑。

舊檔案先不要刪

discovery.jspkce.jsauth.js 現在沒有人 import 了,但留著。

day14 講 token 生命週期時,我們會再回到 HTTP 層。那時候要剖開 refresh 的那一趟交換。auth.js 裡換 token 的那段程式碼是最好的對照組。函式庫幫你做掉的事情,你得看得懂它在做什麼。

小結

token response 裡的 patient 是一串 id,不是一個人。要變成畫面上的姓名得再查一次 Patient/{id},而那一趟需要 patient/Patient.r 這個 scope。

context 的內容由啟動方式決定。standalone 只要得到病人,EHR Launch 連就診事件一起帶進來。因為那包 context 是醫師點下去那一刻就決定的,不是 app 要來的。

今天也把授權流程交給了 fhirclient。它接手 PKCE、state、token 交換與 discovery。也就是 day06 到 day09 我們親手寫過的每一件事。先手刻再換工具,順序反過來的話,你只會得到一堆照抄的參數。

fhirUser 今天出現了兩次,一次指向 Patient、一次指向 Practitioner。明天就從這個 claim 開始,看 app 怎麼知道現在是誰在用它。


上一篇
Day11 - scope 解鎖哪些欄位
下一篇
Day13 - 身分識別與 fhirUser
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言