しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

ASP.NET の HTTP エラー画面の表示処理を IIS 側に統一する

以前に ASP.NET の customErrors と IIS の httpErrors の違いについて書きました。

結局、この時には customErrors と httpErrors の両方を Web.config に追加するという結論に達しましたが、やはり二つを同時に設定するのは手間なので IIS 側に統一してみました。

元々の Web.config はこのように記述していました。これをベースに変更します。

<system.web>
  <customErrors mode="On">
    <error statusCode="404" redirect="/home/error"/>
  </customErrors>
</system.web>
<system.webServer>
  <httpErrors errorMode="Custom">
    <remove statusCode="404" />
    <error statusCode="404" path="/home/error" responseMode="Redirect" />
  </httpErrors>
</system.webServer>

今回は customErrors での処理を行いたくないので、出来るだけ使わないように設定します。

<system.web>
  <customErrors mode="RemoteOnly" />
</system.web>

mode は適当な感じですが、開発中はエラーが見えた方が良いので RemoteOnly にします。ステータスコードによってエラーページのリダイレクトを行う設定は、要素ごと削除しました。

このままだと ASP.NET 側でエラーが発生した場合には IIS の設定がスキップされて、いつものカスタムエラーページが表示されてしまいますが、httpErrors の existingResponse を Replace に設定すると、関係なく IIS の設定に従って画面を表示することが出来ます。

<system.webServer>
  <httpErrors errorMode="Custom" existingResponse="Replace">
    <remove statusCode="404" />
    <error statusCode="404" path="/home/error" responseMode="ExecuteURL" />
  </httpErrors>
</system.webServer>

responseMode は ExecuteURL に設定してあります。この場合にはサーバー側で path に指定した URL を実行してくれるので、リダイレクトすることなくエラー画面を返すことが出来るようになります。

設定の詳細については IIS 公式サイトのリファレンスを参照しておいてください。

Adding HTTP Errors <error> | Microsoft Learn

これでカスタムなエラーページを URL そのままで表示できるようになりました。ASP.NET と IIS で発生した 404 エラーは両方この設定通りに処理が行われます。

しかし、このままだと 200 が返ってしまうので、ステータスコードを 404 に変更してビューを返します。

public class HomeController : Controller
{
    public ActionResult Error()
    {
        Response.StatusCode = 404;

        return View();
    }
}

余談になりますが、responseMode が Redirect を設定している場合には、TrySkipIisCustomErrors を true に設定しないと無限ループになります。

全て設定が終わったので、実際に開発者ツールを使って確認してみました。

URL はアクセスしたそのままで、カスタムエラー画面と 404 が返っていることが確認出来ました。

ASP.NET MVC で HTTP 401 を返した時にログインページへリダイレクトさせない方法

割と有名な ASP.NET のフォーム認証モジュールのおせっかい機能として、HTTP ステータスコードで 401 を返すと自動的にログインページへの 302 リダイレクトに変換するというのがあります。

認証が必要な Web サイトを作る場合には割と便利なんですが、API を MVC でサクッと作りたい場合には厄介です。API としては 401 を返したいのに、勝手にログインページへのリダイレクトになるからです。

f:id:shiba-yan:20150612140747p:plain

ちなみに ASP.NET Web API の Nightly 版でも同じ問題がありましたが、製品版ではアプリケーション設定に特殊なキーを追加することで回避可能になりました。

最初から Web API で作っとけよという感じですが、今回久しぶりにはまったので書きます。

ASP.NET 4.5 から HttpResponse に SuppressFormsAuthenticationRedirect というそのまんまな名前のプロパティが追加されています。

HttpResponse.SuppressFormsAuthenticationRedirect プロパティ (System.Web)

このプロパティを true にすればリダイレクトは回避出来るんですが、このプロパティに値をセットするタイミングで少し悩みました。

最初は OnActionExecuting をオーバーライドして設定すればいいだろと思って試しました。

public class ApiController : Controller
{
    protected override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        Response.SuppressFormsAuthenticationRedirect = true;

        base.OnActionExecuting(filterContext);
    }

    [Authorize]
    public ActionResult Auth()
    {
        return Json(true, JsonRequestBehavior.AllowGet);
    }
}

ところが実際に試すとリダイレクトされたままです。

少し考えると、そもそも Authorize 属性を付けた場合には OnActionExecuting が実行される前に処理が打ち切られてしまうので、そもそも呼び出されないことに気が付きました。

public class ApiController : Controller
{
    protected override void Initialize(System.Web.Routing.RequestContext requestContext)
    {
        requestContext.HttpContext.Response.SuppressFormsAuthenticationRedirect = true;

        base.Initialize(requestContext);
    }

    [Authorize]
    public ActionResult Auth()
    {
        return Json(true, JsonRequestBehavior.AllowGet);
    }
}

必ず呼び出されるメソッドを考えた結果、今回は Initialize をオーバーライドすることにしました。

f:id:shiba-yan:20150612140759p:plain

これで 401 を返してもリダイレクトされることが無くなりました。

なぞなぞ認証を ASP.NET MVC で実装して遊んでみた

特に意味はないのですが、はてなの各サービスで使えるようになっているなぞなぞ認証を、ASP.NET MVC の機能を使って実装してみました。なぞなぞ認証の詳細ははてなキーワードを見てください。

単純になぞなぞに回答して、それが正解であればページを表示できる機能です。最近は新機能の紹介的なことばかりやってきて疲れてきたので、たまにはこんな実装をしてみるのもいいでしょう。

ASP.NET MVC で実装するので MVC 5 で追加された IAuthenticationFilter を使ってみようと思ったのですが、なぞなぞ認証の場合はユーザーの特定は出来ないので ActionFilter として素直に実装しました。

実装して試した結果

今回はまず実装したらどのような挙動になるのかを紹介しておきます。実際にルートに対してなぞなぞ認証を有効にした場合、専用のフォームが表示されるようになります。

この答えは Azure を使っている方なら誰でも分かると思います。当然ながら答えは kosmosebi ですね。

正しい答えを入力して送信ボタンを押すと、実際のページが表示されるようになります。

これがなぞなぞ認証の大雑把な流れになります。

実際には一度正解した場合には再度入力することの無いように、クッキーに何らかの情報を入れておくべきだと思ったので、適当にクッキーに保存するような処理を実装しました。

コードの解説など

なぞなぞ認証は ActionFilter として実装するので、継承した RiddleAuthenticationAttribute というクラスを作成しました。実装としては簡単で、問題文と答えをコンストラクタで受け取って、ユーザーから答えが入力された場合にはチェックしているだけです。

初回アクセスや、回答が間違っていた場合にはフォームを強制的に表示するようにしています。

public class RiddleAuthenticationAttribute : ActionFilterAttribute
{
    public RiddleAuthenticationAttribute(string question, string answer)
    {
        _question = question;
        _answer = answer;

        var hash = SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(_question + ":" + _answer));

        _riddleHash = BitConverter.ToString(hash).Replace("-", "").ToLower();
    }

    private readonly string _question;
    private readonly string _answer;

    private readonly string _riddleHash;

    private const char Separator = '|';
    private const string RiddleCookieName = "riddle";

    private const string DefaultAnswerName = "riddle-answer";
    private const string DefaultViewName = "_RiddleForm";

    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        // 既に回答済みか確認
        var cookie = filterContext.HttpContext.Request.Cookies[RiddleCookieName] ?? new HttpCookie(RiddleCookieName);

        var values = (cookie.Value ?? "").Split(new[] { Separator }, StringSplitOptions.RemoveEmptyEntries);

        if (values.Contains(_riddleHash))
        {
            return;
        }

        // 回答が入力されたか確認
        var postAnswer = filterContext.HttpContext.Request.Form[DefaultAnswerName];

        if (postAnswer == _answer)
        {
            cookie.Value = string.Join(Separator.ToString(), values.Concat(new[] { _riddleHash }));

            filterContext.HttpContext.Response.SetCookie(cookie);

            return;
        }

        // なぞなぞ認証フォームを表示
        filterContext.Result = new ViewResult
        {
            ViewData = new ViewDataDictionary
            {
                { "Question", _question }
            },
            ViewName = DefaultViewName
        };
    }
}

多少、フォームの項目は決め打ちが多いですが、眠たいのと遊びなのでこのように実装しました。

この属性を使った例は以下のようになります。単純にアクションへ追加するだけです。

public class HomeController : Controller
{
    [RiddleAuthentication("Azure 界の抱かれたい男 No.1 とは?", "kosmosebi")]
    public ActionResult Index()
    {
        return View();
    }
}

ちなみになぞなぞ認証のフォームは以下のような形です。特にいうこともないシンプルなフォームです。

<h2>なぞなぞ認証</h2>

@using (Html.BeginForm())
{
    <div class="form-group">
        <label>@ViewBag.Question</label>
        <input type="text" name="riddle-answer" class="form-control" />
    </div>
    <button type="submit" class="btn btn-primary">送信</button>
}

実際に使うかと言われると微妙ですが、ActionFilter を使う例としては分かりやすいサンプルになったのではないかと思います。IAuthenticationFilter は Basic 認証の例をどこかでアップしておきたいと思います。

IIS 10.0 と ASP.NET 4.6 で HTTP/2 のサーバープッシュが使えるようになっていたので試した

IIS 10.0 で HTTP/2 のサーバープッシュに対応しているか分からないと書きましたが、実際には ASP.NET からサーバープッシュを簡単に行えるようになっていました。

まずは HTTP/2 のサーバープッシュについての参考になる記事を紹介しておきます。

初めてのHTTP/2サーバプッシュ | GREE Engineering
HTTP/2 入門 - Yahoo! JAPAN Tech Blog

要するに、リクエストされたページに必要なリソースを予めクライアントに送信しておくための仕様ですね。ページをパースしてからリソースを取りに行くのと異なり、既にダウンロード済みとなるので、素早く表示することが出来るというからくりです。

ASP.NET 4.6 での対応

ASP.NET 4.6 の API を軽く調べてみると、HttpResponse に PushPromise というメソッドが追加されていることに気が付きました。これは ASP.NET 4.6 の HTTP/2 対応らしいです。

HttpResponse.PushPromise Method (System.Web) | Microsoft Learn

HttpResponse クラスと HttpResponseBase クラスの両方に実装されているので、Web Forms と MVC の両方で問題なく使えるようになっています。

簡単に試したいので、Web Forms を使ってサンプルコードを書いてみました。1 行ですけど。

public partial class Default : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        Response.PushPromise("~/iisstart.png");
    }
}

PushPromise メソッドの引数は仮想パスじゃないとエラーになります。

サーバープッシュ無し

まずは PushPromise メソッドの呼び出しをコメントアウトして試したところ、iistart.png のリクエストでは TTFB が大きな時間を占めていることが分かります。

サーバーからデータを受信し始めるまで時間がかかってます。

サーバープッシュ有り

次に PushPromise メソッドの呼び出しを有効にして試したところ、TTFB がほぼ無くなりました。

Request Sent に時間がかかるようになっていますが、これは純粋にプッシュされた画像のダウンロードに時間がかかっているのかもしれません。

Chrome で HTTP/2 のフレームを確認してみたところ、ちゃんとプッシュされていることが確認出来ます。

レンダリングがブロックされる JavaScript や CSS をプッシュした方が効果が大きい気がします。

ASP.NET MVC から使う

本来なら透過的に静的なリソースをプッシュするような HTTP Module を実装するのが良いと思いましたが、ちょっと手間なので MVC の URL ヘルパーとして実装してみました。

public static string PushContent(this UrlHelper urlHelper, string contentPath)
{
    var context = urlHelper.RequestContext.HttpContext;

    context.Response.PushPromise(contentPath);

    return urlHelper.Content(contentPath);
}

実装としては PushPromise を呼び出す単純なヘルパーです。使い方は Url.Content と同じですが、プッシュしたいコンテンツの時に使うようにするだけで恩恵に与れます。

ブラウザで確認すると、TTFB はとても短くなっているのでプッシュされていることが確認出来ます。

今回は手を抜いて全コンテンツに対してプッシュを使ってみましたが、サーバープッシュを使うとキャッシュが効かなくなるので、CDN で配信可能なものに関しては有効にしない方が良い気がしました。

Firefox でも試してみると、プッシュが効いているからかタイミングすら表示されなかったです。

コンテンツを事前に送り込むというのは、キャッシュされないということを考えると、思っていたよりも使いどころが難しい機能だと感じました。

ASP.NET Web API を使って画像を Motion JPEG で配信する

Raspberry Pi 2 が Azure Blob に保存した画像を Motion JPEG として配信してみたくなったので、まずはローカルに保存している画像を Motion JPEG として返す Web API を作って試してみました。

Motion JPEG - Wikipedia

H.264 で圧縮しても良い気がしますが、エンコードしながら HTTP で出力する難易度がとても高そうだったので、今回はとても簡単な Motion JPEG です。

Web API に用意されている PushStreamContent を使うと、サーバーからのプッシュが簡単に実装できるので、これを利用して JPEG データを定期的に送信することで動画っぽく見せます。

http://blogs.msdn.com/b/henrikn/archive/2012/04/23/using-cookies-with-asp-net-web-api.aspx

中の人がブログで簡単な使い方を紹介してくれています。

要するに PushStreamContent を Content として返すと、レスポンスのストリームを明示的に閉じるか、HTTP 自体が閉じられるまで自由に書き込みできるという話です。動画をバッファリングして配信したり、Server-Sent Events の実装もこれを使えば簡単でしょう。

ちなみに Motion JPEG として配信するための HTTP レスポンスについては、以下の Wiki に書いてあったので参考に実装しました。

[Wiki] Motion JPEG | en.code-bude.net

まずは、ローカルに保存してある画像を出力するだけの API を作ってみました。

public class StreamController : ApiController
{
    private readonly byte[] _newLine = Encoding.UTF8.GetBytes("\r\n");
    private readonly string _boundary = "0123456789";

    public HttpResponseMessage Get()
    {
        var response = Request.CreateResponse();

        response.Content = new PushStreamContent(async (stream, content, context) =>
        {
            foreach (var file in Directory.GetFiles(@"C:\Users\shibayan\Documents\snapshot", "*.jpg"))
            {
                var fileInfo = new FileInfo(file);

                // ヘッダー書き込み
                var header = string.Format("--{0}\r\nContent-Type: image/jpeg\r\nContent-Length: {1}\r\n\r\n", _boundary, fileInfo.Length);
                var headerData = Encoding.UTF8.GetBytes(header);

                await stream.WriteAsync(headerData, 0, headerData.Length);

                // JPEG データ書き込み
                await fileInfo.OpenRead().CopyToAsync(stream);

                // 最後の改行書き込み
                await stream.WriteAsync(_newLine, 0, _newLine.Length);

                // 30fps で頑張ってみるつもり
                await Task.Delay(1000 / 30);
            }
        });

        // PushStreamContent の mediaType に指定すると検証エラーになったのでここで追加
        response.Content.Headers.TryAddWithoutValidation("Content-Type", "multipart/x-mixed-replace;boundary=" + _boundary);

        return response;
    }
}

いつも通り手抜きな感じですが、とりあえずこれで動作を確認してみます。

残念と言うか当然という感じではありますが、世間的には multipart/x-mixed-replace は全く流行らなかったらしく、ブラウザとしては Firefox しか対応していないようです。

問題なく再生出来ました。正確には素早く JPEG を差し替えているだけですが。

他には VLC Media Player を使って、ネットワークストリームとして開くことで再生出来ました。

本来の目的である、Azure Blob に保存している画像を Motion JPEG として配信することに関しては、JPEG を読み込む元を変更するぐらいで対応出来ると思います。

ASP.NET Web API でバージョニングを実現する方法を調べた

一般的に Web API のバージョニングを実現する方法として、URL にバージョンを含める方法とクエリパラメータや HTTP ヘッダーでバージョンを指定する方法の 2 種類が使われています。

例を挙げると Twitter や Instagram の API は URL でバージョンを指定します。

https://api.twitter.com/1.1/statuses/user_timeline.json

https://api.instagram.com/v1/media/popular?client_id=CLIENT-ID

対して Azure の REST API では、クエリパラメータや HTTP ヘッダーでバージョンを指定します。

https://[search service name].search.windows.net/indexes?api-version=2015-02-28

今回は Twitter や Instagram ような URL ベースのバージョニングを、ASP.NET Web API で実現する方法を調べました。思ったよりめんどくさい感じでした。

IHttpControllerSelector を実装して実現

Web API の DefaultHttpControllerSelector はコントローラをクラス名だけでグルーピングしてしまうので、名前空間が異なっていたとしても同じものとして扱われてしまいます。

string controllerName = controllerTypeGroup.Key;

foreach (IGrouping<string, Type> controllerTypesGroupedByNs in controllerTypeGroup.Value)
{
    foreach (Type controllerType in controllerTypesGroupedByNs)
    {
        if (result.Keys.Contains(controllerName))
        {
            duplicateControllers.Add(controllerName);
            break;
        }
        else
        {
            result.TryAdd(controllerName, new HttpControllerDescriptor(_configuration, controllerName, controllerType));
        }
    }
}
ASP.NET MVC / Web API / Web Pages - Source Code

クラス名に V1 とか V2 と付ければ問題無いですが、やはり名前空間でバージョンを分けておきたいので IHttpControllerSelector を実装して対応します。

規約ベースで実装

最初に考えられるのは規約ベースでバージョンを決めてしまう方法だと思います。実際に MSDN Blog でもバージョンを URL ルーティングに追加し、名前空間にマッピングさせる方法が紹介されています。

ASP.NET Web API: Using Namespaces to Version Web APIs - .NET Web Development and Tools Blog - Site Home - MSDN Blogs

規約ベースで書けるので追加の設定など必要なく、名前空間とコントローラを増やすだけで複数バージョンに対応出来ますし、サンプルコードが CodePlex で提供されているので、そのまま使うことが出来そうです。

http://aspnet.codeplex.com/SourceControl/latest#Samples/WebApi/NamespaceControllerSelector/

規約ベースで開発している場合は、こっちを選んだ方がスムーズな気がします。

Attribute Routing で実装

既に ASP.NET Web API で Attribute Routing を使った開発をしている場合には、名前空間でコントローラを分けて RoutePrefix でバージョンを直接指定する方法も考えられます。

namespace SampleWebApi.Controllers.V1
{
    [RoutePrefix("v1/products")]
    public class ProductsController : BaseApiController
    {
        [Route]
        public async Task<IHttpActionResult> GetProduct()
        {
            return Ok();
        }
    }
}

残念ながら DefaultHttpControllerSelector の制限でこのままでは動作しませんし、サンプルコードで用意されている NamespaceControllerSelector を使っても対応出来ないので、自前で同じように実装しました。

重要な部分は SelectController メソッドなので、その部分だけ引っ張り出してきました。

public HttpControllerDescriptor SelectController(HttpRequestMessage request)
{
    var routeData = request.GetRouteData();

    if (routeData == null)
    {
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }

    var subRoute = routeData.GetSubRoutes().FirstOrDefault();

    if (subRoute == null)
    {
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }

    var actions = (HttpActionDescriptor[])subRoute.Route.DataTokens["actions"];

    if (actions.Length == 0)
    {
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }

    return actions[0].ControllerDescriptor;
}

Attribute Routing を使った場合には MS_SubRoutes というキーが RouteData に含まれていて、更にその中に HttpActionDescriptor が入っているので辿っていきます。

最後に HttpControllerDescriptor を返せば、そのコントローラが使われるようになります。

ライブラリを使って実現

自分で IHttpControllerSelector を実装するのもめんどくさい場合には、手軽に公開されているライブラリを使うという方法もあります。

このライブラリを使えば URL と HTTP ヘッダーを使ったバージョニングの両方が使えるようになるみたいです。実際に使い方を紹介している方が居たのでリンクを載せておきます。

Versioning ASP.Net Web API | Culbertson Exchange

最近は Attribute Routing ばかり使っているので、IHttpControllerSelector を実装して解決しました。名前空間を無視するかどうかを設定で切り替えさせてほしかったですね。

ASP.NET Web API でアップロードされたファイルを扱う方法を知らなかったので調べた

ASP.NET MVC でファイルアップロードを実装する場合は、モデルバインダが HttpPostedFileBase クラスなどに自動的にバインドしてくれていましたが、Web API だとそこまで面倒は見てくれないみたいです。

これまで Web API での実装方法を知らなかったですが、今回ついに観念して調べました。

Sending HTML Form Data in ASP.NET Web API: File Upload and Multipart MIME | The ASP.NET Site

MultipartFormDataStreamProvider クラスを使うとアップロードされたファイルをストレージ上に保存してくれるみたいですね。メモリストリームとして扱い続けるよりも楽な感じがします。

サンプルコードでは App_Data に入れてましたが、個人的に Temp のが良い気がしたので変えました。

public async Task<IHttpActionResult> FileUpload()
{
    // multipart/form-data 以外は 415 を返す
    if (!Request.Content.IsMimeMultipartContent())
    {
        return StatusCode(HttpStatusCode.UnsupportedMediaType);
    }

    // マルチパートデータを一時的に保存する場所を指定
    var rootPath = Path.GetTempPath();
    var provider = new MultipartFormDataStreamProvider(rootPath);

    // 実際に読み込む
    await Request.Content.ReadAsMultipartAsync(provider);

    // ファイルデータを読む
    foreach (var file in provider.FileData)
    {
        // ファイル情報を取得
        var fileInfo = new FileInfo(file.LocalFileName);
    }

    return Ok();
}

アップロード時に渡されたファイル名などの情報は Headers プロパティから参照できます。

MultipartFormDataStreamProvider クラスを使って実際にアップロードしてみると、Temp ディレクトリにユニークなファイル名でアップロードしたファイルが保存されていることが分かりますね。

f:id:shiba-yan:20150319160736p:plain

単純なファイルアップロードが必要な場合はこれで十分だと思います。

他にファイルアップロード時に使えるプロバイダーとしては MultipartFormDataRemoteStreamProvider クラスも用意されています。このプロバイダーは書き込み可能なストリームを返すような実装を用意することで、アップロードされたファイルを直接保存することが出来ます。

実際に Azure Blob へのストリームを返すプロバイダーを実装してみました。

public class AzureBlobRemoteStreamProvider : MultipartFormDataRemoteStreamProvider
{
    public AzureBlobRemoteStreamProvider()
    {
        _storageAccount = CloudStorageAccount.Parse("...");
    }

    private readonly CloudStorageAccount _storageAccount;

    public override RemoteStreamInfo GetRemoteStream(HttpContent parent, HttpContentHeaders headers)
    {
        var fileName = headers.ContentDisposition.FileName.Trim('"');

        var blobClient = _storageAccount.CreateCloudBlobClient();

        var container = blobClient.GetContainerReference("upload");
        var blob = container.GetBlockBlobReference(fileName);

        var stream = blob.OpenWrite();

        return new RemoteStreamInfo(stream, blob.Uri.AbsoluteUri, fileName);
    }
}

アップロードされたファイル名のまま upload というコンテナに保存するだけの簡単なプロバイダーです。

使い方はシンプルで、ReadAsMultipartAsync に渡すプロバイダーを切り替えるだけです。

public async Task<IHttpActionResult> Post()
{
    var provider = new AzureBlobRemoteStreamProvider();

    await Request.Content.ReadAsMultipartAsync(provider);

    foreach (var file in provider.FileData)
    {
        var blobUri = file.Location;
    }

    return Ok();
}

実際にアップロードすると、Blob に書きこまれていることが確認出来ます。

f:id:shiba-yan:20150319162151p:plain

GetRemoteStream メソッドでもうちょっと処理を頑張ると、アップロードしたユーザー別にコンテナを作成して保存するといった処理も実装できると思います。

SendGrid の Inbound Parse Webhook を ASP.NET MVC から簡単に使うためのヘルパーを作った

以前に SendGrid の Parse API を使ってメールの受信をフックする - しばやん雑記 で使ったことがある SendGrid の Inbound Parse Webhook は、簡単にメールの受信が出来てとても便利なのですが、送信されるパラメータが多く、部分的に JSON だったりもするので、簡単に使うためにヘルパークラスを作ってみました。

ちなみに Inbound Parse Webhook のドキュメントは以下の通り。当然ながら英語です。*1

Inbound Parse Webhook - SendGrid Documentation | SendGrid

今回は ASP.NET MVC を使って Parse Webhook 向けにモデルバインダと属性を用意しました。サンプルコードは既に GitHub にて公開してあります。

折角なので、それぞれについて簡単に使い方を紹介しておきます。

ParseModelBinder クラス

Webhook の呼び出しと同時に渡されるパラメータをいい感じにクラスにバインドする ParseModelBinder と、その器である ParseInfo クラスを用意しました。

使い方は以下のようにとてもシンプルです。

public class WebhookController : Controller
{
    [HttpPost]
    public ActionResult Index(ParseInfo model)
    {
        
        return new HttpStatusCodeResult(200);
    }
}

ParseInfo クラスに ModelBinder 属性を付けてあるので、普通にメソッドの引数として受け取るだけです。

実際にメールを受信した際には、以下のようにデータがバインドされた形で渡されます。

f:id:shiba-yan:20150127144327p:plain

ParseInfo には扱いやすい形でデータを入れているので、空メールのハンドリングや添付ファイルを利用するアプリケーションなどで便利に使えると思います。

ParseTo 属性

Parse Webhook では特定のホスト宛てのメールを全て同じエンドポイントで受信するので、どのアドレス宛に送られたのかは Webhook 側で判断する必要があります。

しかし、やはりちょっとめんどくさいと思ったので ASP.NET MVC の ActionMethodSelector 属性を継承した ParseTo 属性を実装しました。コンストラクタで指定したメールアドレス宛てのメールのみ、そのアクションが実行されるようになります。

これもコードは以下のようにシンプルです。

public class WebhookController : Controller
{
    [HttpPost]
    [ParseTo("info@shibayan.jp")]
    public ActionResult Index(ParseInfo model)
    {
        return new HttpStatusCodeResult(200);
    }

    [HttpPost]
    [ParseTo("web@shibayan.jp")]
    public ActionResult Index(string to, ParseInfo model)
    {
        return new HttpStatusCodeResult(204);
    }
}

実際にメールを送り、ngrok を使って確認してみました。

f:id:shiba-yan:20150127151523p:plain

送信先メールアドレスと同じ値の ParseTo 属性が付いているアクションが呼ばれているのが確認出来ました。

メールを受信したタイミングで何らかのアクションを実行したいという要求は思った以上にあると思うので、これからも Parse Webhook を便利に使っていきたいですね。

*1:KKE に相談すれば日本語のドキュメントをくれるのかもしれないけど

ASP.NET の Response.Cookies を弄ると空のクッキーが生成されて困った話

ASP.NET で Response.Cookies にとある名前のクッキーが既に入っているかをチェックするコードを書いたところ、空っぽのセッションクッキーが作られることに気が付きました。

例を挙げると以下のようなコードを書いた時です。

public partial class Default : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Response.Cookies["hoge"] != null)
        {
            // 何かする
        }
    }
}

やりたいことは既にクッキーが発行されているかのチェックなのですが、どうもインデクサで触った瞬間にクッキーが作られてしまうようです。

実際に動かすと、空のクッキーが発行されているのが確認できます。

f:id:shiba-yan:20150114124727p:plain

RCW 経由で COM のインデクサを使った時にメモリリークしたのを思い出しました。

Request.Cookies を使う時と同じような感覚で使うと、ちょっとめんどくさいことになるようです。なので AllKeys をまずチェックしてから値を取り出すような拡張メソッドを書きました。

public static class HttpCookieExtension
{
    public static bool Contains(this HttpCookieCollection cookies, string name)
    {
        return cookies.AllKeys.Any(p => p == name);
    }

    public static HttpCookie Peek(this HttpCookieCollection cookies, string name)
    {
        return cookies.AllKeys.Any(p => p == name) ? cookies[name] : null;
    }
}

色々とメソッドが足りていないので、やっぱり HttpCookieCollection って古いコレクションなんだと実感出来ますね。きっと中身は NameValueCollection とかになってるんでしょう。

先ほどの例のようにクッキーが既に返されているかチェックする処理は、以下のように書けます。

public partial class Default : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Response.Cookies.Contains("hoge"))
        {
            // 何かする
        }
    }
}

これで新しくクッキーが作成されることなく弄ることが出来ました。

ASP.NET MVC で任意のタイミングでモデルの検証を行う方法

MVC 5 でウィザード的に 1 ページごとに項目を入れていくページを作っていると、実際に DB に格納する前にセッションに入れておいたモデルの状態が正しいか調べたくなりました。

てっきり「あー、UpdateModel / TryUpdateMode の中身参考にしないといけないのか、めんどくさいなー」と思っていたところ、かなり前*1から Controller クラスに ValidateModel / TryValidateModel メソッドが用意されていることに気が付きました。

Controller.TryValidateModel メソッド (System.Web.Mvc)
Controller.ValidateModel メソッド (System.Web.Mvc)

MVC 5 の機能は一通りは触ってるだろうと思っていたら、こんな初歩的な部分を見落としていたことに恥ずかしい限り…。戒めとしてこの記事を書きます。

これらのメソッドは基本的にモデルバインダーが内部で行っている検証とほぼ同じ処理になるので、データアノテーションや IValidatableObject を使った検証まで行ってくれるようです。

public class SampleModel
{
    [Required]
    public string Title { get; set; }

    [Range(0, 1000)]
    public int Price { get; set; }
}

動作を確認するために、上のような簡単なモデルクラスを用意しました。

public ActionResult Confirm()
{
    // わざと検証エラーになるデータを入れる
    var model = new SampleModel
    {
        Price = -1
    };

    // データに不整合が発生していないかチェック
    TryValidateModel(model);

    return View(model);
}

簡単なビューを用意して実行してみたところ、モデルバインダーを使っていないにもかかわらずエラーが表示されることが確認できます。

f:id:shiba-yan:20141121004308p:plain

ちなみに以下のようにネストした場合にはルートのプロパティしか検証してくれないので、実際に使う場合にはちょっと工夫とか注意が必要になるかと思います。

public class SampleModel
{
    [Required]
    public string Title { get; set; }

    [Range(0, 1000)]
    public int Price { get; set; }

    public SampleChildModel ChildModel { get; set; }
}

public class SampleChildModel
{
    [Required]
    public string Name { get; set; }
}

以下のようなアクションに書き換えて実行をしてみると、ChildModel.Name プロパティは必須にもかかわらずエラーとなりません。これはいまいちな挙動です。

public ActionResult Confirm()
{
    // わざと検証エラーになるデータを入れる
    var model = new SampleModel
    {
        Price = -1,
        ChildModel = new SampleChildModel()
    };

    // データに不整合が発生していないかチェック
    TryValidateModel(model);
    TryValidateModel(model.ChildModel);

    return View(model);
}

なので、とりあえずはネストしたモデルに対しても TryValidateModel メソッドを実行することで、意図したとおりの挙動にはなります。拡張メソッドを用意してもいいかもしれません。

f:id:shiba-yan:20141121004941p:plain

DB 登録前には最終的なモデルのチェックをしたかったのに、このメソッドの存在を知らなかった為に各方面に迷惑をかけた気がします。*2

*1:少なくとも MVC 2 の頃にはあったらしい…

*2:主に蠣殻町の某社さん