C#: HttpClientの生HTTP要求・応答をダンプ

概要

  • 外部のREST APIとの疎通でトラブルに見舞われる場合があります。このようなケースでは、REST APIとのHTTP通信の内容を把握し、問題を切り分けする必要があります。
    ここでは、HttpClientのHTTP要求・応答を文字列としてダンプするサンプルを紹介します。
  • サンプルコードはgithubで公開しています。(サンプルの主要コードはHttpDebugUtilsです。)
  • 使用環境は次の通りです。
    OSWindows 10(64ビット)
    IDEMicrosoft Visual Studio Community 2022(17.6.0)
    言語C#(10.0) + .NET6

サンプルコード

呼び出しの例

HttpClientの要求・応答内容であるHttpRequestMessage, HttpResponseMessageの生HTTP通信内容の文字列として取得するサンプルイメージです。
この変換処理は、後述のHttpDebugUtilsのGetRawRequestAsync(), GetRawResponseAsync()メソッドで行っています。

// HTTP要求メッセージの生成
using var req = new HttpRequestMessage(){
    Method = HttpMethod.Get,
    RequestUri = targetUri,
};

// IHttpClientFactoryから取得したHttpClientでHTTP要求の実行
var httpClient = _httpClientFactory.CreateClient();
using var res = await httpClient.SendAsync(req);

// HTTP要求・応答メッセージのダンプ
var rawreq = await HttpDebugUtils.GetRawRequestAsync(req);
var rawres = await HttpDebugUtils.GetRawResponseAsync(res);
  • ASP.NET(.NET6)では、IHttpClientFactoryから取得したHttpClientの使用が推奨されてるので、その想定の例になっています。詳細はリファレンスをご覧ください。
  • この例では、HttpClientの要求実行の前後でダンプしています。IHttpClientFactoryにDelegatingHandlerを追加することで、透過的(既存のHTTP要求実行処理を変更せず)にダンプすることも可能です。詳細はリファレンスをご覧ください。

生HTTP通信内容のダンプ処理

  • HttpRequestMessage, HttpResponseMessageの内容を可能な限りHTTP仕様に一致するよう文字列にしています。
  • ボディ部のコンテンツに関して、画像ファイル等のバイナリデータを文字列変換すると、後続データが表示されない場合もあるので、印刷可能文字(英数記号、改行コード、空白)のみ文字列にしています。
    印刷不可の文字は”.”で出力。日本語も対象。
  • 制限事項となりますが、Hostヘッダは出力されません。実際の通信ではHostヘッダも使用しているのですが、HttpRequestMessageからこのヘッダ値を取得できませんでした。
public static async Task<string> GetRawRequestAsync(HttpRequestMessage req)
{
    var sb = new StringBuilder();

    // スタートライン
    sb.AppendLine($"{req.Method} {req.RequestUri.PathAndQuery} HTTP/{req.Version}");

    // HTTPヘッダ(HttpRequestMessageとHttpContentを対象)
    sb.Append(GetHeadersString(req.Headers));
    sb.Append(GetHeadersString(req.Content?.Headers));

    // HTTPヘッダ・ボディを分離する空行
    sb.AppendLine();

    // HTTPボディ
    if( req.Content != null)
    {
        var bytes = await req.Content.ReadAsByteArrayAsync();
        sb.Append(GetContentAsPrintable(bytes));
    }

    return sb.ToString();
}

public static async Task<string> GetRawResponseAsync(HttpResponseMessage res)
{
    var sb = new StringBuilder();

    // スタートライン
    sb.AppendLine($"HTTP/{res.Version} {(int)res.StatusCode} {res.ReasonPhrase}");

    // HTTPヘッダ(HttpRequestMessageとHttpContentを対象)
    sb.Append(GetHeadersString(res.Headers));
    sb.Append(GetHeadersString(res.Content.Headers));

    // HTTPヘッダ・ボディを分離する空行
    sb.AppendLine();

    // HTTPボディ
    var bytes = await res.Content.ReadAsByteArrayAsync();
    sb.Append(GetContentAsPrintable(bytes));

    return sb.ToString();
}


private static string GetHeadersString(HttpHeaders headers)
{
    var sb = new StringBuilder();
    if (headers == null) return sb.ToString();

    foreach (var header in headers)
        foreach (var val in header.Value)
            sb.AppendLine($"{header.Key}: {val}");
    return sb.ToString();
}

private static string GetContentAsPrintable(byte[] bytes)
{
    var sb = new StringBuilder();
    if (bytes == null) return sb.ToString();

    // 印刷可能文字のみ出力(印刷不可は"."で出力)
    foreach (var c in bytes.Select(e => (char)e))
        sb.Append(char.IsAscii(c) &&
            (c == '\r' || c == '\n' || c == ' ' || !char.IsControl((char)c)) ? c : ".");

    return sb.ToString();
}

ダンプの例

いくつかのパターンでの生HTTP通信・応答のダンプの例です。
前述の通り、印刷可能文字(主にASCII文字)以外は、日本語文字も含めて”.”として出力しています。
実装上の制約で、HTTP要求のHOSTヘッダは出力できていません。
(HOSTヘッダはHTTP1.1の必須ヘッダなので、通信内容には必ず含まれています。)

GET要求・JSON応答

※要求の最後には実際にはヘッダとボディを区切る空行が出力されます。(wordpressの都合で非表示)

GET /api/DummyApi?body=json HTTP/1.1
traceparent: 00-b39433e4a000bbb5a8b7647deb605e3c-3dc651e5e262e4b2-00
HTTP/1.1 200 OK
Date: Thu, 02 Nov 2023 03:42:08 GMT
Server: Kestrel
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8

{"result":1,"description":"success"}

フォームPOST要求・画像応答

POST /api/DummyApi?body=image HTTP/1.1
traceparent: 00-ac3abef8243438d1004e4553028da1b6-e6665039958daa39-00
Content-Type: application/x-www-form-urlencoded
Content-Length: 33

key1=%E5%80%A4%EF%BC%91&key2=1234
HTTP/1.1 200 OK
Date: Thu, 02 Nov 2023 03:49:28 GMT
Server: Kestrel
Content-Length: 233
Content-Type: image/png

.PNG
.
...
IHDR...@...@......iq.....sRGB.........gAMA......a.....pHYs..........+.....~IDATx^..... .........-.
...o...j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j....Y...........IEND.B`.

マルチパートPOST要求・JSON応答

POST /api/DummyApi?body=json HTTP/1.1
traceparent: 00-917ef20ceb4a9e010fbe01ec63a2bfc9-2b31a8e12d029258-00
Content-Type: multipart/form-data; boundary="90351cae-4113-4b30-b349-55735bbaf2b5"
Content-Length: 991

--90351cae-4113-4b30-b349-55735bbaf2b5
Content-Type: text/plain; charset=utf-8
Content-Disposition: form-data; name=key1

......
--90351cae-4113-4b30-b349-55735bbaf2b5
Content-Disposition: form-data; name=key2; filename=sample1.png; filename*=utf-8''sample1.png

.PNG
.
...
IHDR...@...@......iq.....sRGB.........gAMA......a.....pHYs..........+.....~IDATx^..... .........-.
...o...j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j..j....Y...........IEND.B`.
--90351cae-4113-4b30-b349-55735bbaf2b5
Content-Disposition: form-data; name=key3; filename="=?utf-8?B?44K144Oz44OX44Or77ySLnBuZw==?="; filename*=utf-8''%E3%82%B5%E3%83%B3%E3%83%97%E3%83%AB%EF%BC%92.png

.PNG
.
...
IHDR...@...@......iq.....sRGB.........gAMA......a.....pHYs..........+......IDATx^..1.. .....)Lg.]h......7b.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.j5.J.|5.....w.....IEND.B`.
--90351cae-4113-4b30-b349-55735bbaf2b5--
HTTP/1.1 200 OK
Date: Thu, 02 Nov 2023 03:51:26 GMT
Server: Kestrel
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8

{"result":1,"description":"success"}

参考

Content-Dispositionのトラブル事例

  • 一部の処理系では、HttpClientが生成するContent-Dispositionヘッダに起因して疎通に失敗する場合があります。
  • Content-Dispositionヘッダにはname, filename等の項目を含めることができ、「Content-Disposition: form-data; name=”key”; filename=”sample.png”;」のように項目の値には引用符が付きます。
  • .NET6のHttpClientでも同様にContent-Dispositionを生成できるのですが、ファイル名がASCII文字のみの場合、「filename=sample1.png」のようにヘッダ内の項目の値に引用符が付きません。日本語等のように非ASCII文字が含まれると引用符が付きます。
  • HTTPの仕様(RFC)として正しいのかの判断まではできなかったのですが、ネットでの解説やサンプルを見ると、引用符が付いているのが一般的のように見えます。
  • 引用符があることを前提としているREST APIでは、システムエラーが発生したり、BAD REQUEST(400)が返却される場合があります。また、ファイル名の文字化けや、ファイル名の先頭・最後の文字が消える(先頭・最後の引用符の削除を試行した結果)場合などがあります。
  • この問題の一番簡単な回避策は、ファイル名に引用符を含める、ことです…