FHIR 09 說明 Standalone 建立 ExchangeToken 新頁面的程式碼做法與實測過程 在上一篇文章中 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_bannertrue 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.
ExchangeToken.razor 頁面程式碼說明 在這個頁面,使用了底下語法,來接收使用 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,取得最新的一筆身高與體重資料。 GetAccessTokenAsync 方法說明: 為了要使用授權碼來交換存取權杖 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 }
} ;
這個方法主要是使用取得的存取權杖 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 方法說明: 下面是針對 GetHeightAndWeightAsync 方法,採用你給的 GetPatientAsync 說明風格所寫的說明內容:
GetHeightAndWeightAsync 方法說明: 這個方法主要是使用取得的存取權杖 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 取得