2026年1月15日 星期四

FHIR 06 建立 Standalone Launch App

FHIR 06 建立 Standalone Launch App

前面的一系列文章,說明了如何建立一個符合 Smart On FHIR 規範的 EHR Launch 的應用系統,這篇文章將會示範如何建立一個符合 Smart On FHIR 規範的 Standalone Launch 的應用系統。

根據 Smart On FHIR 規格說明,這裡開發出來的 Standalone Launch 系統,並不會是透過 EHR 系統來觸發啟動,而是獨立運行與啟動,在操作這個系統的過程中,Smart App 會先到指定的 FHIR Server 上,讀取該服務上的 Meta Data 資訊,接著根據取得的這些資訊,主動引導使用者前往 FHIR Server 系統的授權端點,並且傳送一些參數到該端點內,這些參數包括了 response_type、client_id、redirect_uri、scope、state、launch 與 aud 等等,這些參數將會用於授權流程中。一旦取得了授權碼之後,接著便會回到 Smart App 網頁內,在這個 Smart App 網頁內使用這個授權碼,來向授權伺服器的 Token Endpoint 交換存取權杖 (Access Token),最後,便可以使用這個存取權杖來呼叫 FHIR API,取得所需的 FHIR 資源。

為了要完成 Smart On FHIR 之 Standalone Launch App 的開發,並且符合沙盒的要求,這篇文章將會示範如何建立一個 Standalone Launch App 的專案,透過 Smart On FHIR 的規範,取得一個病患 ID,接著呼叫 FHIR API,取得該病患的姓名與身高、體重。

建立 Blazor 專案

  • 開啟 Visual Studio 2026
  • 選擇「建立新專案」
  • 在 [建立新專案] 視窗中,在右方清單內,找到並選擇「Blazor Web 應用程式」 項目
  • 然後點擊右下方「下一步」按鈕
  • 此時將會看到 [設定新的專案] 對話窗
  • 在該對話窗的 [專案名稱] 欄位中,輸入專案名稱,例如 "SmartStandalone1"
  • 然後點擊右下方「下一步」按鈕
  • 接著會看到 [其他資訊] 對話窗
  • 在這個對話窗內,確認使用底下的選項
    • 架構:.NET 10.0 (或更新版本)
    • 驗證類型:無
    • 勾選 針對 HTTPS 進行設定
    • 互動式轉譯模式:伺服器
    • 互動功能位置:全球
    • 勾選 包和範例頁面
    • 勾選 不要使用最上層陳述式 (這是我的個人習慣)
    • 不要勾選 在應用程式 URL 中使用 .dev.localhost TLD
    • 不要勾選 在 .NET Aspire 協調流程中登錄
  • 然後點擊右下方「建立」按鈕
  • 現在,已經完成了這個 Blazor 專案的建立

安裝需要用到的 NuGet 套件

  • 滑鼠右擊 [SmartStandalone1] 專案節點
  • 點選彈出功能表的 [管理 NuGet 套件] 項
  • 在 [瀏覽] 索引標籤中,搜尋並且安裝底下的 NuGet 套件
    • Hl7.Fhir.R4

建立 Standalone Launch 進入點頁面

在這裡將會說明採用 [Standalone Launch] 模式,則會透過其他的文章來說明這樣的系統開如何開發出來。

  • 在剛剛建立的 [SmartStandalone1] 專案中,滑鼠右擊 [Components] > [Pages] 資料夾
  • 點選彈出功能表的 [加入] > [Razor 元件] 項目
  • 在 [新增項目] 對話窗的最下方之 [名稱] 欄位中,輸入頁面名稱為 [Launch]
  • 然後點擊右下方的 [新增] 按鈕 
  • 接下要來建立這個頁面的 Code Behind 的程式碼檔案
  • 在剛剛建立的 [SmartStandalone1] 專案中,滑鼠右擊 [Components] > [Pages] 資料夾
  • 點選彈出功能表的 [加入] > [新增項目] 項目
  • 底下操作將會是在 [顯示精簡檢視] 模式下操作
  • 在 [新增項目] 對話窗的文字輸入盒中,輸入檔案名稱為 [Launch.razor.cs]
  • 然後點擊右下方的 [新增] 按鈕 
  • 在剛剛建立的 [Launch.razor.cs] 程式碼檔案中,輸入底下的程式碼內容
namespace SmartStandalone1.Components.Pages;

public partial class Launch
{
}
  • 這樣正式完成這個頁面的 Code Behind 的程式碼檔案建立

建立神奇物件的資料夾與類別

  • 滑鼠右擊 [SmartStandalone1] 專案節點
  • 點選彈出功能表的 [加入] > [新增資料夾] 項目
  • 建立這個 [Helpers] 資料夾
  • 滑鼠右擊 [Helpers] 資料夾
  • 點選彈出功能表的 [加入] > [新增項目] 項目
  • 在文字輸入盒內輸入檔案名稱為 [MagicObjectHelper.cs]
  • 然後點擊右下方的 [新增] 按鈕
  • 使用底下程式碼內容來取代剛剛建立的 [MagicObjectHelper.cs] 程式碼檔案內容
namespace SmartStandalone1.Helpers;

public class MagicObjectHelper
{
    public const string SmartAppSettingKey = "SmartAppSetting";
}

建立需要用到的資料模型用的資料夾與類別

  • 滑鼠右擊 [SmartStandalone1] 專案節點
  • 點選彈出功能表的 [加入] > [新增資料夾] 項目
  • 建立這個 [Models] 資料夾
  • 滑鼠右擊 [SmartStandalone1] 專案節點
  • 點選彈出功能表的 [加入] > [新增資料夾] 項目
  • 建立這個 [Servicers] 資料夾
  • 滑鼠右擊 [Models] 資料夾
  • 點選彈出功能表的 [加入] > [新增項目] 項目
  • 在文字輸入盒內輸入檔案名稱為 [SmartAppSettingModel.cs]
  • 然後點擊右下方的 [新增] 按鈕 
  • 使用底下程式碼內容來取代剛剛建立的 [SmartAppSettingModel.cs] 程式碼檔案內容
namespace SmartStandalone1.Models;

/// <summary>
/// Smart On FHIR 設定模型,用於儲存與 FHIR 伺服器互動所需的各項設定值。
/// </summary>
public class SmartAppSettingModel
{
    /// <summary>
    /// FHIR 伺服器的基底網址。
    /// </summary>
    public string FhirServerUrl { get; set; }

    /// <summary>
    /// Gets or sets the unique identifier for the client application.
    /// </summary>
    public string ClientId { get; set; }

    /// <summary>
    /// 授權完成後 Smart App 的重新導向網址。
    /// </summary>
    public string RedirectUrl { get; set; }

    /// <summary>
    /// 從授權伺服器取得的授權碼 (Authorization Code)。
    /// </summary>
    public string AuthCode { get; set; }

    /// <summary>
    /// 用於驗證請求完整性的客戶端狀態字串。
    /// </summary>
    public string ClientState { get; set; } = "local_state";

    /// <summary>
    /// 交換 Access Token 的授權伺服器 Token Endpoint 位址。
    /// </summary>
    public string TokenUrl { get; set; }
    /// <summary>
    /// 用於與外部提供者啟動授權流程的 URL。
    /// </summary>
    public string AuthorizeUrl { get; set; }
    /// <summary>
    /// 可用來是否透過 EHR Launch 來啟動授權流程的參數。
    /// </summary>
    public string Iss { get; set; }
    public string Launch { get; set; }
    public string State { get; set; }
}
  • 這個資料模型,將會用於儲存與 Smart On FHIR 互動所需的各項設定值
  • 滑鼠右擊 [Servicers] 資料夾
  • 點選彈出功能表的 [加入] > [新增項目] 項目
  • 在文字輸入盒內輸入檔案名稱為 [SmartAppSettingService.cs]
  • 然後點擊右下方的 [新增] 按鈕
  • 使用底下程式碼內容來取代剛剛建立的 [SmartAppSettingService.cs] 程式碼檔案內容
using SmartStandalone1.Models;

namespace SmartStandalone1.Servicers;

public class SmartAppSettingService
{
    private readonly SettingService settingService;
    public SmartAppSettingModel Data = new SmartAppSettingModel();

    public SmartAppSettingService(SettingService settingService)
    {
        this.settingService = settingService;

        var data = settingService.GetValue();
        Data.FhirServerUrl = data.FhirServerUrl;
        Data.RedirectUrl = data.RedirectUrl;
        Data.ClientId = data.ClientId;
    }

    public void UpdateSetting(SmartAppSettingModel model)
    {
        Data.FhirServerUrl = model.FhirServerUrl;
        Data.ClientId = model.ClientId;
        Data.RedirectUrl = model.RedirectUrl;
        Data.AuthCode = model.AuthCode;
        Data.ClientState = model.ClientState;
        Data.TokenUrl = model.TokenUrl;
        Data.AuthorizeUrl = model.AuthorizeUrl;
        Data.Iss = model.Iss;
        Data.Launch = model.Launch;
        Data.State = model.State;
    }
}
  • 這個服務類別,將會用於提供 Smart On FHIR 設定模型的相關服務
  • 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到 var app = builder.Build(); 程式碼
  • 在這行程式碼前,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
#region 客製化註冊服務
builder.Services.AddScoped<SmartAppSettingService>();
#endregion
  • 滑鼠右擊 [Models] 資料夾
  • 點選彈出功能表的 [加入] > [新增項目] 項目
  • 在文字輸入盒內輸入檔案名稱為 [SettingModel.cs]
  • 然後點擊右下方的 [新增] 按鈕
  • 使用底下程式碼內容來取代剛剛建立的 [SettingModel.cs] 程式碼檔案內容
namespace SmartStandalone1.Models;

public class SettingModel
{
    public string FhirServerUrl { get; set; }
    public string RedirectUrl { get; set; }
    public string ClientId { get; set; }
}
  • 這個資料模型,將會用於儲存 appsetting.json 的系統設定值
  • 滑鼠右擊 [Servicers] 資料夾
  • 點選彈出功能表的 [加入] > [新增項目] 項目
  • 在文字輸入盒內輸入檔案名稱為 [SettingService.cs]
  • 然後點擊右下方的 [新增] 按鈕
  • 使用底下程式碼內容來取代剛剛建立的 [SettingService.cs] 程式碼檔案內容
using Microsoft.Extensions.Options;
using SmartStandalone1.Models;

namespace SmartStandalone1.Servicers;

public class SettingService
{
    private readonly SettingModel settingModel;

    public SettingService(IOptions<SettingModel> options)
    {
        settingModel = options.Value;
    }

    public SettingModel GetValue()
    {
        return settingModel;
    }
}
  • 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到 #region 客製化註冊服務 程式碼
  • 在這行程式碼後,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
builder.Services.AddScoped<SettingService>();
  • 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到 #region 客製化註冊服務 程式碼
  • 在這行程式碼前,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
#region 加入設定強型別注入宣告
builder.Services.Configure<SettingModel>(builder.Configuration
    .GetSection(MagicObjectHelper.SmartAppSettingKey));
#endregion

加入該系統的 Configuration 內容

  • 開啟專案根目錄下的 [appsettings.json] 設定檔案
  • 找到 "AllowedHosts": "*" 這個內容
  • 在這行程式碼前,加入底下的 json 內容
"SmartAppSetting": {
    "FhirServerUrl": "https://thas.mohw.gov.tw/v/r4/sim/WzIsIiIsIiIsIkFVVE8iLDAsMCwwLCIiLCIiLCIiLCIiLCIiLCIiLCIiLDAsMSwiIl0/fhir",
    "RedirectUrl": "https://localhost:7191/ExchangeToken",
    "ClientId": "smart-app"
}

建立 OAuthStateStoreService 服務

  • 這裡的服務,將會用於儲存 OAuth 狀態資訊,因為,這些資訊將會在進行 OAuth 認證過程中,因為頁面切換而造成遺失
  • 滑鼠右擊 [Servicers] 資料夾
  • 點選彈出功能表的 [加入] > [新增項目] 項目
  • 在文字輸入盒內輸入檔案名稱為 [OAuthStateStoreService.cs]
  • 然後點擊右下方的 [新增] 按鈕
  • 使用底下程式碼內容來取代剛剛建立的 [OAuthStateStoreService.cs] 程式碼檔案內容
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;

namespace SmartStandalone1.Servicers;

public class OAuthStateStoreService
{
    private const string KeyPrefix = "oauth:state:";
    private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);

    private readonly IDistributedCache _cache;

    public OAuthStateStoreService(IDistributedCache cache) => _cache = cache;

    public async Task<string> SaveAsync<T>(string stateId, T state, TimeSpan ttl, CancellationToken ct = default)
    {
        var key = KeyPrefix + stateId;

        var json = JsonSerializer.Serialize(state, JsonOpts);

        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = ttl
        };

        await _cache.SetStringAsync(key, json, options, ct);
        return stateId;
    }

    public async Task<T?> LoadAsync<T>(string stateId, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(stateId)) return default;

        var key = KeyPrefix + stateId;
        var json = await _cache.GetStringAsync(key, ct);

        if (string.IsNullOrWhiteSpace(json)) return default;

        return JsonSerializer.Deserialize<T>(json, JsonOpts);
    }

    public Task RemoveAsync(string stateId, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(stateId)) return Task.CompletedTask;
        return _cache.RemoveAsync(KeyPrefix + stateId, ct);
    }
}
  • 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到 #region 客製化註冊服務 程式碼
  • 在這行程式碼前,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
builder.Services.AddScoped<OAuthStateStoreService>();
  • 由於,在這個系統中,將會採用分散式快取 (Distributed Cache) 來儲存 OAuth 狀態資訊,因此,需要進行分散式快取服務的註冊
  • 在專案根目錄下的 [Program.cs] 程式碼檔案中,找到 #region 客製化註冊服務 程式碼
  • 在這行程式碼前,加入底下的程式碼內容,以便將這個服務類別註冊到依賴注入容器中
// 提供 IDistributedCache 的記憶體實作
builder.Services.AddDistributedMemoryCache();

設計 Launch 頁面與邏輯

  • 在 [Components] > [Pages] 資料夾下
  • 找到並且開啟 [Launch.razor] 程式碼檔案
  • 使用底下程式碼內容來取代剛剛建立的 [Launch.razor] 程式碼檔案內容
@page "/launch"
@inject NavigationManager NavigationManager
<h3>請稍後,正在初始化中</h3>

<div>@authUrlMessage</div>

@code {
    [SupplyParameterFromQuery(Name = "iss")]
    public string? Iss { get; set; }
    [SupplyParameterFromQuery(Name = "launch")]
    public string? LaunchCode { get; set; }

}
  • 在 [Components] > [Pages] 資料夾下
  • 找到並且開啟 [Launch.razor.cs] 程式碼檔案
  • 使用底下程式碼內容來取代剛剛建立的 [Launch.razor.cs] 程式碼檔案內容
using Hl7.Fhir.Model;
using Microsoft.AspNetCore.Components;
using SmartStandalone1.Models;
using SmartStandalone1.Servicers;

namespace SmartStandalone1.Components.Pages;

public partial class Launch
{
    // https://localhost:7191/launch

    // https://localhost:7191/launch?iss=https://thas.mohw.gov.tw/v/r4/fhir&launch=WzAsIiIsIiIsIkFVVE8iLDAsMCwwLCIiLCIiLCIiLCIiLCIiLCIiLCIiLDAsMSwiIl0
    [Inject]
    public SmartAppSettingService SmartAppSettingService { get; init; }
    [Inject]
    public OAuthStateStoreService OAuthStateStoreService { get; init; }

    string authUrlMessage = string.Empty;

    protected override async System.Threading.Tasks.Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            KeepLaunchIss();
            var bar = await GetMetadataAsync();
            var authUrl = await GetAuthorizeUrlAsync();
            authUrlMessage = $"重新導向到授權伺服器:{authUrl}";

            StateHasChanged();

            await System.Threading.Tasks.Task.Delay(4000);

            NavigationManager.NavigateTo(authUrl);
        }
    }

    public void KeepLaunchIss()
    {
        if (string.IsNullOrEmpty(Iss) || string.IsNullOrEmpty(LaunchCode))
        {
            SmartAppSettingService.Data.Iss = null;
            SmartAppSettingService.Data.Launch = null;
            return;
        }
        SmartAppSettingService.Data.Iss = Iss;
        SmartAppSettingService.Data.Launch = LaunchCode;
        SmartAppSettingService.Data.FhirServerUrl = Iss;
    }

    public async Task<bool> GetMetadataAsync()
    {
        Hl7.Fhir.Rest.FhirClient fhirClient = new Hl7.Fhir.Rest.FhirClient(SmartAppSettingService.Data.FhirServerUrl);

        CapabilityStatement capabilities = (CapabilityStatement)(await fhirClient.GetAsync("metadata"));

        foreach (CapabilityStatement.RestComponent restComponent in capabilities.Rest)
        {
            if (restComponent.Security == null)
            {
                continue;
            }

            foreach (Extension securityExt in restComponent.Security.Extension)
            {
                if (securityExt.Url != "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris")
                {
                    continue;
                }

                if ((securityExt.Extension == null) || (securityExt.Extension.Count == 0))
                {
                    continue;
                }

                foreach (Extension smartExt in securityExt.Extension)
                {
                    switch (smartExt.Url)
                    {
                        case "authorize":
                            SmartAppSettingService.Data.AuthorizeUrl = ((FhirUri)smartExt.Value).Value.ToString();
                            break;

                        case "token":
                            SmartAppSettingService.Data.TokenUrl = ((FhirUri)smartExt.Value).Value.ToString();
                            break;
                    }
                }
            }
        }

        if (string.IsNullOrEmpty(SmartAppSettingService.Data.AuthorizeUrl) || string.IsNullOrEmpty(SmartAppSettingService.Data.TokenUrl))
        {
            return false;
        }

        return true;
    }

    public async System.Threading.Tasks.Task<string> GetAuthorizeUrlAsync()
    {
        var state = Guid.NewGuid().ToString("N");
        SmartAppSettingService.Data.State = state;

        await OAuthStateStoreService.SaveAsync<SmartAppSettingModel>(state, SmartAppSettingService.Data, TimeSpan.FromMinutes(10));

        Console.WriteLine($"Generated state: {SmartAppSettingService.Data.State}");
        string launchUrl = $"{SmartAppSettingService.Data.AuthorizeUrl}?response_type=code" +
            $"&client_id={SmartAppSettingService.Data.ClientId}" +
            $"&redirect_uri={Uri.EscapeDataString(SmartAppSettingService.Data.RedirectUrl)}" +
            $"&scope={Uri.EscapeDataString("openid fhirUser profile launch/patient patient/*.read patient/Encounter.read patient/MedicationRequest.read patient/ServiceRequest.read")}" +
            $"&state={SmartAppSettingService.Data.State}" +
            $"&launch={SmartAppSettingService.Data.Launch}" +
            $"&aud={Uri.EscapeDataString(SmartAppSettingService.Data.FhirServerUrl)}";
        return launchUrl;
    } 

} 




2026年1月13日 星期二

FHIR 11 如何對FhirClient的API呼叫,看到實際的 HTTP Payload

 

FHIR 11 如何對FhirClient的API呼叫,看到實際的 HTTP Payload

對於.NET的開發者,想要存取 FHIR Server上的資料,透過 FhirClient 物件來操作,是最為方便與簡潔的作法,想要使用這個物件,在 .NET 下主要需要安裝 Firely SDK(前身是 FHIR .NET API),在這裡的例子將會安裝了 Hl7.Fhir.R4 套件。

一旦安裝了安裝了 Hl7.Fhir.R4 套件之後,就可以在程式碼中使用 FhirClient 物件來對 FHIR Server 進行各種操作,例如讀取、查詢、更新、刪除等。可以想像你要去醫院查病歷資料,FhirClient 就像是一個「翻譯員 + 信差」:

  • 翻譯員角色:把你的請求(「我要查某個病人的資料」)翻譯成 FHIR 伺服器能懂的語言
  • 信差角色:幫你把請求送到伺服器,再把結果帶回來給你

因此,面對 FHIR Server 中超過上百個 Resource,操作起來更加輕鬆雨容易,這裡可以做到這些實際用途,例如:讀取病人資料(Patient)、查詢檢驗結果(Observation)、取得用藥記錄(Medication)、新增或更新醫療資料等。

就技術上來說,FhirClient 是一個程式庫(library),封裝了 HTTP 請求、資料格式轉換、錯誤處理等複雜細節,讓開發者可以用簡單的程式碼就能存取 FHIR 醫療資料,不用自己處理那些繁瑣的通訊協定和資料格式。簡單來說,它就是讓程式開發者能輕鬆存取 FHIR 醫療資料標準系統的工具。

有些時候,開發者可能會想要知道 FhirClient 在背後實際傳送了什麼 HTTP 請求,或是伺服器回傳了什麼樣的 HTTP 回應,這對於除錯、效能優化、了解系統行為等都很有幫助。甚至可以透過這些 HTTP 請求與回應,更深入的取學習與理解 FHIR API 的用法。

建立 Http 管道處理器

  • 延續 文章中做做出的程式碼
  • 在 [Program.cs] 檔案中找到 namespace csPatientCRUD;,在其下方加入這個類別定義
public class HttpLoggingHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var startTime = DateTime.UtcNow;

        // 記錄請求
        Console.WriteLine("===== HTTP 請求 =====");
        Console.WriteLine($"{request.Method} {request.RequestUri}");
        Console.WriteLine("標頭:");
        foreach (var header in request.Headers)
        {
            Console.WriteLine($"  {header.Key}: {string.Join(", ", header.Value)}");
        }

        if (request.Content != null)
        {
            foreach (var header in request.Content.Headers)
            {
                Console.WriteLine($"  {header.Key}: {string.Join(", ", header.Value)}");
            }

            var requestBody = await request.Content.ReadAsStringAsync(cancellationToken);
            if (!string.IsNullOrEmpty(requestBody))
            {
                Console.WriteLine("請求內容:");
                Console.WriteLine(requestBody);
            }
        }

        // 發送請求
        var response = await base.SendAsync(request, cancellationToken);

        var duration = DateTime.UtcNow - startTime;

        // 記錄回應
        Console.WriteLine("\n===== HTTP 回應 =====");
        Console.WriteLine($"狀態: {(int)response.StatusCode} {response.StatusCode}");
        Console.WriteLine("標頭:");
        foreach (var header in response.Headers)
        {
            Console.WriteLine($"  {header.Key}: {string.Join(", ", header.Value)}");
        }

        if (response.Content != null)
        {
            foreach (var header in response.Content.Headers)
            {
                Console.WriteLine($"  {header.Key}: {string.Join(", ", header.Value)}");
            }

            var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
            if (!string.IsNullOrEmpty(responseBody))
            {
                Console.WriteLine("回應內容:");
                Console.WriteLine(responseBody);
            }
        }

        Console.WriteLine($"耗時: {duration.TotalMilliseconds:F2} ms");
        Console.WriteLine("".PadRight(50, '='));

        return response;
    }
}
  • [DelegatingHandler 類別] (https://learn.microsoft.com/zh-tw/dotnet/api/system.net.http.delegatinghandler?view=net-8.0) 是 .NET 提供的一個抽象類別,可以用來建立自訂的 HTTP 處理器(Handler)。這些處理器可以用來攔截、修改、記錄或處理 HTTP 請求和回應。透過繼承 DelegatingHandler,開發者可以實現自己的邏輯,並將其插入到 HTTP 請求管道中。
  • 這裡新建立的 [HttpLoggingHandler] 類別將會繼承了這個 [DelegatingHandler] 類別,因此,可以用來攔截 HTTP 請求與回應,並記錄相關的資訊,例如請求方法、URL、標頭、內容,以及回應的狀態碼、標頭、內容等。
  • 此類別覆寫了 [SendAsync] 方法,這個方法會在每次發送 HTTP 請求時被呼叫。
  • 在這個方法中,我們先記錄請求的詳細資訊,然後呼叫 base.SendAsync 方法來發送請求,接著再記錄回應的詳細資訊。最後,將回應物件返回。
  • 這裡也會將 HTTP Header 的資訊與內容(Content)都記錄下來,方便後續查看。
  • 當 [SendAsync] 方法被呼叫後,會得到一個 [HttpResponseMessage] 物件,代表伺服器的回應。
  • 有了這個物件,便可以取得此次 HTTP 回應的原始內容,例如狀態碼、標頭、內容等,並將這些資訊記錄下來。
  • 最後,這個類別也會計算出每次呼叫 FHIR API 需要花費的時間成本。
  • 這樣一來,每次透過 FhirClient 發送的 HTTP 請求與回應,都會被這個處理器攔截並記錄,方便開發者查看實際的 HTTP Payload。

在 FhirClient 中使用 HttpLoggingHandler

  • 在 [Program.cs] 檔案中,找到 var httpClient = new HttpClient(); 這一行程式碼,將其修改為以下內容:
var httpHandler = new HttpClientHandler();
var loggingHandler = new HttpLoggingHandler { InnerHandler = httpHandler };
var httpClient = new HttpClient(loggingHandler);
  • 這段程式碼中,我們先建立了一個 [HttpClientHandler] 物件,這是 .NET 提供的預設 HTTP 處理器,負責處理實際的 HTTP 通訊。
  • 接著,我們建立了一個 [HttpLoggingHandler] 物件,並將剛剛建立的 [HttpClientHandler] 設定為它的 [InnerHandler],這樣當 [HttpLoggingHandler] 收到請求時,就會將請求傳遞給內部的 [HttpClientHandler] 來處理。
  • 最後,我們使用這個 [HttpLoggingHandler] 來建立 [HttpClient] 物件,這樣所有透過這個 [HttpClient] 發送的請求,都會先經過我們的日誌處理器,從而記錄下詳細的 HTTP 請求與回應資訊。
  • 這樣一來,當我們使用 FhirClient 進行各種操作時,例如讀取病人資料、查詢檢驗結果等,都會觸發我們的 [HttpLoggingHandler],從而在控制台中看到詳細的 HTTP 請求與回應內容,方便我們進行除錯與分析。

測試 FhirClient 的 API 呼叫

  • 執行 [csPatientCRUD] 專案,觀察控制台輸出
Creating Patient ...
JSON: {"resourceType":"Patient","identifier":[{"system":"http://example.org/mrn","value":"MRN-20240814A1"}],"active":true,"name":[{"family":"Lee","given":["Vulcan20250814111"]}],"telecom":[{"system":"phone","value":"0912-345-678","use":"mobile"}],"gender":"female","birthDate":"1990-01-01"}
===== HTTP 請求 =====
POST https://hapi.fhir.org/baseR4/Patient
標頭:
  Accept: application/fhir+json
  Accept-Charset: utf-8
  User-Agent: firely-sdk-client/5.12.1
  Content-Type: application/fhir+json; charset=utf-8
請求內容:
{"resourceType":"Patient","identifier":[{"system":"http://example.org/mrn","value":"MRN-20240814A1"}],"active":true,"name":[{"family":"Lee","given":["Vulcan20250814111"]}],"telecom":[{"system":"phone","value":"0912-345-678","use":"mobile"}],"gender":"female","birthDate":"1990-01-01"}

===== HTTP 回應 =====
狀態: 201 Created
標頭:
  Server: nginx/1.24.0, (Ubuntu)
  Date: Tue, 13 Jan 2026 08:29:11 GMT
  Transfer-Encoding: chunked
  Connection: keep-alive
  X-Powered-By: HAPI FHIR 8.5.3-SNAPSHOT/e3a3c5f741/2025-08-28 REST Server (FHIR Server; FHIR 4.0.1/R4)
  ETag: W/"1"
  X-Request-ID: I9kYzrNbqdbmsueL
  Location: https://hapi.fhir.org/baseR4/Patient/53805146/_history/1
  Content-Type: application/fhir+json; charset=utf-8
  Content-Location: https://hapi.fhir.org/baseR4/Patient/53805146/_history/1
  Last-Modified: Tue, 13 Jan 2026 08:29:11 GMT
回應內容:
{
  "resourceType": "Patient",
  "id": "53805146",
  "meta": {
    "versionId": "1",
    "lastUpdated": "2026-01-13T08:29:11.623+00:00",
    "source": "#I9kYzrNbqdbmsueL"
  },
  "text": {
    "status": "generated",
    "div": "<div xmlns=\"http://www.w3.org/1999/xhtml\"><div class=\"hapiHeaderText\">Vulcan20250814111 <b>LEE </b></div><table class=\"hapiPropertyTable\"><tbody><tr><td>Identifier</td><td>MRN-20240814A1</td></tr><tr><td>Date of birth</td><td><span>01 January 1990</span></td></tr></tbody></table></div>"
  },
  "identifier": [ {
    "system": "http://example.org/mrn",
    "value": "MRN-20240814A1"
  } ],
  "active": true,
  "name": [ {
    "family": "Lee",
    "given": [ "Vulcan20250814111" ]
  } ],
  "telecom": [ {
    "system": "phone",
    "value": "0912-345-678",
    "use": "mobile"
  } ],
  "gender": "female",
  "birthDate": "1990-01-01"
}
耗時: 1122.91 ms
==================================================
Created: id=53805146, version=1
Press any key to continue...
 Reading Patient by id ...
===== HTTP 請求 =====
GET https://hapi.fhir.org/baseR4/Patient/53805146
標頭:
  Accept: application/fhir+json
  Accept-Charset: utf-8
  User-Agent: firely-sdk-client/5.12.1

===== HTTP 回應 =====
狀態: 200 OK
標頭:
  Server: nginx/1.24.0, (Ubuntu)
  Date: Tue, 13 Jan 2026 08:29:14 GMT
  Transfer-Encoding: chunked
  Connection: keep-alive
  X-Powered-By: HAPI FHIR 8.5.3-SNAPSHOT/e3a3c5f741/2025-08-28 REST Server (FHIR Server; FHIR 4.0.1/R4)
  ETag: W/"1"
  X-Request-ID: 6eD6YAfNWkKzdFl5
  Content-Type: application/fhir+json; charset=utf-8
  Content-Location: https://hapi.fhir.org/baseR4/Patient/53805146/_history/1
  Last-Modified: Tue, 13 Jan 2026 08:29:11 GMT
回應內容:
{
  "resourceType": "Patient",
  "id": "53805146",
  "meta": {
    "versionId": "1",
    "lastUpdated": "2026-01-13T08:29:11.623+00:00",
    "source": "#I9kYzrNbqdbmsueL"
  },
  "text": {
    "status": "generated",
    "div": "<div xmlns=\"http://www.w3.org/1999/xhtml\"><div class=\"hapiHeaderText\">Vulcan20250814111 <b>LEE </b></div><table class=\"hapiPropertyTable\"><tbody><tr><td>Identifier</td><td>MRN-20240814A1</td></tr><tr><td>Date of birth</td><td><span>01 January 1990</span></td></tr></tbody></table></div>"
  },
  "identifier": [ {
    "system": "http://example.org/mrn",
    "value": "MRN-20240814A1"
  } ],
  "active": true,
  "name": [ {
    "family": "Lee",
    "given": [ "Vulcan20250814111" ]
  } ],
  "telecom": [ {
    "system": "phone",
    "value": "0912-345-678",
    "use": "mobile"
  } ],
  "gender": "female",
  "birthDate": "1990-01-01"
}
耗時: 200.41 ms
==================================================
Read: Vulcan20250814111 Lee | active=True
Press any key to continue...
 Updating Patient (add email, set active=false) ...
===== HTTP 請求 =====
PUT https://hapi.fhir.org/baseR4/Patient/53805146
標頭:
  Accept: application/fhir+json
  Accept-Charset: utf-8
  User-Agent: firely-sdk-client/5.12.1
  Content-Type: application/fhir+json; charset=utf-8
  Last-Modified: Tue, 13 Jan 2026 08:29:11 GMT
請求內容:
{"resourceType":"Patient","id":"53805146","meta":{"versionId":"1","lastUpdated":"2026-01-13T08:29:11.623+00:00","source":"#I9kYzrNbqdbmsueL"},"text":{"status":"generated","div":"<div xmlns=\"http://www.w3.org/1999/xhtml\"><div class=\"hapiHeaderText\">Vulcan20250814111 <b>LEE </b></div><table class=\"hapiPropertyTable\"><tbody><tr><td>Identifier</td><td>MRN-20240814A1</td></tr><tr><td>Date of birth</td><td><span>01 January 1990</span></td></tr></tbody></table></div>"},"identifier":[{"system":"http://example.org/mrn","value":"MRN-20240814A1"}],"active":false,"name":[{"family":"Lee","given":["Vulcan20250814111"]}],"telecom":[{"system":"phone","value":"0912-345-678","use":"mobile"},{"system":"email","value":"Vulcan20250814111.Lee@example.org"}],"gender":"female","birthDate":"1990-01-01"}

===== HTTP 回應 =====
狀態: 200 OK
標頭:
  Server: nginx/1.24.0, (Ubuntu)
  Date: Tue, 13 Jan 2026 08:29:17 GMT
  Transfer-Encoding: chunked
  Connection: keep-alive
  X-Powered-By: HAPI FHIR 8.5.3-SNAPSHOT/e3a3c5f741/2025-08-28 REST Server (FHIR Server; FHIR 4.0.1/R4)
  ETag: W/"2"
  X-Request-ID: etoip1zPped1yLq4
  Content-Type: application/fhir+json; charset=utf-8
  Content-Location: https://hapi.fhir.org/baseR4/Patient/53805146/_history/2
  Last-Modified: Tue, 13 Jan 2026 08:29:17 GMT
回應內容:
{
  "resourceType": "Patient",
  "id": "53805146",
  "meta": {
    "versionId": "2",
    "lastUpdated": "2026-01-13T08:29:17.196+00:00",
    "source": "#etoip1zPped1yLq4"
  },
  "text": {
    "status": "generated",
    "div": "<div xmlns=\"http://www.w3.org/1999/xhtml\"><div class=\"hapiHeaderText\">Vulcan20250814111 <b>LEE </b></div><table class=\"hapiPropertyTable\"><tbody><tr><td>Identifier</td><td>MRN-20240814A1</td></tr><tr><td>Date of birth</td><td><span>01 January 1990</span></td></tr></tbody></table></div>"
  },
  "identifier": [ {
    "system": "http://example.org/mrn",
    "value": "MRN-20240814A1"
  } ],
  "active": false,
  "name": [ {
    "family": "Lee",
    "given": [ "Vulcan20250814111" ]
  } ],
  "telecom": [ {
    "system": "phone",
    "value": "0912-345-678",
    "use": "mobile"
  }, {
    "system": "email",
    "value": "Vulcan20250814111.Lee@example.org"
  } ],
  "gender": "female",
  "birthDate": "1990-01-01"
}
耗時: 232.04 ms
==================================================
Updated: version=2, telecom=Phone:0912-345-678, Email:Vulcan20250814111.Lee@example.org
Press any key to continue...
 Searching Patient by identifier 'MRN-20240814A1' ...
===== HTTP 請求 =====
GET https://hapi.fhir.org/baseR4/Patient?_count=5&identifier=MRN-20240814A1
標頭:
  Accept: application/fhir+json
  Accept-Charset: utf-8
  User-Agent: firely-sdk-client/5.12.1

===== HTTP 回應 =====
狀態: 200 OK
標頭:
  Server: nginx/1.24.0, (Ubuntu)
  Date: Tue, 13 Jan 2026 08:29:18 GMT
  Transfer-Encoding: chunked
  Connection: keep-alive
  X-Powered-By: HAPI FHIR 8.5.3-SNAPSHOT/e3a3c5f741/2025-08-28 REST Server (FHIR Server; FHIR 4.0.1/R4)
  X-Request-ID: UbbmBpPRELgqIKJ1
  Content-Type: application/fhir+json; charset=utf-8
  Last-Modified: Tue, 13 Jan 2026 08:29:18 GMT
回應內容:
{
  "resourceType": "Bundle",
  "id": "3b672bdf-058e-4580-bab8-dbf3e6335188",
  "meta": {
    "lastUpdated": "2026-01-13T08:29:18.646+00:00"
  },
  "type": "searchset",
  "total": 1,
  "link": [ {
    "relation": "self",
    "url": "https://hapi.fhir.org/baseR4/Patient?_count=5&identifier=MRN-20240814A1"
  } ],
  "entry": [ {
    "fullUrl": "https://hapi.fhir.org/baseR4/Patient/53805146",
    "resource": {
      "resourceType": "Patient",
      "id": "53805146",
      "meta": {
        "versionId": "2",
        "lastUpdated": "2026-01-13T08:29:17.196+00:00",
        "source": "#etoip1zPped1yLq4"
      },
      "text": {
        "status": "generated",
        "div": "<div xmlns=\"http://www.w3.org/1999/xhtml\"><div class=\"hapiHeaderText\">Vulcan20250814111 <b>LEE </b></div><table class=\"hapiPropertyTable\"><tbody><tr><td>Identifier</td><td>MRN-20240814A1</td></tr><tr><td>Date of birth</td><td><span>01 January 1990</span></td></tr></tbody></table></div>"
      },
      "identifier": [ {
        "system": "http://example.org/mrn",
        "value": "MRN-20240814A1"
      } ],
      "active": false,
      "name": [ {
        "family": "Lee",
        "given": [ "Vulcan20250814111" ]
      } ],
      "telecom": [ {
        "system": "phone",
        "value": "0912-345-678",
        "use": "mobile"
      }, {
        "system": "email",
        "value": "Vulcan20250814111.Lee@example.org"
      } ],
      "gender": "female",
      "birthDate": "1990-01-01"
    },
    "search": {
      "mode": "match"
    }
  } ]
}
耗時: 207.54 ms
==================================================
Search total (if provided): 1
 - 53805146 | Vulcan20250814111 Lee | active=False
Press any key to continue...
 Deleting Patient ...
===== HTTP 請求 =====
DELETE https://hapi.fhir.org/baseR4/Patient/53805146
標頭:
  Accept: application/fhir+json
  Accept-Charset: utf-8
  User-Agent: firely-sdk-client/5.12.1

===== HTTP 回應 =====
狀態: 200 OK
標頭:
  Server: nginx/1.24.0, (Ubuntu)
  Date: Tue, 13 Jan 2026 08:29:21 GMT
  Transfer-Encoding: chunked
  Connection: keep-alive
  X-Powered-By: HAPI FHIR 8.5.3-SNAPSHOT/e3a3c5f741/2025-08-28 REST Server (FHIR Server; FHIR 4.0.1/R4)
  X-Request-ID: 483EQAcOrq348Tca
  Content-Type: application/fhir+json; charset=utf-8
  Content-Location: https://hapi.fhir.org/baseR4/Patient/53805146/_history/3
回應內容:
{
  "resourceType": "OperationOutcome",
  "text": {
    "status": "generated",
    "div": "<div xmlns=\"http://www.w3.org/1999/xhtml\"><h1>Operation Outcome</h1><table border=\"0\"><tr><td style=\"font-weight: bold;\">INFORMATION</td><td>[]</td><td>Successfully deleted 1 resource(s). Took 18ms.</td></tr></table></div>"
  },
  "issue": [ {
    "severity": "information",
    "code": "informational",
    "details": {
      "coding": [ {
        "system": "https://hapifhir.io/fhir/CodeSystem/hapi-fhir-storage-response-code",
        "code": "SUCCESSFUL_DELETE",
        "display": "Delete succeeded."
      } ]
    },
    "diagnostics": "Successfully deleted 1 resource(s). Took 18ms."
  } ]
}
耗時: 226.93 ms
==================================================
Deleted.
===== HTTP 請求 =====
GET https://hapi.fhir.org/baseR4/Patient/53805146
標頭:
  Accept: application/fhir+json
  Accept-Charset: utf-8
  User-Agent: firely-sdk-client/5.12.1

===== HTTP 回應 =====
狀態: 410 Gone
標頭:
  Server: nginx/1.24.0, (Ubuntu)
  Date: Tue, 13 Jan 2026 08:29:22 GMT
  Transfer-Encoding: chunked
  Connection: keep-alive
  X-Powered-By: HAPI FHIR 8.5.3-SNAPSHOT/e3a3c5f741/2025-08-28 REST Server (FHIR Server; FHIR 4.0.1/R4)
  X-Request-ID: bxMyna52dXXlZoKB
  Location: https://hapi.fhir.org/baseR4/Patient/53805146/_history/3
  Content-Type: application/fhir+json; charset=utf-8
回應內容:
{
  "resourceType": "OperationOutcome",
  "text": {
    "status": "generated",
    "div": "<div xmlns=\"http://www.w3.org/1999/xhtml\"><h1>Operation Outcome</h1><table border=\"0\"><tr><td style=\"font-weight: bold;\">ERROR</td><td>[]</td><td>Resource was deleted at 2026-01-13T08:29:21.412+00:00</td></tr></table></div>"
  },
  "issue": [ {
    "severity": "error",
    "code": "processing",
    "diagnostics": "Resource was deleted at 2026-01-13T08:29:21.412+00:00"
  } ]
}
耗時: 977.67 ms
==================================================
Confirmed 410 Gone after delete.
Press any key to continue...