在上一篇文章中 FHIR 08 在 Standalone 建立新頁面來接收授權碼進而取得存取權杖並呼叫 FHIR API ,已經完成了相關 Exchange Token 的程式碼,透過這些新增的程式碼與頁面,透過準備新得到的存取權杖 Access Token 成功地呼叫 FHIR API 來讀取病患資料。
首先,先來執行這個專案,看看執行成果,接著,再來說明這些程式碼的做法與實測過程。
依照底下步驟操作,完成使用 Smart On FHIR 沙盒環境來進行 EHR Launch 的授權碼取得流程:
- 開啟並且執行 [SmartStandalone1]
- 在瀏覽器的位置列中,輸入
https://localhost:7108/launch - 在 [Launch] 端點頁面中,將會顯示出文字 [請稍後,正在初始化中]
- 接著,系統會自動將我們的瀏覽器,重新導向到授權伺服器
- 現在,將會進入到 FHIR 的授權伺服器,要取得授權碼,當取得了授權碼之後,就會使用 redirect_uri 參數,重新導向回到我們開發的頁面端點
https://localhost:7108/ExchangeToken,並且在網址列中帶入授權碼參數 code - 在網頁上,將會看到下面的畫面截圖,準備進入到身分驗證階段
- 在這裡使用預設的帳號與密碼,點選 [Login] 按鈕
- 現在的畫面將會切換到選擇病患的畫面
- 點選名稱為 [Geoffrey Abbott] 病患項目,這個病人位於第二頁,準備進入到下一個階段
- 這裡出現了 [Authorize App Launch] 對話窗,這裡需要你同意這個應用程式代表你,可以對 FHIR Server 做列出能力清單的操作。
- 此時,現在可以點選 [Approve] 按鈕,來同意這個應用程式的授權要求
- 當點選了 [Approve] 按鈕之後,系統將會重新導向回到我們的應用程式頁面端點
https://localhost:7108/ExchangeToken,並且在網址列中帶入授權碼參數 code 與 state 參數 - 這時候,ExchangeToken 頁面將會被載入,並且開始執行頁面程式碼
- 之後,就會在螢幕上看到下面截圖畫面,這裡出現了從授權伺服器回傳的授權碼內容,這裡列出了 code & state 這兩個參數
- 當使用 code 這個 OAuth2 授權碼來交換存取權杖,將會得到底下的內容
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6Im9wZW5pZCBmaGlyVXNlciBwcm9maWxlIGxhdW5jaC9wYXRpZW50IHBhdGllbnQvKi5yZWFkIHBhdGllbnQvRW5jb3VudGVyLnJlYWQgcGF0aWVudC9NZWRpY2F0aW9uUmVxdWVzdC5yZWFkIHBhdGllbnQvU2VydmljZVJlcXVlc3QucmVhZCIsImNvbnRleHQiOnsibmVlZF9wYXRpZW50X2Jhbm5lciI6dHJ1ZSwic21hcnRfc3R5bGVfdXJsIjoiaHR0cHM6Ly90aGFzLm1vaHcuZ292LnR3L3NtYXJ0LXN0eWxlLmpzb24iLCJwYXRpZW50IjoiMmNkYTVhYWQtZTQwOS00MDcwLTlhMTUtZTFjMzVjNDZlZDVhIn0sImNsaWVudF9pZCI6InNtYXJ0LWFwcCIsImZoaXJVc2VyIjoiUHJhY3RpdGlvbmVyLzE0NzUzMyIsImlhdCI6MTc2ODQ2NDg5NywiZXhwIjoxNzY4NDY4NDk3fQ.haFOCN0Bx7hLyxmrvf-6_4MEDGnFBf8iATmV9TfX8NU","token_type":"Bearer","expires_in":3600,"scope":"openid fhirUser profile launch/patient patient/*.read patient/Encounter.read patient/MedicationRequest.read patient/ServiceRequest.read","id_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9maWxlIjoiUHJhY3RpdGlvbmVyLzE0NzUzMyIsImZoaXJVc2VyIjoiUHJhY3RpdGlvbmVyLzE0NzUzMyIsImF1ZCI6InNtYXJ0LWFwcCIsInN1YiI6IjFhN2M3NjFiN2MzM2NiNGRmNGVhMDcwZTMwZmRiZjUxMzUxNjM1OWZjYzQ5YzNhZDYzYjY0MmU0NTEwOTc2NDQiLCJpc3MiOiJodHRwczovL3RoYXMubW9ody5nb3YudHcvdi9yNC9zaW0vV3pJc0lpSXNJaUlzSWtGVlZFOGlMREFzTUN3d0xDSWlMQ0lpTENJaUxDSWlMQ0lpTENJaUxDSWlMREFzTVN3aUlsMC9maGlyIiwiaWF0IjoxNzY4NDY0ODk3LCJleHAiOjE3Njg0Njg0OTd9.NxDdzAzT8wz1rl_sFGTCKUAE8gR0_WoVIVtgPK8jpi30UI0SWLSblp9a3RNqZTefKUStScrF8bKqV_oPkPE1cNB_jgy_X6SM1YawBE2J0zv_ShObwDfxunOPYmOOl1yfQf-qQuNP96ywAuFe3yljbnbvBd_zTfy5_H2ERSOxE0vN4Q8irY5p02YiJhWLTl62Q3E_aS2GZ4Kn-IGGnvAgmOWJEluVdVQ35s3UP-L_h272H8lS8iFs-5FO7xR4_iltleHBRZyBGMLnaZ-8IU_zPCxmcwBTLklLK1JE_f1HUFMNxa5itfb03tCV5kUC9wHTOdD_52h4jAhKrI1B1hSwdA","need_patient_banner":true,"smart_style_url":"https://thas.mohw.gov.tw/smart-style.json","patient":"2cda5aad-e409-4070-9a15-e1c35c46ed5a"}- 從上面的內容中,可以看到幾個重要的欄位值
- access_token:這就是我們要用來存取 FHIR API 的存取權杖
- id_token:這是一個 JWT 格式的身分識別權杖
- patient:這就是我們這次所選擇的病患資源 ID
- 透過上面提供的 Access Token 與 Patient ID ,接著,頁面程式碼會呼叫 FHIR API 來讀取病患資料
- 讀取到的病患資料內容,包含病患名稱與出生日期
- 最後,會取得該病人的身高與體重值,這裡是透過 FHIR 內的 Observation 資源來取得這些資料
- 下面的截圖,顯示了從 FHIR 伺服器讀取到的病患資料內容

在這個 ExchangeToken 頁面的程式碼中,為了要能夠順利地交換取得存取權杖 Access Token 與呼叫 FHIR API 來讀取病患資料,必須要先建立兩個輔助的程式碼檔案,分別是 SmartResponse 資料模型與 JwtHelper 輔助支援類別。
- 當要透過授權碼來取得存取權杖時候,將會得到剛剛提到的 JSON 物件
- 該 json 物件結構符合:SMART on FHIR + OpenID Connect Hybrid
- 這裡取得的 access_token 經過解碼之後的內容如下,這個存取權杖的有效期先將會是1 小時
{
"scope": "openid fhirUser profile launch/patient patient/*.read patient/Encounter.read patient/MedicationRequest.read patient/ServiceRequest.read",
"context": {
"need_patient_banner": true,
"smart_style_url": "https://thas.mohw.gov.tw/smart-style.json",
"patient": "2cda5aad-e409-4070-9a15-e1c35c46ed5a"
},
"client_id": "smart-app",
"fhirUser": "Practitioner/147533",
"iat": 1768464897,
"exp": 1768468497
}- 這裡取得的 id_token 經過解碼之後的內容如下,是 RS256,內容是 OpenID Connect 身分聲明
{
"profile": "Practitioner/147533",
"fhirUser": "Practitioner/147533",
"aud": "smart-app",
"sub": "1a7c761b7c33cb4df4ea070e30fdbf513516359fcc49c3ad63b642e451097644",
"iss": "https://thas.mohw.gov.tw/v/r4/sim/WzIsIiIsIiIsIkFVVE8iLDAsMCwwLCIiLCIiLCIiLCIiLCIiLCIiLCIiLDAsMSwiIl0/fhir",
"iat": 1768464897,
"exp": 1768468497
}因此,這裡需要一個 SmartResponse 資料模型來對應這些欄位。透過了反序列化程式碼
SmartResponse smartResponse = JsonSerializer.Deserialize<SmartResponse>(json);便可以將上述的 JSON 內容,轉換成 SmartResponse 物件來使用。在 Smart On FHIR 文件內的 2.2.3.3 Launch context arrives with your access_token 文件中,有提到授權伺服器回傳的存取權杖 Access Token 的 JSON 格式內容,這個內容需要一個資料模型來對應這些欄位,因此,我們建立了 SmartResponse.cs 這個資料模型來對應這些欄位。
| Launch context parameter | Example value | Meaning |
|---|---|---|
patient | "123" | String value with a patient id, indicating that the app was launched in the context of FHIR Patient 123. If the app has any patient-level scopes, they will be scoped to Patient 123. |
encounter | "123" | String value with an encounter id, indicating that the app was launched in the context of FHIR Encounter 123. |
fhirContext | [{"reference": "Appointment/123"}] | Array of objects referring to any resource type other than "Patient" or "Encounter". See details below. |
need_patient_banner | true or false (boolean) | Boolean value indicating whether the app was launched in a UX context where a patient banner is required (when true) or may not be required (when false). An app receiving a value of false might not need to take up screen real estate displaying a patient banner. |
intent | "reconcile-medications" | String value describing the intent of the application launch (see notes below). |
smart_style_url | "https://ehr/styles/smart_v1.json" | String URL where the EHR’s style parameters can be retrieved (for apps that support styling). |
tenant | "2ddd6c3a-8e9a-44c6-a305-52111ad302a2" | String conveying an opaque identifier for the healthcare organization that is launching the app. This parameter is intended primarily to support EHR Launch scenarios. |
- 在這個頁面,使用了底下語法,來接收使用 Query String 參數傳遞過來的值
[SupplyParameterFromQuery(Name = "code")]
public string? Code { get; set; }
[SupplyParameterFromQuery(Name = "state")]
public string? State { get; set; }- 在 OnAfterRenderAsync 方法中,這裡會先檢查是否為第一次渲染頁面
- 在第一次渲染事件發生的時候,將會執行底下的敘述
await SetAuthCodeAsync();
StateHasChanged();
await System.Threading.Tasks.Task.Delay(5000);
SmartResponse smartResponse = await GetAccessTokenAsync();
await GetPatientAsync(smartResponse);
heightAndWeight = await GetHeightAndWeightAsync(smartResponse);
StateHasChanged();- 在 Blazor 元件第一次渲染後,自動完成「授權碼處理 → 換取 token → 讀取病人資料 → 讀取病人身高與體重」這一連串動作:
- await SetAuthCodeAsync()
- 從
OAuthStateStoreService載入對應State的設定物件,寫入目前的Code、State,更新SmartAppSettingService,並用JwtHelper解碼AuthCode(當作 JWT)存到AuthorizationCodeJson,方便頁面顯示。
- 從
- GetAccessTokenAsync()
- 建立
POST請求到TokenUrl,帶上grant_type=authorization_code、code、redirect_uri、launch,呼叫 OAuth token endpoint。 - 回傳 JSON 反序列化成
SmartResponse(裡面包含AccessToken、PatientId等)。
- 建立
- GetPatientAsync(smartResponse)
- 透過
FhirClient讀取Patient/{smartResponse.PatientId},把結果存到patient欄位,並把isReadPatient設為true。
- 透過
- GetHeightAndWeightAsync(smartResponse)
- 透過
FhirClient搜尋Observation,條件是patient=smartResponse.PatientId且code是身高或體重的 LOINC code,取得最新的一筆身高與體重資料。
- 透過
- 為了要使用授權碼來交換存取權杖 Access Token,這裡將會使用底下程式碼建立 Post 方法要用到的 Payload
Dictionary<string, string> requestValues = new Dictionary<string, string>()
{
{ "grant_type", "authorization_code" },
{ "code", SmartAppSettingService.Data.AuthCode },
{ "redirect_uri", SmartAppSettingService.Data.RedirectUrl },
{ "launch", SmartAppSettingService.Data.Launch }
};- 然後,建立 HttpRequestMessage 物件,並且設定好相關屬性值
- 接著,透過 HttpClient 透過 https://thas.mohw.gov.tw/v/r4/auth/token 端點來發送 Post 請求,這個服務端點則是透過授權伺服器所提供的 Token 端點
- 最後,取得回傳的 JSON 內容,並且反序列化成 SmartResponse 物件來使用
- 這個方法主要是使用取得的存取權杖 Access Token 來呼叫 FHIR API 來讀取病患資料
- 首先,建立 HttpClient 物件,並且設定好 BaseAddress 屬性值
- 接著,設定好 Authorization 標頭內容,這裡使用 Bearer Token 的方式來進行授權
- 然後,建立 FhirClient 物件,並且將剛剛建立的 HttpClient 物件傳遞給 FhirClient 來使用
- 最後,使用 FhirClient 物件來讀取 Patient 資源,並且將讀取到的病患資料存到 patient 欄位中,並且將 isReadPatient 屬性設為 true,代表已經成功讀取到病患資料
- 這裡將會透過
await fhirClient.ReadAsync<Patient>($"Patient/{smartResponse.PatientId}");方法來做到這個需求,也就是說,想要能夠存取 FHIR Server 上的資源,將會使用 FhirClient 物件來進行相關的操作,透過轉換成為 FHIR API,便可以很方便地來進行相關的存取動作 - 這樣一來,頁面便可以顯示出病患的相關資料內容
下面是針對 GetHeightAndWeightAsync 方法,採用你給的 GetPatientAsync 說明風格所寫的說明內容:
- 這個方法主要是使用取得的存取權杖 Access Token,透過 FHIR API 讀取指定病患的生命徵象(vital signs)觀測值,並從中擷取「身高」與「體重」資料
- 首先,建立
HttpClient物件,並設定好BaseAddress為 FHIR Server 的 URL - 接著,設定好
Authorization標頭內容,這裡同樣是使用 Bearer Token 的方式來進行授權,Token 來源為smartResponse.AccessToken - 然後,建立
FhirClientSettings,並將PreferredFormat設定為ResourceFormat.Json,代表後續與 FHIR Server 溝通時,預期使用 JSON 格式 - 之後,建立
FhirClient物件,並將剛剛建立的HttpClient物件與 FHIR Server 的 URL 傳入,之後便可以透過這個FhirClient物件來呼叫 FHIR API - 接下來,建立一個
SearchParams查詢條件:- 使用
patient={smartResponse.PatientId}限定只查詢該病患的資料 - 使用
category=vital-signs限定只查詢屬於「生命徵象」類別的 Observation - 使用
LimitTo(50)限制最多回傳 50 筆資料
- 使用
- 使用
await fhirClient.SearchAsync<Observation>(searchParams);來呼叫 FHIR API,取得一個Bundle,其中包含多筆 Observation 資源 - 接著,逐筆走訪
bundle.Entry,針對每一筆Observation做以下處理:- 先確認
entry.Resource真的是Observation型別 - 取出
Observation.Code.Coding中第一筆 coding 的Code欄位,把它視為 LOINC 代碼 - 僅處理
Value為Quantity型別的 Observation,並從Quantity.Value取得實際數值,從Quantity.Unit或Quantity.Code取得單位 - 當 LOINC 代碼是
8302-2時,代表身高(Body height),將其數值與單位存到heightValue與heightUnit - 當 LOINC 代碼是
29463-7時,代表體重(Body weight),將其數值與單位存到weightValue與weightUnit
- 先確認
- 這裡會將搜尋結果中最後一筆符合條件的 Observation 視為最終的身高或體重值(若有多筆同 code 的 Observation,後面的會覆蓋前面的變數值)
- 最後,將取得到的身高與體重數值與單位,轉成字串,包裝成一個
VitalSignsResult物件並回傳 - 在 Blazor 頁面中,便可以利用這個欄位來顯示病患的身高與體重資訊
透過這個 ExchangeToken 頁面的程式碼,成功地完成了使用授權碼來交換存取權杖 Access Token,並且使用取得的存取權杖來呼叫 FHIR API 來讀取病患資料的需求,這樣一來,便可以順利地完成 Standalone Launch 的整個流程。
若要取得這篇文章的程式碼,可以透過 https://github.com/vulcanlee/CSharp2025/tree/main/SmartStandalone1 取得
沒有留言:
張貼留言