Flurl

流暢で使いやすいURL構築とHTTPクライアント機能を提供する.NETライブラリ。メソッドチェーンによる直感的なAPI、優れたテスト可能性、JSON統合、エラーハンドリング機能を特徴とする。HttpClientベースで現代的な非同期通信パターンを支援。

HTTPクライアント.NET流暢なAPIURL構築テスト可能

GitHub概要

tmenier/Flurl

Fluent URL builder and testable HTTP client for .NET

ホームページ:https://flurl.dev
スター4,363
ウォッチ97
フォーク397
作成日:2014年2月16日
言語:C#
ライセンス:MIT License

トピックス

c-sharpdotnethttprest-clienturl-builder

スター履歴

tmenier/Flurl Star History
データ取得日時: 2025/10/22 09:55

ライブラリ

Flurl

概要

Flurlは「.NET向けの流暢なURL構築とテスト可能なHTTPクライアント」として開発された、.NETエコシステムで注目を集めている現代的なHTTPライブラリです。「流暢で連鎖可能な構文」をコンセプトに、URL構築とHTTP通信を統合した直感的なAPIを提供。JSON処理、認証、テスト機能、クエリパラメータ処理など、Web API統合に必要な機能を包括的にサポートし、.NET Standard準拠により多様なプラットフォームで動作する次世代HTTPクライアントとして地位を築いています。

詳細

Flurl 2025年版は.NET HTTPクライアントの新しいパラダイムを提案する革新的なライブラリとして確固たる地位を築いています。従来のHttpClientとは異なる流暢なAPI設計により、URL構築からHTTPリクエスト実行まで一連の処理を直感的なメソッドチェーンで記述可能。.NET Framework、.NET Core、Xamarin、UWPなど幅広いプラットフォームをサポートし、モダンな.NET開発において高い生産性を実現します。特にテスト機能に特化した設計により、単体テストでのHTTP通信モックが非常に簡単に実装できる点が他のライブラリにない大きな特徴です。

主な特徴

  • 流暢なAPI設計: 読みやすく連鎖可能なメソッドチェーン構文
  • 統合URL構築: URLとHTTPリクエストの一体化されたビルダーパターン
  • 包括的JSON処理: シリアライゼーション・デシリアライゼーションの自動処理
  • 優れたテスト機能: モック化とテストに特化した設計
  • マルチプラットフォーム対応: .NET Standard準拠による幅広い環境サポート
  • 柔軟な認証システム: OAuth、Bearer Token、カスタム認証の簡易実装

メリット・デメリット

メリット

  • 流暢なAPI構文による優れたコード可読性と保守性の向上
  • URL構築とHTTPリクエストが統合された直感的な開発体験
  • 単体テストでのHTTP通信モック化が非常に簡単
  • JSON処理の自動化による開発効率の大幅向上
  • .NET Standard準拠による幅広いプラットフォーム対応
  • 豊富なHTTPメソッドとヘッダーカスタマイズ機能

デメリット

  • 流暢なAPIに慣れていない開発者にとっての学習コストの存在
  • 文字列拡張メソッドの使用に対する議論(オプションだが)
  • 大規模プロジェクトでの依存関係隠蔽による可視性の課題
  • HttpClientと比較した場合の機能的な制約(一部の低レベル制御)
  • 相対的に新しいライブラリであるため企業採用における慎重な検討が必要
  • 複雑なHTTP設定を要する場合の柔軟性の限界

参考ページ

書き方の例

インストールと基本セットアップ

# Flurl HTTP クライアント(URL構築機能を含む)のインストール
Install-Package Flurl.Http

# URL構築のみ必要な場合
Install-Package Flurl

# .NET CLI使用時
dotnet add package Flurl.Http

# パッケージマネージャーコンソールでの確認
Get-Package Flurl.Http

基本的なHTTPリクエスト(GET/POST/PUT/DELETE)

using Flurl;
using Flurl.Http;

// 基本的なGETリクエスト
var users = await "https://api.example.com/users"
    .GetJsonAsync<List<User>>();

Console.WriteLine($"取得したユーザー数: {users.Count}");

// クエリパラメータ付きGETリクエスト
var filteredUsers = await "https://api.example.com/users"
    .SetQueryParams(new { 
        page = 1, 
        limit = 10, 
        sort = "created_at",
        status = "active" 
    })
    .GetJsonAsync<ApiResponse<User>>();

// 動的URL構築とGETリクエスト
var userId = 123;
var userDetail = await "https://api.example.com"
    .AppendPathSegment("users")
    .AppendPathSegment(userId)
    .SetQueryParam("include", "profile,settings")
    .GetJsonAsync<UserDetail>();

Console.WriteLine($"ユーザー名: {userDetail.Name}");

// POSTリクエスト(JSON送信)
var newUser = new User
{
    Name = "田中太郎",
    Email = "[email protected]",
    Age = 30,
    Department = "開発部"
};

var createdUser = await "https://api.example.com/users"
    .WithHeader("Authorization", "Bearer your-jwt-token")
    .PostJsonAsync(newUser)
    .ReceiveJson<User>();

Console.WriteLine($"作成されたユーザーID: {createdUser.Id}");

// PUTリクエスト(データ更新)
var updatedData = new { Name = "田中次郎", Email = "[email protected]" };

var updatedUser = await $"https://api.example.com/users/{userId}"
    .WithOAuthBearerToken("your-oauth-token")
    .PutJsonAsync(updatedData)
    .ReceiveJson<User>();

// DELETEリクエスト
var deleteResponse = await $"https://api.example.com/users/{userId}"
    .WithHeader("Authorization", "Bearer your-token")
    .DeleteAsync();

if (deleteResponse.IsSuccessStatusCode)
{
    Console.WriteLine("ユーザー削除完了");
}

// フォームデータ送信
var loginResult = await "https://api.example.com/login"
    .PostUrlEncodedAsync(new { 
        username = "testuser", 
        password = "secret123",
        remember_me = true 
    })
    .ReceiveJson<LoginResponse>();

// ファイルアップロード
var uploadResult = await "https://api.example.com/upload"
    .WithOAuthBearerToken("your-token")
    .PostMultipartAsync(mp => {
        mp.AddFile("file", "/path/to/document.pdf", "application/pdf");
        mp.AddString("category", "documents");
        mp.AddString("public", "false");
    })
    .ReceiveJson<UploadResponse>();

Console.WriteLine($"アップロード完了: {uploadResult.FileId}");

高度な設定とカスタマイズ(ヘッダー、認証、タイムアウト等)

using Flurl;
using Flurl.Http;
using Flurl.Http.Configuration;

// カスタムヘッダーの設定
var response = await "https://api.example.com/data"
    .WithHeaders(new {
        User_Agent = "MyApp/1.0 (Flurl Client)",
        Accept = "application/json",
        Accept_Language = "ja-JP,en-US",
        X_API_Version = "v2",
        X_Request_ID = "req-12345"
    })
    .GetJsonAsync<ApiData>();

// 認証設定(複数パターン)

// Bearer Token認証
var bearerData = await "https://api.example.com/protected"
    .WithOAuthBearerToken("your-jwt-token")
    .GetJsonAsync<ProtectedData>();

// Basic認証
var basicAuthData = await "https://api.example.com/private"
    .WithBasicAuth("username", "password")
    .GetJsonAsync<PrivateData>();

// カスタムヘッダー認証
var apiKeyData = await "https://api.example.com/secure"
    .WithHeader("X-API-Key", "your-api-key")
    .WithHeader("X-Client-Id", "client-123")
    .GetJsonAsync<SecureData>();

// タイムアウト設定
try
{
    var slowData = await "https://api.example.com/slow-endpoint"
        .WithTimeout(TimeSpan.FromSeconds(30))  // 30秒タイムアウト
        .GetJsonAsync<SlowData>();
}
catch (FlurlHttpTimeoutException)
{
    Console.WriteLine("リクエストがタイムアウトしました");
}

// Cookieの使用
var session = await "https://api.example.com/login"
    .WithCookies(out var cookies)  // Cookieコンテナの作成
    .PostJsonAsync(new { username = "user", password = "pass" })
    .ReceiveJson<LoginResult>();

// 同じCookieセッションで後続リクエスト
var userData = await "https://api.example.com/user-data"
    .WithCookies(cookies)  // 前回のCookieを使用
    .GetJsonAsync<UserData>();

// SSL設定とクライアント証明書
var secureClient = new FlurlClient("https://secure-api.example.com")
    .Configure(settings => {
        settings.HttpClientFactory = new DefaultHttpClientFactory();
        // クライアント証明書の設定は HttpClientHandler で行う
    });

var secureData = await secureClient
    .Request("data")
    .GetJsonAsync<SecureApiData>();

// プロキシ設定
FlurlHttp.Configure(settings => {
    settings.HttpClientFactory = new DefaultHttpClientFactory();
    // プロキシ設定は HttpClientHandler で行う
});

// グローバル設定のカスタマイズ
FlurlHttp.Configure(settings => {
    settings.Timeout = TimeSpan.FromSeconds(60);  // デフォルトタイムアウト
    settings.AllowedHttpStatusRange = "200-299,404";  // 許可ステータス範囲
});

// クライアント別設定
var apiClient = new FlurlClient("https://api.example.com")
    .Configure(settings => {
        settings.Timeout = TimeSpan.FromSeconds(30);
        settings.BeforeCall = call => {
            Console.WriteLine($"呼び出し開始: {call.Request.Url}");
        };
        settings.AfterCall = call => {
            Console.WriteLine($"呼び出し完了: {call.Response.StatusCode}");
        };
    });

// レート制限対応
var rateLimitedData = await "https://api.example.com/rate-limited"
    .WithHeader("X-RateLimit-User", "user123")
    .OnError(call => {
        if (call.Response?.StatusCode == 429) // Too Many Requests
        {
            var retryAfter = call.Response.Headers.FirstOrDefault("Retry-After");
            Console.WriteLine($"レート制限: {retryAfter}秒後に再試行");
        }
    })
    .GetJsonAsync<RateLimitedData>();

エラーハンドリングとリトライ機能

using Flurl;
using Flurl.Http;
using Polly;
using Polly.Extensions.Http;

// 包括的なエラーハンドリング
async Task<T> SafeApiCall<T>(string url) where T : class
{
    try
    {
        return await url
            .WithTimeout(30)
            .GetJsonAsync<T>();
    }
    catch (FlurlHttpTimeoutException)
    {
        Console.WriteLine("タイムアウトエラー: リクエストが時間内に完了しませんでした");
        return null;
    }
    catch (FlurlHttpException ex)
    {
        Console.WriteLine($"HTTPエラー: {ex.StatusCode}");
        
        if (ex.StatusCode == 401)
        {
            Console.WriteLine("認証エラー: トークンを確認してください");
        }
        else if (ex.StatusCode == 403)
        {
            Console.WriteLine("権限エラー: アクセス権限がありません");
        }
        else if (ex.StatusCode == 404)
        {
            Console.WriteLine("見つかりません: リソースが存在しません");
        }
        else if (ex.StatusCode == 429)
        {
            Console.WriteLine("レート制限: しばらく待ってから再試行してください");
        }
        else if (ex.StatusCode >= 500)
        {
            Console.WriteLine("サーバーエラー: サーバー側の問題です");
            var errorDetails = await ex.GetResponseStringAsync();
            Console.WriteLine($"エラー詳細: {errorDetails}");
        }
        
        return null;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"予期しないエラー: {ex.Message}");
        return null;
    }
}

// 使用例
var userData = await SafeApiCall<UserData>("https://api.example.com/users/123");
if (userData != null)
{
    Console.WriteLine($"ユーザー名: {userData.Name}");
}

// Flurlのビルトインエラーハンドリング
var result = await "https://api.example.com/data"
    .OnError(call => {
        Console.WriteLine($"エラー発生: {call.Exception.Message}");
        Console.WriteLine($"URL: {call.Request.Url}");
        Console.WriteLine($"ステータス: {call.Response?.StatusCode}");
        
        // エラーの詳細ログ記録
        LogApiError(call);
    })
    .OnErrorAsync(async call => {
        // 非同期エラー処理
        await LogApiErrorAsync(call);
    })
    .GetJsonAsync<ApiData>();

// Pollyライブラリを使用した高度なリトライ機能
private static readonly HttpPolicyWrap<HttpResponseMessage> RetryPolicy = 
    Policy.WrapAsync(
        // 指数バックオフ付きリトライ
        HttpPolicyExtensions
            .HandleTransientHttpError()
            .OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests)
            .WaitAndRetryAsync(
                retryCount: 3,
                sleepDurationProvider: retryAttempt => 
                    TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // 2, 4, 8秒
                onRetry: (outcome, timespan, retryCount, context) => {
                    Console.WriteLine($"リトライ {retryCount}: {timespan}秒後");
                }),
        
        // サーキットブレーカー
        HttpPolicyExtensions
            .HandleTransientHttpError()
            .CircuitBreakerAsync(
                handledEventsAllowedBeforeBreaking: 3,
                durationOfBreak: TimeSpan.FromSeconds(30),
                onBreak: (exception, duration) => {
                    Console.WriteLine($"サーキットブレーカー開放: {duration}秒間");
                },
                onReset: () => {
                    Console.WriteLine("サーキットブレーカー復旧");
                })
    );

// Pollyポリシーを使用したHTTPクライアント設定
FlurlHttp.Configure(settings => {
    settings.HttpClientFactory = new PollyHttpClientFactory(RetryPolicy);
});

// 手動リトライ実装
async Task<T> RetryApiCall<T>(string url, int maxRetries = 3) where T : class
{
    var delay = TimeSpan.FromSeconds(1);
    
    for (int attempt = 1; attempt <= maxRetries; attempt++)
    {
        try
        {
            return await url.GetJsonAsync<T>();
        }
        catch (FlurlHttpException ex) when (
            ex.StatusCode >= 500 || 
            ex.StatusCode == 429 ||
            ex.StatusCode == 408) // リトライ対象のステータス
        {
            if (attempt == maxRetries)
            {
                Console.WriteLine($"最大試行回数に達しました: {ex.StatusCode}");
                throw;
            }
            
            Console.WriteLine($"試行 {attempt} 失敗. {delay.TotalSeconds}秒後に再試行...");
            await Task.Delay(delay);
            delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // 指数バックオフ
        }
    }
    
    return null;
}

// ステータスコード別の詳細処理
var response = await "https://api.example.com/status-check"
    .AllowHttpStatus("200-299,404") // 404も成功として扱う
    .GetAsync();

switch (response.StatusCode)
{
    case 200:
        var data = await response.GetJsonAsync<ApiData>();
        Console.WriteLine($"成功: {data}");
        break;
    case 401:
        Console.WriteLine("認証エラー: 新しいトークンが必要です");
        await RefreshAuthToken();
        break;
    case 403:
        Console.WriteLine("権限エラー: 管理者に連絡してください");
        break;
    case 404:
        Console.WriteLine("リソースが見つかりません(正常なケース)");
        break;
    case 429:
        var retryAfter = response.Headers.FirstOrDefault("Retry-After");
        Console.WriteLine($"レート制限: {retryAfter}秒待機");
        break;
    default:
        Console.WriteLine($"予期しないステータス: {response.StatusCode}");
        break;
}

// カスタム例外処理クラス
public class ApiClient
{
    private readonly string _baseUrl;
    private readonly string _apiKey;
    
    public ApiClient(string baseUrl, string apiKey)
    {
        _baseUrl = baseUrl;
        _apiKey = apiKey;
    }
    
    public async Task<T> GetAsync<T>(string endpoint) where T : class
    {
        try
        {
            return await _baseUrl
                .AppendPathSegment(endpoint)
                .WithHeader("X-API-Key", _apiKey)
                .WithTimeout(30)
                .GetJsonAsync<T>();
        }
        catch (FlurlHttpException ex)
        {
            await HandleApiException(ex);
            throw;
        }
    }
    
    private async Task HandleApiException(FlurlHttpException ex)
    {
        var errorResponse = await ex.GetResponseStringAsync();
        
        // ログ記録
        Console.WriteLine($"API Error: {ex.StatusCode} - {errorResponse}");
        
        // 外部監視システムへの通知
        await NotifyMonitoringSystem(ex);
    }
    
    private async Task NotifyMonitoringSystem(FlurlHttpException ex)
    {
        // 監視システムへのエラー通知実装
        await Task.CompletedTask;
    }
}

並行処理とパフォーマンス最適化

using Flurl;
using Flurl.Http;
using System.Collections.Concurrent;

// 複数URLの並列取得
async Task<List<T>> FetchMultipleEndpoints<T>(IEnumerable<string> endpoints) where T : class
{
    var semaphore = new SemaphoreSlim(5, 5); // 最大5並列
    var results = new ConcurrentBag<T>();
    
    var tasks = endpoints.Select(async endpoint => {
        await semaphore.WaitAsync();
        try
        {
            var data = await endpoint.GetJsonAsync<T>();
            results.Add(data);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"エラー {endpoint}: {ex.Message}");
        }
        finally
        {
            semaphore.Release();
        }
    });
    
    await Task.WhenAll(tasks);
    return results.ToList();
}

// 使用例
var endpoints = new[]
{
    "https://api.example.com/users",
    "https://api.example.com/posts", 
    "https://api.example.com/comments",
    "https://api.example.com/categories"
};

var allData = await FetchMultipleEndpoints<ApiData>(endpoints);
Console.WriteLine($"取得データ数: {allData.Count}");

// ページネーション対応の全データ取得
async Task<List<T>> FetchAllPages<T>(string baseUrl, string accessToken) where T : class
{
    var allItems = new List<T>();
    var page = 1;
    const int pageSize = 100;
    
    while (true)
    {
        try
        {
            var response = await baseUrl
                .SetQueryParams(new { page, per_page = pageSize })
                .WithOAuthBearerToken(accessToken)
                .GetJsonAsync<PagedResponse<T>>();
            
            if (response.Items == null || !response.Items.Any())
                break;
            
            allItems.AddRange(response.Items);
            Console.WriteLine($"ページ {page} 取得: {response.Items.Count}件");
            
            if (!response.HasMore || response.Items.Count < pageSize)
                break;
                
            page++;
            
            // API負荷軽減
            await Task.Delay(100);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"ページ {page} でエラー: {ex.Message}");
            break;
        }
    }
    
    Console.WriteLine($"総取得件数: {allItems.Count}");
    return allItems;
}

// 効率的なバッチ処理
async Task<BatchResult<T>> ProcessBatch<T>(IEnumerable<BatchRequest> requests) where T : class
{
    var results = new ConcurrentDictionary<string, T>();
    var errors = new ConcurrentDictionary<string, Exception>();
    
    var parallelOptions = new ParallelOptions
    {
        MaxDegreeOfParallelism = Environment.ProcessorCount
    };
    
    await Parallel.ForEachAsync(requests, parallelOptions, async (request, ct) => {
        try
        {
            var result = await request.Url
                .WithHeaders(request.Headers)
                .WithTimeout(request.Timeout ?? TimeSpan.FromSeconds(30))
                .GetJsonAsync<T>(cancellationToken: ct);
                
            results.TryAdd(request.Id, result);
        }
        catch (Exception ex)
        {
            errors.TryAdd(request.Id, ex);
        }
    });
    
    return new BatchResult<T>
    {
        Successful = results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
        Failed = errors.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
        TotalProcessed = requests.Count()
    };
}

// キャッシュ機能付きクライアント
public class CachedApiClient
{
    private readonly MemoryCache _cache;
    private readonly string _baseUrl;
    
    public CachedApiClient(string baseUrl)
    {
        _baseUrl = baseUrl;
        _cache = new MemoryCache(new MemoryCacheOptions
        {
            SizeLimit = 100
        });
    }
    
    public async Task<T> GetWithCache<T>(string endpoint, TimeSpan? cacheExpiry = null) where T : class
    {
        var cacheKey = $"{_baseUrl}/{endpoint}";
        
        if (_cache.TryGetValue(cacheKey, out T cachedResult))
        {
            Console.WriteLine($"キャッシュヒット: {cacheKey}");
            return cachedResult;
        }
        
        var result = await _baseUrl
            .AppendPathSegment(endpoint)
            .GetJsonAsync<T>();
        
        var cacheOptions = new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = cacheExpiry ?? TimeSpan.FromMinutes(5),
            Size = 1
        };
        
        _cache.Set(cacheKey, result, cacheOptions);
        Console.WriteLine($"キャッシュ追加: {cacheKey}");
        
        return result;
    }
}

// ストリーミング処理(大容量ファイル対応)
async Task DownloadLargeFile(string fileUrl, string localPath, IProgress<long> progress = null)
{
    using var response = await fileUrl
        .WithTimeout(TimeSpan.FromMinutes(30))
        .GetStreamAsync();
    
    using var fileStream = File.Create(localPath);
    
    var buffer = new byte[8192];
    long totalBytesRead = 0;
    int bytesRead;
    
    while ((bytesRead = await response.ReadAsync(buffer, 0, buffer.Length)) > 0)
    {
        await fileStream.WriteAsync(buffer, 0, bytesRead);
        totalBytesRead += bytesRead;
        
        progress?.Report(totalBytesRead);
    }
    
    Console.WriteLine($"ダウンロード完了: {localPath} ({totalBytesRead:N0} bytes)");
}

// プログレス付きダウンロードの使用例
var progress = new Progress<long>(bytesDownloaded => {
    Console.WriteLine($"ダウンロード進捗: {bytesDownloaded:N0} bytes");
});

await DownloadLargeFile(
    "https://api.example.com/files/large-dataset.zip",
    "/tmp/dataset.zip", 
    progress
);

フレームワーク統合とテスト実装

using Flurl;
using Flurl.Http;
using Flurl.Http.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Xunit;

// ASP.NET Core統合用のAPIサービス
public interface IApiService
{
    Task<T> GetAsync<T>(string endpoint) where T : class;
    Task<T> PostAsync<T>(string endpoint, object data) where T : class;
}

public class FlurlApiService : IApiService
{
    private readonly IFlurlClient _client;
    
    public FlurlApiService(IFlurlClient client)
    {
        _client = client;
    }
    
    public async Task<T> GetAsync<T>(string endpoint) where T : class
    {
        return await _client
            .Request(endpoint)
            .GetJsonAsync<T>();
    }
    
    public async Task<T> PostAsync<T>(string endpoint, object data) where T : class
    {
        return await _client
            .Request(endpoint)
            .PostJsonAsync(data)
            .ReceiveJson<T>();
    }
}

// Startup.cs での DI 設定
public void ConfigureServices(IServiceCollection services)
{
    // Flurlクライアントの登録
    services.AddSingleton<IFlurlClient>(sp => 
        new FlurlClient("https://api.example.com")
            .Configure(settings => {
                settings.Timeout = TimeSpan.FromSeconds(30);
                settings.BeforeCall = call => {
                    var logger = sp.GetService<ILogger<FlurlApiService>>();
                    logger?.LogInformation($"API呼び出し: {call.Request.Url}");
                };
            })
    );
    
    services.AddScoped<IApiService, FlurlApiService>();
}

// 単体テスト実装
public class ApiServiceTests
{
    [Fact]
    public async Task GetUsers_ShouldReturnUserList()
    {
        // Arrange
        using var httpTest = new HttpTest();
        
        var expectedUsers = new[]
        {
            new User { Id = 1, Name = "田中太郎" },
            new User { Id = 2, Name = "佐藤花子" }
        };
        
        httpTest.RespondWithJson(expectedUsers);
        
        var apiService = new FlurlApiService(
            new FlurlClient("https://api.example.com")
        );
        
        // Act
        var users = await apiService.GetAsync<User[]>("users");
        
        // Assert
        Assert.Equal(2, users.Length);
        Assert.Equal("田中太郎", users[0].Name);
        
        // HTTP呼び出しの検証
        httpTest.ShouldHaveCalled("https://api.example.com/users")
            .WithVerb(HttpMethod.Get)
            .Times(1);
    }
    
    [Fact]
    public async Task CreateUser_WithValidData_ShouldReturnCreatedUser()
    {
        // Arrange
        using var httpTest = new HttpTest();
        
        var newUser = new { Name = "山田太郎", Email = "[email protected]" };
        var createdUser = new User { Id = 3, Name = "山田太郎", Email = "[email protected]" };
        
        httpTest.RespondWithJson(createdUser, 201);
        
        var apiService = new FlurlApiService(
            new FlurlClient("https://api.example.com")
        );
        
        // Act
        var result = await apiService.PostAsync<User>("users", newUser);
        
        // Assert
        Assert.Equal(3, result.Id);
        Assert.Equal("山田太郎", result.Name);
        
        // リクエスト内容の検証
        httpTest.ShouldHaveCalled("https://api.example.com/users")
            .WithVerb(HttpMethod.Post)
            .WithContentType("application/json")
            .WithRequestJson(newUser);
    }
    
    [Fact]
    public async Task GetUser_WhenNotFound_ShouldThrowException()
    {
        // Arrange
        using var httpTest = new HttpTest();
        
        httpTest.RespondWith("User not found", 404);
        
        var apiService = new FlurlApiService(
            new FlurlClient("https://api.example.com")
        );
        
        // Act & Assert
        var exception = await Assert.ThrowsAsync<FlurlHttpException>(
            () => apiService.GetAsync<User>("users/999")
        );
        
        Assert.Equal(404, exception.StatusCode);
    }
    
    [Fact]
    public async Task ApiCall_WithTimeout_ShouldThrowTimeoutException()
    {
        // Arrange
        using var httpTest = new HttpTest();
        
        httpTest.SimulateTimeout();
        
        var apiService = new FlurlApiService(
            new FlurlClient("https://api.example.com")
                .Configure(settings => settings.Timeout = TimeSpan.FromMilliseconds(100))
        );
        
        // Act & Assert
        await Assert.ThrowsAsync<FlurlHttpTimeoutException>(
            () => apiService.GetAsync<User[]>("users")
        );
    }
}

// 統合テスト例
public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Startup>>
{
    private readonly WebApplicationFactory<Startup> _factory;
    
    public ApiIntegrationTests(WebApplicationFactory<Startup> factory)
    {
        _factory = factory;
    }
    
    [Fact]
    public async Task GetWeatherForecast_ShouldReturnForecast()
    {
        // Arrange
        var client = _factory.CreateClient();
        var baseUrl = client.BaseAddress.ToString();
        
        // Act
        var forecast = await baseUrl
            .AppendPathSegment("WeatherForecast")
            .GetJsonAsync<WeatherForecast[]>();
        
        // Assert
        Assert.NotEmpty(forecast);
        Assert.All(forecast, f => Assert.InRange(f.TemperatureC, -20, 55));
    }
}

// モック用のテストヘルパー
public static class FlurlTestHelpers
{
    public static HttpTest SetupMockApi(string baseUrl)
    {
        var httpTest = new HttpTest();
        
        // 共通レスポンスヘッダーの設定
        httpTest.WithSettings(settings => {
            settings.FakeHttpMethod = true;
            settings.BeforeCall = call => {
                call.Response.Headers.Add("X-Test-Mode", "true");
            };
        });
        
        return httpTest;
    }
    
    public static void MockSuccessResponse<T>(this HttpTest httpTest, T data, int statusCode = 200)
    {
        httpTest.RespondWithJson(data, statusCode);
    }
    
    public static void MockErrorResponse(this HttpTest httpTest, string message, int statusCode)
    {
        httpTest.RespondWith(message, statusCode);
    }
}

// テストヘルパーの使用例
[Fact]
public async Task TestWithHelper()
{
    using var httpTest = FlurlTestHelpers.SetupMockApi("https://api.example.com");
    
    httpTest.MockSuccessResponse(new { Message = "Success" });
    
    var result = await "https://api.example.com/test"
        .GetJsonAsync<dynamic>();
    
    Assert.Equal("Success", result.Message);
}