高性能・高拡張性を兼ね備えた宣言型HTTPクライアントライブラリ - WebApiClientCore

高性能・高拡張性を兼ね備えた宣言型HTTPクライアントライブラリ - WebApiClientCore

WebApiClient.JIT/AOTの.NET Core版。高性能かつ高拡張性を備えた宣言型HTTPクライアントライブラリで、マイクロサービスのRESTfulリソースリクエストに特に適しており、さまざまな非標準HTTPインターフェースのリクエストにも対応します。

最終更新 2023/09/06 12:26
老九
読了目安 21 分
カテゴリ
.NET
タグ
.NET C# AOT アーキテクチャ設計 Web API

WebApiClientCore

WebApiClient.JIT/AOTの.NET Coreバージョン。高性能かつ拡張性に優れた宣言型HTTPクライアントライブラリで、マイクロサービスのRESTfulリソースリクエストに特に適しており、さまざまな非標準HTTPインターフェースリクエストにも対応します。

NuGet

パッケージ名 説明 NuGet
WebApiClientCore 基本パッケージ NuGet
WebApiClientCore.Extensions.OAuths OAuth拡張パッケージ NuGet
WebApiClientCore.Extensions.NewtonsoftJson Json.Net拡張パッケージ NuGet
WebApiClientCore.Extensions.JsonRpc JsonRpc呼び出し拡張パッケージ NuGet
WebApiClientCore.OpenApi.SourceGenerator ローカルまたはリモートのOpenApiドキュメントを解析してWebApiClientCoreインターフェースコードを生成するdotnetツール NuGet

使用方法

[HttpHost("http://localhost:5000/")]
public interface IUserApi
{
    [HttpGet("api/users/{id}")]
    Task<User> GetAsync(string id);

    [HttpPost("api/users")]
    Task<User> PostAsync([JsonContent] User user);
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddHttpApi<IUserApi>();
}

public class MyService
{
    private readonly IUserApi userApi;
    public MyService(IUserApi userApi)
    {
        this.userApi = userApi;
    }
}

QQグループサポート

825135345

グループ参加時はWebApiClientと明記してください。質問する前に、以下の残りのドキュメントをよくお読みいただき、作者の重複説明時間を節約してください。

コンパイル時構文分析

WebApiClientCore.Analyzersはコーディング時の構文解析とヒントを提供します。宣言されたインターフェースが空メソッドのIHttpApiインターフェースを継承している場合、構文解析が有効になります。開発者はこの機能を有効にすることを推奨します。

例えば、[Header]属性はInterface、Method、Parameterの3か所に宣言できますが、正しいコンストラクターを使用する必要があります。そうしないと実行時に例外がスローされます。構文解析機能により、インターフェース宣言時に不適切な構文を使用することを防止できます。

/// <summary>
/// IHttpApiを実装することを忘れずに
/// </summary>
public interface IUserApi : IHttpApi
{
    ...
}

インターフェース設定とオプション

各インターフェースの設定はHttpApiOptionsに対応し、設定名はインターフェースの完全名、またはHttpApi.GetName()メソッドで取得できます。

IHttpClientBuilderでの設定

services
    .AddHttpApi<IUserApi>()
    .ConfigureHttpApi(Configuration.GetSection(nameof(IUserApi)))
    .ConfigureHttpApi(o =>
    {
        // 国内事情に合わせた非標準の時間形式(一部のインターフェースでは非標準が要求される)
        o.JsonSerializeOptions.Converters.Add(new JsonLocalDateTimeConverter("yyyy-MM-dd HH:mm:ss"));
    });

設定ファイルのJSON

{
  "IUserApi": {
    "HttpHost": "http://www.webappiclient.com/",
    "UseParameterPropertyValidate": false,
    "UseReturnValuePropertyValidate": false,
    "JsonSerializeOptions": {
      "IgnoreNullValues": true,
      "WriteIndented": false
    }
  }
}

IServiceCollectionでの設定

services
    .ConfigureHttpApi<IUserApi>(Configuration.GetSection(nameof(IUserApi)))
    .ConfigureHttpApi<IUserApi>(o =>
    {
        // 国内事情に合わせた非標準の時間形式(一部のインターフェースでは非標準が要求される)
        o.JsonSerializeOptions.Converters.Add(new JsonLocalDateTimeConverter("yyyy-MM-dd HH:mm:ss"));
    });

データ検証

パラメーター値の検証

パラメーター値には、ValidationAttribute属性を付けて検証できます。

public interface IUserApi
{
    [HttpGet("api/users/{email}")]
    Task<User> GetAsync([EmailAddress, Required] string email);
}

パラメーターまたは戻り値モデルのプロパティ検証

public interface IUserApi
{
    [HttpPost("api/users")]
    Task<User> PostAsync([Required][XmlContent] User user);
}

public class User
{
    [Required]
    [StringLength(10, MinimumLength = 1)]
    public string Account { get; set; }

    [Required]
    [StringLength(10, MinimumLength = 1)]
    public string Password { get; set; }
}

よく使われる組み込み属性

組み込み属性とは、フレームワーク内で提供される属性で、そのまま使用することで一般的なさまざまなアプリケーションに対応できます。もちろん、開発者は実際のアプリケーションで特定のシナリオに合わせた属性を作成し、インターフェース、メソッド、またはパラメーターにカスタム属性を付与することもできます。

Return属性

属性名 機能説明 備考
RawReturnAttribute プリミティブ型の戻り値を処理 デフォルトでも有効
JsonReturnAttribute JSONモデルの戻り値を処理 デフォルトでも有効
XmlReturnAttribute XMLモデルの戻り値を処理 デフォルトでも有効

よく使われるAction属性

属性名 機能説明 備考
HttpHostAttribute リクエスト先のHTTP絶対完全ホストドメイン名 Options設定より優先度低
HttpGetAttribute GETリクエストメソッドとパスを宣言 null、絶対、相対パス対応
HttpPostAttribute POSTリクエストメソッドとパスを宣言 null、絶対、相対パス対応
HttpPutAttribute PUTリクエストメソッドとパスを宣言 null、絶対、相対パス対応
HttpDeleteAttribute DELETEリクエストメソッドとパスを宣言 null、絶対、相対パス対応
HeaderAttribute リクエストヘッダーを宣言 定数値
TimeoutAttribute タイムアウト時間を宣言 定数値
FormFieldAttribute Formフィールドと値を宣言 定数のキーと値
FormDataTextAttribute FormDataフィールドと値を宣言 定数のキーと値

よく使われるParameter属性

属性名 機能説明 備考
PathQueryAttribute パラメーター値のキーと値ペアをURLパスパラメーターまたはクエリパラメーターとして扱う属性 属性未指定のパラメーターはデフォルトでこの属性
FormContentAttribute パラメーター値のキーと値ペアをx-www-form-urlencodedフォームとして扱う
FormDataContentAttribute パラメーター値のキーと値ペアをmultipart/form-dataフォームとして扱う
JsonContentAttribute パラメーター値をシリアル化してリクエストのJSON本文として扱う
XmlContentAttribute パラメーター値をシリアル化してリクエストのXML本文として扱う
UriAttribute パラメーター値をリクエストURIとして扱う 最初のパラメーターのみ可能
ParameterAttribute 集約的なリクエストパラメーター宣言 細かい設定には非対応
HeaderAttribute パラメーター値をリクエストヘッダーとして扱う
TimeoutAttribute パラメーター値をタイムアウト時間として扱う HttpClientのTimeoutプロパティより大きくできない
FormFieldAttribute パラメーター値をFormフィールドと値として扱う 単純型パラメーターのみ対応
FormDataTextAttribute パラメーター値をFormDataフィールドと値として扱う 単純型パラメーターのみ対応

Filter属性

属性名 機能説明 備考
ApiFilterAttribute Filter属性の抽象クラス
LoggingFilterAttribute リクエストとレスポンスの内容をログに出力するフィルター

自己説明的パラメーター型

型名 機能説明 備考
FormDataFile form-dataのファイル項目 属性不要。FileInfo型と同等
JsonPatchDocument JsonPatchリクエストドキュメントを表す 属性不要

URI結合ルール

URIの結合はすべてUri(Uri baseUri, Uri relativeUri)コンストラクターで行われます。

/で終わるbaseUri

  • http://a.com/ + b/c/d = http://a.com/b/c/d
  • http://a.com/path1/ + b/c/d = http://a.com/path1/b/c/d
  • http://a.com/path1/path2/ + b/c/d = http://a.com/path1/path2/b/c/d

/で終わらないbaseUri

  • http://a.com + b/c/d = http://a.com/b/c/d
  • http://a.com/path1 + b/c/d = http://a.com/b/c/d
  • http://a.com/path1/path2 + b/c/d = http://a.com/path1/b/c/d

実際にはhttp://a.comhttp://a.com/は完全に同じで、パスはどちらも/です。そのため同じ動作になります。誤動作を避けるために、標準的なbaseUriの記述方法、つまり/で終わる最初の方法を使用してください。

フォームコレクションの処理

OpenApiに従って、コレクションはURIのQueryまたはフォーム内で5つの表現方法をサポートします。

  • Csv // カンマ区切り
  • Ssv // スペース区切り
  • Tsv // バックスラッシュ区切り
  • Pipes // パイプ区切り
  • Multi // 同名キーの複数キーと値ペア

id = new string []{"001","002"}のような値の場合、PathQueryAttributeとFormContentAttributeで処理すると次のようになります。

CollectionFormat Data
[PathQuery(CollectionFormat = CollectionFormat.Csv)] id=001,002
[PathQuery(CollectionFormat = CollectionFormat.Ssv)] id=001 002
[PathQuery(CollectionFormat = CollectionFormat.Tsv)] id=001\002
[PathQuery(CollectionFormat = CollectionFormat.Pipes)] id=001\|002
[PathQuery(CollectionFormat = CollectionFormat.Multi)] id=001&id=002

CancellationTokenパラメーター

各インターフェースは、リクエストのキャンセルをサポートするために、1つ以上のCancellationToken型パラメーターを宣言できます。CancellationToken.Noneはキャンセルしないことを意味し、CancellationTokenSourceを作成してCancellationTokenを提供できます。

[HttpGet("api/users/{id}")]
ITask<User> GetAsync([Required]string id, CancellationToken token = default);

ContentType CharSet

フォーム以外の本文コンテンツの場合、デフォルトまたは省略時のcharset値はUTF8エンコーディングです。サーバーの要件に応じてエンコーディングを調整できます。

Attribute ContentType
[JsonContent] Content-Type: application/json; charset=utf-8
[JsonContent(CharSet ="utf-8")] Content-Type: application/json; charset=utf-8
[JsonContent(CharSet ="unicode")] Content-Type: application/json; charset=utf-16

Accept ContentType

これは、クライアントがサーバーにどのようなコンテンツ形式(JSONやXMLなど)を返すことを希望するかを制御します。

デフォルト設定値

デフォルト設定は[JsonReturn(0.01),XmlReturn(0.01)]で、対応するリクエストAccept値は Accept: application/json; q=0.01, application/xml; q=0.01

JSON優先

InterfaceまたはMethodに[JsonReturn]を明示的に宣言すると、リクエストAcceptはAccept: application/json, application/xml; q=0.01になります。

JSON無効化

InterfaceまたはMethodに[JsonReturn(Enable = false)]を宣言すると、リクエストはAccept: application/xml; q=0.01になります。

リクエストとレスポンスのログ

Interface全体または特定のMethodに[LoggingFilter]を宣言すると、リクエストとレスポンスの内容がLoggingFactoryに出力されます。特定のMethodのログ出力を除外するには、そのMethodに[LoggingFilter(Enable = false)]を宣言します。

デフォルトログ

[LoggingFilter]
public interface IUserApi
{
    [HttpGet("api/users/{account}")]
    ITask<HttpResponseMessage> GetAsync([Required]string account);

    // ログ無効化
    [LoggingFilter(Enable =false)]
    [HttpPost("api/users/body")]
    Task<User> PostByJsonAsync([Required, JsonContent]User user, CancellationToken token = default);
}

カスタムログ出力先

class MyLoggingAttribute : LoggingFilterAttribute
{
    protected override Task WriteLogAsync(ApiResponseContext context, LogMessage logMessage)
    {
        xxlogger.Log(logMessage.ToIndentedString(spaceCount: 4));
        return Task.CompletedTask;
    }
}

[MyLogging]
public interface IUserApi
{
}

プリミティブ型の戻り値

インターフェースの戻り値が次の型として宣言されている場合、それをプリミティブ型と呼び、RawReturnAttributeで処理されます。

戻り値型 説明
Task 応答メッセージを無視
Task<HttpResponseMessage> 生の応答メッセージ型
Task<Stream> 生の応答ストリーム
Task<byte[]> 生の応答バイナリデータ
Task<string> 生の応答メッセージテキスト

インターフェース宣言サンプル

Petstoreインターフェース

このOpenApiドキュメントはpetstore.swagger.ioにあり、コードはWebApiClientCore.OpenApi.SourceGeneratorツールを使用してそのOpenApiドキュメントから逆生成されたものです。

/// <summary>
/// Everything about your Pets
/// </summary>
[LoggingFilter]
[HttpHost("https://petstore.swagger.io/v2/")]
public interface IPetApi : IHttpApi
{
    /// <summary>
    /// Add a new pet to the store
    /// </summary>
    /// <param name="body">Pet object that needs to be added to the store</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpPost("pet")]
    Task AddPetAsync([Required] [JsonContent] Pet body, CancellationToken cancellationToken = default);

    /// <summary>
    /// Update an existing pet
    /// </summary>
    /// <param name="body">Pet object that needs to be added to the store</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpPut("pet")]
    Task UpdatePetAsync([Required] [JsonContent] Pet body, CancellationToken cancellationToken = default);

    /// <summary>
    /// Finds Pets by status
    /// </summary>
    /// <param name="status">Status values that need to be considered for filter</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [HttpGet("pet/findByStatus")]
    ITask<List<Pet>> FindPetsByStatusAsync([Required] IEnumerable<Anonymous> status, CancellationToken cancellationToken = default);

    /// <summary>
    /// Finds Pets by tags
    /// </summary>
    /// <param name="tags">Tags to filter by</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [Obsolete]
    [HttpGet("pet/findByTags")]
    ITask<List<Pet>> FindPetsByTagsAsync([Required] IEnumerable<string> tags, CancellationToken cancellationToken = default);

    /// <summary>
    /// Find pet by ID
    /// </summary>
    /// <param name="petId">ID of pet to return</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [HttpGet("pet/{petId}")]
    ITask<Pet> GetPetByIdAsync([Required] long petId, CancellationToken cancellationToken = default);

    /// <summary>
    /// Updates a pet in the store with form data
    /// </summary>
    /// <param name="petId">ID of pet that needs to be updated</param>
    /// <param name="name">Updated name of the pet</param>
    /// <param name="status">Updated status of the pet</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpPost("pet/{petId}")]
    Task UpdatePetWithFormAsync([Required] long petId, [FormField] string name, [FormField] string status, CancellationToken cancellationToken = default);

    /// <summary>
    /// Deletes a pet
    /// </summary>
    /// <param name="api_key"></param>
    /// <param name="petId">Pet id to delete</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpDelete("pet/{petId}")]
    Task DeletePetAsync([Header("api_key")] string api_key, [Required] long petId, CancellationToken cancellationToken = default);

    /// <summary>
    /// uploads an image
    /// </summary>
    /// <param name="petId">ID of pet to update</param>
    /// <param name="additionalMetadata">Additional data to pass to server</param>
    /// <param name="file">file to upload</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [HttpPost("pet/{petId}/uploadImage")]
    ITask<ApiResponse> UploadFileAsync([Required] long petId, [FormDataText] string additionalMetadata, FormDataFile file, CancellationToken cancellationToken = default);
}

IOAuthClientインターフェース

このインターフェースはWebApiClientCore.Extensions.OAuths.IOAuthClient.csで宣言されています。

using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using WebApiClientCore.Attributes;

namespace WebApiClientCore.Extensions.OAuths
{
    /// <summary>
    /// Tokenクライアントのインターフェースを定義
    /// </summary>
    [LoggingFilter]
    [XmlReturn(Enable = false)]
    [JsonReturn(EnsureMatchAcceptContentType = false, EnsureSuccessStatusCode = false)]
    public interface IOAuthClient
    {
        /// <summary>
        /// client_credentials付与方式でtokenを取得
        /// </summary>
        /// <param name="endpoint">tokenリクエストアドレス</param>
        /// <param name="credentials">認証情報</param>
        /// <returns></returns>
        [HttpPost]
        [FormField("grant_type", "client_credentials")]
        Task<TokenResult> RequestTokenAsync([Required, Uri] Uri endpoint, [Required, FormContent] ClientCredentials credentials);

        /// <summary>
        /// password付与方式でtokenを取得
        /// </summary>
        /// <param name="endpoint">tokenリクエストアドレス</param>
        /// <param name="credentials">認証情報</param>
        /// <returns></returns>
        [HttpPost]
        [FormField("grant_type", "password")]
        Task<TokenResult> RequestTokenAsync([Required, Uri] Uri endpoint, [Required, FormContent] PasswordCredentials credentials);

        /// <summary>
        /// tokenをリフレッシュ
        /// </summary>
        /// <param name="endpoint">tokenリクエストアドレス</param>
        /// <param name="credentials">認証情報</param>
        /// <returns></returns>
        [HttpPost]
        [FormField("grant_type", "refresh_token")]
        Task<TokenResult> RefreshTokenAsync([Required, Uri] Uri endpoint, [Required, FormContent] RefreshTokenCredentials credentials);
    }
}

リクエストの条件付きリトライ

ITask<>非同期宣言を使用すると、Retry拡張が利用できます。Retryの条件は、特定のExceptionをキャッチした場合、または応答モデルが特定の条件を満たした場合などです。

public interface IUserApi
{
    [HttpGet("api/users/{id}")]
    ITask<User> GetAsync(string id);
}

var result = await userApi.GetAsync(id: "id001")
    .Retry(maxCount: 3)
    .WhenCatch<HttpRequestException>()
    .WhenResult(r => r.Age <= 0);

例外と例外処理

インターフェースのリクエストでは、どのような例外が発生しても最終的にHttpRequestExceptionがスローされます。HttpRequestExceptionの内部例外が実際の具体的な例外です。内部例外として設計されているのは、内部例外のスタック情報を完全に保持するためです。

WebApiClient内部の多くの例外はApiException抽象例外に基づいています。つまり、多くの場合、スローされる例外は特定のApiExceptionを内部に持つHttpRequestExceptionです。

try
{
    var model = await api.GetAsync();
}
catch (HttpRequestException ex) when (ex.InnerException is ApiInvalidConfigException configException)
{
    // 設定が不正な例外
}
catch (HttpRequestException ex) when (ex.InnerException is ApiResponseStatusException statusException)
{
    // 応答ステータスコード例外
}
catch (HttpRequestException ex) when (ex.InnerException is ApiException apiException)
{
    // 抽象API例外
}
catch (HttpRequestException ex) when (ex.InnerException is SocketException socketException)
{
    // ソケット接続層例外
}
catch (HttpRequestException ex)
{
    // リクエスト例外
}
catch (Exception ex)
{
    // その他例外
}

PATCHリクエスト

JSON Patchは、クライアントがサーバー上の既存リソースを部分的に更新するための標準的なインタラクションであり、RFC6902で詳細に説明されています。簡単に言うと、以下の要点があります。

  1. HTTP PATCHリクエストメソッドを使用する。
  2. リクエスト本文は複数の操作を記述したJSONデータ。
  3. リクエストのContent-Typeはapplication/json-patch+json。

Patchメソッドの宣言

public interface IUserApi
{
    [HttpPatch("api/users/{id}")]
    Task<UserInfo> PatchAsync(string id, JsonPatchDocument<User> doc);
}

JsonPatchDocumentのインスタンス化

var doc = new JsonPatchDocument<User>();
doc.Replace(item => item.Account, "laojiu");
doc.Replace(item => item.Email, "laojiu@qq.com");

リクエスト内容

PATCH /api/users/id001 HTTP/1.1
Host: localhost:6000
User-Agent: WebApiClientCore/1.0.0.0
Accept: application/json; q=0.01, application/xml; q=0.01
Content-Type: application/json-patch+json

[{"op":"replace","path":"/account","value":"laojiu"},{"op":"replace","path":"/email","value":"laojiu@qq.com"}]

応答内容のキャッシュ

CacheAttribute属性を設定したMethodは、今回の応答内容をキャッシュします。次回、条件に合致する場合、リモートサーバーにリクエストせずにIResponseCacheProviderからキャッシュ内容を取得します。開発者は独自のResponseCacheProviderを実装できます。

キャッシュ属性の宣言

public interface IUserApi
{
    // 1分間キャッシュ
    [Cache(60 * 1000)]
    [HttpGet("api/users/{account}")]
    ITask<HttpResponseMessage> GetAsync([Required]string account);
}

デフォルトのキャッシュ条件:URL(例:http://abc.com/a)と指定されたリクエストヘッダーが一致すること。 [CacheByPath]のような機能が必要な場合は、ApiCacheAttributeを直接継承して実装できます。

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class CacheByAbsolutePathAttribute : ApiCacheAttribute
{
    public CacheByPathAttribute(double expiration) : base(expiration)
    {
    }

    public override Task<string> GetCacheKeyAsync(ApiRequestContext context)
    {
        return Task.FromResult(context.HttpContext.RequestMessage.RequestUri.AbsolutePath);
    }
}

カスタムキャッシュプロバイダー

デフォルトのキャッシュプロバイダーはメモリキャッシュです。キャッシュを他のストレージに保存する場合は、カスタムキャッシュプロバイダーを作成し、デフォルトのキャッシュプロバイダーを置き換えます。

public class RedisResponseCacheProvider : IResponseCacheProvider
{
    public string Name => nameof(RedisResponseCacheProvider);

    public Task<ResponseCacheResult> GetAsync(string key)
    {
        throw new NotImplementedException();
    }

    public Task SetAsync(string key, ResponseCacheEntry entry, TimeSpan expiration)
    {
        throw new NotImplementedException();
    }
}

// RedisResponseCacheProviderを登録
var services = new ServiceCollection();
services.AddSingleton<IResponseCacheProvider, RedisResponseCacheProvider>();

非モデルリクエスト

必ずしも強力なモデルが必要とは限りません。既に生のフォームテキスト、生のJSONテキスト、またはSystem.Net.Http.HttpContentオブジェクトがある場合、それらをリモートサーバーにそのままリクエストできます。

生テキスト

[HttpPost]
Task PostAsync([RawStringContent("txt/plain")] string text);

[HttpPost]
Task PostAsync(StringContent text);

生JSON

[HttpPost]
Task PostAsync([RawJsonContent] string json);

生XML

[HttpPost]
Task PostAsync([RawXmlContent] string xml);

生フォーム内容

[HttpPost]
Task PostAsync([RawFormContent] string form);

自己説明的パラメーター型のカスタマイズ

極端なケース、例えば顔認識比較のインターフェースでは、入力モデルと転送モデルが必ずしも一致しない場合があります。例えば、

サーバーが要求するJSONモデル

{
  "image1": "画像1のbase64",
  "image2": "画像2のbase64"
}

クライアントが期待するビジネスモデル

class FaceModel
{
    public Bitmap Image1 {get; set;}
    public Bitmap Image2 {get; set;}
}

モデルインスタンス構築時にはBitmapオブジェクトを渡し、転送時にはBitmapのbase64値にしたい場合、FaceModelを改造してIApiParameterインターフェースを実装します。

class FaceModel : IApiParameter
{
    public Bitmap Image1 { get; set; }

    public Bitmap Image2 { get; set; }


    public Task OnRequestAsync(ApiParameterContext context)
    {
        var image1 = GetImageBase64(this.Image1);
        var image2 = GetImageBase64(this.Image2);
        var model = new { image1, image2 };

        var jsonContent = new JsonContent();
        context.HttpContext.RequestMessage.Content = jsonContent;

        var options = context.HttpContext.HttpApiOptions.JsonSerializeOptions;
        var serializer = context.HttpContext.ServiceProvider.GetJsonSerializer();
        serializer.Serialize(jsonContent, model, options);
    }

    private static string GetImageBase64(Bitmap image)
    {
        using var stream = new MemoryStream();
        image.Save(stream, System.Drawing.Imaging.ImageFormat.Jpeg);
        return Convert.ToBase64String(stream.ToArray());
    }
}

最後に、改良されたFaceModelを使ってリクエストします。

public interface IFaceApi
{
    [HttpPost("/somePath")]
    Task<HttpResponseMessage> PostAsync(FaceModel faces);
}

カスタムリクエスト内容と応答内容のパース

一般的なXMLやJSONの応答内容を強型結果モデルに逆シリアル化する以外に、GoogleのProtoBufバイナリ内容など、他のバイナリプロトコルの応答内容に遭遇することがあります。

1 関連するカスタム属性の作成

カスタムリクエスト内容処理属性
public class ProtobufContentAttribute : HttpContentAttribute
{
    public string ContentType { get; set; } = "application/x-protobuf";

    protected override Task SetHttpContentAsync(ApiParameterContext context)
    {
        var stream = new MemoryStream();
        if (context.ParameterValue != null)
        {
            Serializer.NonGeneric.Serialize(stream, context.ParameterValue);
            stream.Position = 0L;
        }

        var content = new StreamContent(stream);
        content.Headers.ContentType = new MediaTypeHeaderValue(this.ContentType);
        context.HttpContext.RequestMessage.Content = content;
        return Task.CompletedTask;
    }
}
カスタム応答内容パース属性
public class ProtobufReturnAttribute : ApiReturnAttribute
{
    public ProtobufReturnAttribute(string acceptContentType = "application/x-protobuf")
        : base(new MediaTypeWithQualityHeaderValue(acceptContentType))
    {
    }

    public override async Task SetResultAsync(ApiResponseContext context)
    {
        if (context.ApiAction.Return.DataType.IsRawType == false)
        {
            var stream = await context.HttpContext.ResponseMessage.Content.ReadAsStreamAsync();
            context.Result = Serializer.NonGeneric.Deserialize(context.ApiAction.Return.DataType.Type, stream);
        }
    }
}

2 関連カスタム属性の適用

[ProtobufReturn]
public interface IProtobufApi
{
    [HttpPut("/users/{id}")]
    Task<User> UpdateAsync([Required, PathQuery] string id, [ProtobufContent] User user);
}

非標準インターフェースへの対応

実際のアプリケーションでは、標準化されていない非標準のインターフェースによく遭遇します。主にRESTfulの概念が普及する前の時代のインターフェースです。これらのインターフェースを分析し、使いやすいクライアント呼び出しインターフェースにラップします。

不適切なパラメーター名のエイリアス

例えば、サーバーがQueryパラメーター名としてfield-Nameを要求する場合、これはC#のキーワードや変数名として許可されていません。[AliasAsAttribute]を使用して対応できます。

public interface IDeformedApi
{
    [HttpGet("api/users")]
    ITask<string> GetAsync([AliasAs("field-Name")] string fieldName);
}

最終的なリクエストURIはapi/users/?field-name=fieldNameValueになります。

FormのフィールドがJSONテキスト

フィールド
field1 someValue
field2

対応する強型モデル

class Field2
{
    public string Name {get; set;}
    public int Age {get; set;}
}

通常は、field2のインスタンスをJSONシリアル化してJSONテキストを取得し、それをfield2のstringプロパティに代入します。[JsonFormField]属性を使用すると、Field2型のJSONシリアル化を自動的に行い、結果の文字列をフォームのフィールドとして簡単に扱えます。

public interface IDeformedApi
{
    Task PostAsync([FormField] string field1, [JsonFormField] Field2 field2)
}

Form送信でのネストされたモデル

フィールド
field1 someValue
field2.name sb
field2.age 18

対応するJSON形式

{
  "field1": "someValue",
  "field2": {
    "name": "sb",
    "age": 18
  }
}

通常、複雑なネスト構造のデータモデルにはapplication/jsonを使用すべきですが、インターフェースがForm送信を要求する場合、KeyValueSerializeOptionsを設定してこの形式に対応できます。

services.AddHttpApi<IDeformedApi>((o) =>
{
    o.KeyValueSerializeOptions.KeyNamingStyle = KeyNamingStyle.FullName;
});

応答にContentTypeが指定されていない

応答の内容が明らかにJSONに見えるのに、サーバーの応答ヘッダーにContentTypeがなく、JSONであることがクライアントに通知されない場合があります。これは、クライアントがFormやJSONで送信するときにリクエストヘッダーでコンテンツ形式をサーバーに伝えず、サーバーに推測させるのと同じです。

解決策は、InterfaceまたはMethodに[JsonReturn]属性を宣言し、そのEnsureMatchAcceptContentTypeプロパティをfalseに設定することです。これにより、ContentTypeが期待値と一致しなくても処理されるようになります。

[JsonReturn(EnsureMatchAcceptContentType = false)]
public interface IDeformedApi
{
}

署名パラメーターやAPIキーパラメーター

例えば、各リクエストのURLに動的にsignというパラメーターを追加する必要があり、このsignはリクエストパラメーター値に関連している場合があります。毎回計算が必要です。

カスタムApiFilterAttributeを実装して独自のsign機能を作成し、カスタムFilterをInterfaceまたはMethodに宣言します。

class SignFilterAttribute : ApiFilterAttribute
{
    public override Task OnRequestAsync(ApiRequestContext context)
    {
        var signService = context.HttpContext.ServiceProvider.GetService<SignService>();
        var sign = signService.SignValue(DateTime.Now);
        context.HttpContext.RequestMessage.AddUrlQuery("sign", sign);
        return Task.CompletedTask;
    }
}

[SignFilter]
public interface IDeformedApi
{
    ...
}

HttpMessageHandlerの設定

HTTPプロキシ設定

services
    .AddHttpApi<IUserApi>(o =>
    {
        o.HttpHost = new Uri("http://localhost:6000/");
    })
    .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
    {
        UseProxy = true,
        Proxy = new WebProxy
        {
            Address = new Uri("http://proxy.com"),
            Credentials = new NetworkCredential
            {
                UserName = "username",
                Password = "password"
            }
        }
    });

クライアント証明書の設定

一部のサーバーはクライアント接続を制限するためにHTTPS双方向認証を有効にしており、発行した証明書を持つクライアントのみ接続を許可します。

services
    .AddHttpApi<IUserApi>(o =>
    {
        o.HttpHost = new Uri("http://localhost:6000/");
    })
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        var handler = new HttpClientHandler();
        handler.ClientCertificates.Add(yourCert);
        return handler;
    });

CookieContainerを維持する

もしインターフェースが不幸にもCookieを使用して認証情報を保存する仕組みを採用している場合、CookieContainerインスタンスをHttpMessageHandlerのライフサイクルに追随させないように考慮する必要があります。デフォルトのHttpMessageHandlerの最小ライフサイクルはわずか2分です。

var cookieContainer = new CookieContainer();
services
    .AddHttpApi<IUserApi>(o =>
    {
        o.HttpHost = new Uri("http://localhost:6000/");
    })
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        var handler = new HttpClientHandler();
        handler.CookieContainer = cookieContainer;
        return handler;
    });

OAuths&Token

WebApiClientCore.Extensions.OAuths拡張を使用して、トークンの取得、リフレッシュ、適用を簡単にサポートします。

オブジェクトと概念

オブジェクト 用途
ITokenProviderFactory TokenProvider作成ファクトリー。HttpApiインターフェース型を使用してTokenProviderを作成
ITokenProvider Tokenプロバイダー。トークンを取得するために使用し、トークンの有効期限が切れた後の最初のリクエストでトークンの再取得またはリフレッシュをトリガー
OAuthTokenAttribute Token適用属性。ITokenProviderFactoryを使用してITokenProviderを作成し、ITokenProviderを使用してトークンを取得し、最後にトークンをリクエストメッセージに適用
OAuthTokenHandler HTTPメッセージハンドラー。機能はOAuthTokenAttributeと同じですが、予期しない理由でサーバーがまだ未認可(401ステータスコード)を返す場合、古いトークンを破棄し、新しいトークンを取得してリクエストを1回再試行します。

OAuthのClientモード

1 インターフェースにTokenProviderを登録
// インターフェースにClientモードのTokenProviderを登録・設定
services.AddClientCredentialsTokenProvider<IUserApi>(o =>
{
    o.Endpoint = new Uri("http://localhost:6000/api/tokens");
    o.Credentials.Client_id = "clientId";
    o.Credentials.Client_secret = "xxyyzz";
});
2 Tokenの適用
2.1 OAuthToken属性の使用

OAuthTokenAttributeはWebApiClientCoreフレームワーク層に属し、リクエストコンテンツや応答モデルを簡単に操作できます。例えば、トークンを既存のリクエストフォームにフォームフィールドとして追加したり、応答メッセージを逆シリアル化した後のビジネスモデルを読み取ることも簡単です。ただし、リクエスト内部でリトライを実現することはできません。サーバーがトークンを発行した後にトークンが失われた場合、OAuthTokenAttributeを使用すると失敗したリクエストが発生し、その失敗は回避できません。

/// <summary>
/// ユーザー操作インターフェース
/// </summary>
[OAuthToken]
public interface IUserApi
{
    ...
}

OAuthTokenAttributeのデフォルト実装は、トークンをAuthorizationリクエストヘッダーに配置します。インターフェースでトークンをURIのqueryなど他の場所に配置する必要がある場合は、OAuthTokenAttributeをオーバーライドします。

class UriQueryTokenAttribute : OAuthTokenAttribute
{
    protected override void UseTokenResult(ApiRequestContext context, TokenResult tokenResult)
    {
        context.HttpContext.RequestMessage.AddUrlQuery("mytoken", tokenResult.Access_token);
    }
}

[UriQueryToken]
public interface IUserApi
{
    ...
}
2.2 OAuthTokenHandlerの使用

OAuthTokenHandlerの強みは、1つのリクエスト内部で複数回の再試行をサポートすることです。サーバーがトークンを発行した後にトークンが失われた場合、OAuthTokenHandlerは401ステータスコードを受信すると、そのリクエスト内部で古いトークンを破棄して新しいトークンを取得し、新しいトークンでリクエストを再試行するため、通常のリクエストのように見えます。ただし、OAuthTokenHandlerはWebApiClientCoreフレームワーク層のオブジェクトではなく、内部では生のHttpRequestMessageとHttpResponseMessageにしかアクセスできません。トークンをHttpRequestMessageのContentに追加するのは非常に困難です。同様に、HTTPステータスコード(401など)ではなく、HttpResponseMessageのContentに対応するビジネスモデルの特定のフラグフィールドをトークン無効の基準とする場合も、非常に厄介です。

// インターフェース登録時にOAuthTokenHandlerを追加
services
    .AddHttpApi<IUserApi>()
    .AddOAuthTokenHandler();

OAuthTokenHandlerのデフォルト実装は、トークンをAuthorizationリクエストヘッダーに配置します。インターフェースでトークンをURIのqueryなど他の場所に配置する必要がある場合は、OAuthTokenHandlerをオーバーライドします。

class UriQueryOAuthTokenHandler : OAuthTokenHandler
{
    /// <summary>
    /// Token適用のHTTPメッセージハンドラー
    /// </summary>
    /// <param name="tokenProvider">Tokenプロバイダー</param>
    public UriQueryOAuthTokenHandler(ITokenProvider tokenProvider)
        : base(tokenProvider)
    {
    }

    /// <summary>
    /// Tokenを適用
    /// </summary>
    /// <param name="request"></param>
    /// <param name="tokenResult"></param>
    protected override void UseTokenResult(HttpRequestMessage request, TokenResult tokenResult)
    {
        var builder = new UriBuilder(request.RequestUri);
        builder.Query += "mytoken=" + Uri.EscapeDataString(tokenResult.Access_token);
        request.RequestUri = builder.Uri;
    }
}


// インターフェース登録時にUriQueryOAuthTokenHandlerを追加
services
    .AddHttpApi<IUserApi>()
    .AddOAuthTokenHandler((s, tp) => new UriQueryOAuthTokenHandler(tp));

複数インターフェースで共有するTokenProvider

HTTPインターフェースに基本インターフェースを設定し、その基本インターフェースにTokenProviderを設定できます。例えば、以下のxxxとyyyインターフェースは両方ともIBaiduに属し、IBaiduにのみTokenProviderを設定します。

public interface IBaidu
{
}

[OAuthToken]
public interface IBaidu_XXX_Api : IBaidu
{
    [HttpGet]
    Task xxxAsync();
}

[OAuthToken]
public interface IBaidu_YYY_Api : IBaidu
{
    [HttpGet]
    Task yyyAsync();
}
// PasswordモードのTokenProviderオプションを登録・設定
services.AddPasswordCredentialsTokenProvider<IBaidu>(o =>
{
    o.Endpoint = new Uri("http://localhost:5000/api/tokens");
    o.Credentials.Client_id = "clientId";
    o.Credentials.Client_secret = "xxyyzz";
    o.Credentials.Username = "username";
    o.Credentials.Password = "password";
});

カスタムTokenProvider

拡張パッケージには、OAuthのClientモードとPasswordモードの2つの標準トークンリクエストが組み込まれていますが、多くのインターフェース提供者は、その精神を体現しているだけで実装が異なる場合があります。その場合は、カスタムTokenProviderが必要です。トークン取得インターフェースが次のようになっていると仮定します。

public interface ITokenApi
{
    [HttpPost("http://xxx.com/token")]
    Task<TokenResult> RequestTokenAsync([Parameter(Kind.Form)] string clientId, [Parameter(Kind.Form)] string clientSecret);
}
委譲TokenProvider

委譲TokenProviderは最も簡単な実装方法で、トークンリクエストの委譲をカスタムTokenProviderの実装ロジックとして使用します。

// インターフェースにカスタムTokenProviderを登録
services.AddTokeProvider<IUserApi>(s =>
{
    return s.GetService<ITokenApi>().RequestTokenAsync("id", "secret");
});
完全実装のTokenProvider
// インターフェースにCustomTokenProviderを登録
services.AddTokeProvider<IUserApi, CustomTokenProvider>();
class CustomTokenProvider : TokenProvider
{
    public CustomTokenProvider(IServiceProvider serviceProvider)
        : base(serviceProvider)
    {
    }

    protected override Task<TokenResult> RequestTokenAsync(IServiceProvider serviceProvider)
    {
        return serviceProvider.GetService<ITokenApi>().RequestTokenAsync("id", "secret");
    }

    protected override Task<TokenResult> RefreshTokenAsync(IServiceProvider serviceProvider, string refresh_token)
    {
        return this.RequestTokenAsync(serviceProvider);
    }
}
カスタムTokenProviderのオプション

各TokenProviderにはNameプロパティがあり、service.AddTokeProvider()が返すITokenProviderBuilderのNameと同じ値です。Options値の読み取りにはTokenProviderのGetOptionsValue()メソッドを使用し、Optionsの設定はITokenProviderBuilderのNameを介して行います。

NewtonsoftJsonによるJSON処理

System.Text.Jsonはパフォーマンス上の利点から広く使われるようになるでしょうが、NewtonsoftJsonも舞台から消えることはありません。

System.Text.Jsonはデフォルトで非常に厳格で、呼び出し元に代わって推測や解釈を行わないようにし、決定論的な動作を重視しています。このライブラリはパフォーマンスとセキュリティを目的として設計されています。Newtonsoft.Jsonはデフォルトで非常に柔軟で、標準設定では逆シリアル化の問題にほとんど遭遇しません。これらの問題の多くは、不正確なJSON構造や型宣言によるものです。

拡張パッケージ

デフォルトの基本パッケージにはNewtonsoftJson機能は含まれていません。追加でWebApiClientCore.Extensions.NewtonsoftJson拡張パッケージを参照する必要があります。

設定[オプション]

// ConfigureNewtonsoftJson
services.AddHttpApi<IUserApi>().ConfigureNewtonsoftJson(o =>
{
    o.JsonSerializeOptions.NullValueHandling = NullValueHandling.Ignore;
});

属性の宣言

組み込みの[JsonReturn]を[JsonNetReturn]に、[JsonContent]を[JsonNetContent]に置き換えます。

/// <summary>
/// ユーザー操作インターフェース
/// </summary>
[JsonNetReturn]
public interface IUserApi
{
    [HttpPost("/users")]
    Task PostAsync([JsonNetContent] User user);
}

JsonRpc呼び出し

ごく一部のシナリオで、JsonRpc呼び出しのインターフェースに遭遇する可能性があります。このプロトコルはあまり普及していないため、WebApiClientCoreはこの機能のサポートをWebApiClientCore.Extensions.JsonRpc拡張パッケージとして提供しています。[JsonRpcMethod]でRPCメソッドを修飾し、[JsonRpcParam]でRPCパラメーターを修飾します。

JsonRpc宣言

[HttpHost("http://localhost:5000/jsonrpc")]
public interface IUserApi
{
    [JsonRpcMethod("add")]
    ITask<JsonRpcResult<User>> AddAsync([JsonRpcParam] string name, [JsonRpcParam] int age, CancellationToken token = default);
}

JsonRpcデータパケット

POST /jsonrpc HTTP/1.1
Host: localhost:5000
User-Agent: WebApiClientCore/1.0.6.0
Accept: application/json; q=0.01, application/xml; q=0.01
Content-Type: application/json-rpc

{"jsonrpc":"2.0","method":"add","params":["laojiu",18],"id":1}
さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2026/02/07

AOTの使用経験のまとめ

プロジェクト作成当初から、新機能を追加したり新しい構文を使用したりした場合には、すぐにAOT公開テストを実施するという良い習慣を身につけるべきです。

続きを読む
同じカテゴリ / 同じタグ 2025/02/25

.NET 10 Preview 1 リリース

本日.NET 10 Preview 1がリリースされました。私はすぐにダウンロードして、Avalonia UIプロジェクトとブログサイトをアップグレードしました。前者は機能テストとAOT公開が正常に動作し、後者はデバッグが正常に行えます。Dockerは今のところ成功していません。

続きを読む