経緯
お仕事で作っている Web API で multipart/mixed
なリクエストを読むっていう仕様の機能を作ったんですけど、何故かリクエストを読もうとすると謎のエラーになる。なぜ? というわけで調べたメモです。
んー。C# HttpClient を使って Multipart なメッセージを Post して、ASP.NET Core で受け取りたいんだけど、どうも Buggy でちゃんと受け取れないみたい。
— ネコⅡ世 (@aetos382) 2021年4月8日
送る側は HttpClient を使ってこいつを送る。https://t.co/TkJExN0p0o
— ネコⅡ世 (@aetos382) 2021年4月8日
受ける側はこいつで受ける。https://t.co/yof2ziW8DW
— ネコⅡ世 (@aetos382) 2021年4月8日
MultipartContent は boundary を強制的に "" で囲むんだけど、これで囲っていると MultipartReader が boundary を検出できなくて死ぬっぽい。テストしてんのか?
— ネコⅡ世 (@aetos382) 2021年4月8日
RFC 的には boundary を "" で囲まないといけないケースはある。別に常に囲っても悪いこたぁないだろう。https://t.co/wjrKVMnK3L
— ネコⅡ世 (@aetos382) 2021年4月8日
サーバー側で Request.ContentType から boundary を得た後で、両端の "" を Trim すれば、とりあえず動くな。
— ネコⅡ世 (@aetos382) 2021年4月8日
MultipartReader のバグな気はするけどな。
マルチパートの基礎知識
普通の HTTP メッセージは、ヘッダーとボディから成りますよね。こんな風に。
これは UTF-8 テキストの hello
という文字列を http://example
に POST
するだけのリクエストです。ヘッダとボディは空行で区切られます。
POST http://example HTTP/1.1 Content-Type: text/plain; charset=utf-8 hello
マルチパート メッセージは、ボディの中に、入れ子になったヘッダーとボディがいくつか入った構造をしています。それぞれのパートはバウンダリという文字列で区切られ、バウンダリは親の Content-Type ヘッダーの boundary パラメーターで示されます。
以下の例は、UTF-8 テキストの hello
と world
という文字列からなるマルチパート メッセージを http://example
に POST
するリクエストの例です。各パートのヘッダとボディも、通常のメッセージと同じように空行で区切られます。
POST http://example HTTP/1.1 Content-Type: multipart/mixed; boundary=cf79c84f-c19c-4964-8f24-c22542458e8b --cf79c84f-c19c-4964-8f24-c22542458e8b Content-Type: text/plain; charset=utf-8 hello --cf79c84f-c19c-4964-8f24-c22542458e8b Content-Type: text/plain; charset=utf-8 world --cf79c84f-c19c-4964-8f24-c22542458e8b--
ここでは一階層しか入れ子にしていませんが、子パートの Content-Type ヘッダーにも multipart/*
を指定することで、複数階層の入れ子にすることもできます。
マルチパート メッセージを Web API で使う例はあまり見ないかもしれませんが、HTML メール等では日常的に使われる形式です*1。
マルチパート リクエストの読み方
ASP.NET Core のサーバー サイドでマルチパートなリクエストを読むには、MultipartReader クラスを使います。
MultipartReader
クラスのコンストラクタにはバウンダリを渡す必要があります。バウンダリは Content-Type ヘッダの一部なので MediaTypeHeaderValue
クラスでパースすることが出来ます。
なお、このクラスは、.NET BCL と ASP.NET Core のそれぞれに名前空間違いで同名のものが存在する(メンバーも異なる)ので紛らわしいです。
- .NET BCL の方は System.Net.Http.Headers.MediaTypeHeaderValue
- ASP.NET Core の方は Microsoft.Net.Http.Headers.MediaTypeHeaderValue
どちらを使ってもできますが、コードは微妙に異なるものになります。
以下のサンプルでは .NET BCL の方を使います*2。
var request = /* HttpContext */ context.Request; var mediaType = MediaTypeHeaderValue.Parse(request.ContentType); var boundary = mediaType.Parameters.SingleOrDefault(x => string.Equals(x.Name, "boundary", StringComparison.OrdinalIgnoreCase)); var reader = new MultipartReader(boundary, request.Body); var section = await reader.ReadNextSectionAsync().ConfigureAwait(false);
section は MultipartSection オブジェクトです。
あとは section の Body プロパティを参照して自前で読むなり、文字列にしたいなら ReadAsStringAsync メソッドを使うなり。
リクエスト中のすべてのパートを列挙したいなら、戻り値が null
になるまで ReadNextSectionAsync メソッドを繰り返し呼べばよいです。
マルチパート リクエストの書き方
クライアント サイドで HttpClient クラスを使ってマルチパート リクエストを送出するには、MultipartContent クラスを使えばよいです。
以下のようなコードで、text/plain
なパート 1 つだけを持つ multipart/mixed
な POST
リクエストを送出することができます。
MultipartContent
クラスのコンストラクタでバウンダリを指定することもできますが、特に指定しなければ GUID 形式のバウンダリが自動生成されます。
using var part = new StringContent("hello"); part.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain; charset=utf-8"); using var requestContent = new MultipartContent("mixed"); requestContent.Add(part); using var request = new HttpRequestMessage(HttpMethod.Post, "http://example"); request.Content = requestContent; using var response = await /* HttpClient */ client .SendAsync(request).ConfigureAwait(false);
組み合わせよう
上記のクライアント サイドとサーバー サイドのコードを結合すると、動きません。具体的に言うと ReadNextSectionAsync
メソッドが不可解な例外を吐いて死にます。
ところが、VSCode の REST Client なんかを使ってリクエストすると動いちゃうんですよね。
冒頭の「基礎知識」のところで書いたリクエストを、VSCode を使って送出すると、これは普通に読めちゃいます。
原因
動くリクエストと動かないリクエストをよくよく見比べると、一つだけ差異がありました。エラーが出る方は、Content-Type ヘッダーの boundary パラメーターの値がダブル クォーテーションで囲まれていたのです。
つまり、実際に送信されていたリクエストはこんな感じだったわけです。
POST http://example HTTP/1.1 Content-Type: multipart/mixed; boundary="cf79c84f-c19c-4964-8f24-c22542458e8b" --cf79c84f-c19c-4964-8f24-c22542458e8b Content-Type: text/plain; charset=utf-8 hello --cf79c84f-c19c-4964-8f24-c22542458e8b--
MultipartContent
クラスは、(暗黙的に生成される場合も含め)boundary パラメーターの値がダブル クォーテーションで囲まれていない場合、自動的に値の前後にダブル クォーテーションを付加します。
RFC 2045 §5.1 では、パラメーターの値はダブル クォーテーションがある場合とない場合とで等価であると書かれています。
Note that the value of a quoted string parameter does not include the quotes. That is, the quotation marks in a quoted-string are not a part of the value of the parameter, but are merely used to delimit that parameter value. In addition, comments are allowed in accordance with RFC 822 rules for structured header fields. Thus the following two forms
Content-type: text/plain; charset=us-ascii (Plain text)
Content-type: text/plain; charset="us-ascii"
are completely equivalent.
また、RFC 2046 §5.1.1 では、boundary パラメーターの値はダブル クォーテーションで囲んでおいて損はないと書かれています。
The grammar for parameters on the Content-type field is such that it is often necessary to enclose the boundary parameter values in quotes on the Content-type line. This is not always necessary, but never hurts.
バウンダリとして採用する文字によっては、ダブル クォーテーションで囲まなければならない場合もあります*3が、少なくとも GUID 形式の場合は、その必要はありません。MultipartContent
クラスは、必要か否かにかかわらず、常に boundary パラメーターの値をダブル クォーテーションで囲みますが、そのこと自体は悪いことではありません。
問題は、MultipartReader
クラスは、boundary パラメーターの値がダブル クォーテーションで囲まれているとパートの検出に失敗するということです。
パート間のバウンダリはダブル クォーテーションで囲まれていないのに、Content-Type ヘッダーの boundary パラメーターの値はダブル クォーテーションで囲まれているので、比較したときに不一致になってしまって、パートの区切り位置を検出できていないんだと思います。
回避策
原因がわかれば回避は簡単です。MultipartReader
クラスのコンストラクタ引数には、boundary パラメーターの値からダブル クォーテーションを除去してから与えればよいのです。
修正したサーバー サイドのコードは以下のようになります。
var request = /* HttpContext */ context.Request; var mediaType = MediaTypeHeaderValue.Parse(request.ContentType); var boundary = mediaType.Parameters.SingleOrDefault(x => string.Equals(x.Name, "boundary", StringComparison.OrdinalIgnoreCase)); // boundary を囲むダブル クォーテーションを除去する var reader = new MultipartReader(boundary.Trim('"'), request.Body); var section = await reader.ReadNextSectionAsync().ConfigureAwait(false);
付録
検証用に書いたコードを載せておきます。これだけでサーバー サイドとクライアント サイドの両方を兼ねています。
いちいち Web アプリを実行するのが面倒だったので、テスト用のサーバーをインプロセスで立てました。
using System; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.WebUtilities; using Xunit; namespace TestProject1 { public class UnitTest1 { [Fact] public async Task Test1() { var builder = new WebHostBuilder() .Configure(appBuilder => { appBuilder.Run(async context => { // この中がサーバー サイド var body = context.Request.Body; var mediaType = MediaTypeHeaderValue.Parse(context.Request.ContentType); var boundary = mediaType.Parameters .SingleOrDefault(x => string.Equals(x.Name, "boundary", StringComparison.OrdinalIgnoreCase)); // boundary を囲むダブル クォーテーションを除去する var reader = new MultipartReader(boundary.Value.Trim('"'), body); var section = await reader.ReadNextSectionAsync().ConfigureAwait(false); var content = await section.ReadAsStringAsync().ConfigureAwait(false); var response = context.Response; response.StatusCode = 200; response.ContentType = "text/plain; charset=utf-8"; await response .WriteAsync(content + content) .ConfigureAwait(false); }); }); using var server = new TestServer(builder); // ここからクライアント サイド using var client = server.CreateClient(); using var part = new StringContent("hello"); part.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain; charset=utf-8"); using var requestContent = new MultipartContent("mixed"); requestContent.Add(part); using var request = new HttpRequestMessage(HttpMethod.Post, "http://example"); request.Content = requestContent; using var response = await client.SendAsync(request).ConfigureAwait(false); Assert.Equal(200, (int)response.StatusCode); var responseContent = await response.Content .ReadAsStringAsync() .ConfigureAwait(false); Assert.Equal("hellohello", responseContent); } } }
*1:HTML メールを送る場合、HTML メール未対応のメーラー(いまどきあるのか?)でも表示できるよう、プレーン テキスト形式の本文も付けるのが慣例です。こういう場合は multipart/alternative を使います。また、HTML メールにインライン画像を含める場合は、multipart/related を使います。
*2:サーバー サイドでは ASP.NET Core の方を使うのが楽なのですが、記事末尾に掲載しているサンプル コードでは使いにくいため。
*3:詳しくは RFC 2045 §5.1 を参照