しばやん雑記

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

Azure Functions v3 で快適に REST API を書くためのライブラリを公開した

前にサンプルコード的に書いた REST API を作りやすくするコードを、ちゃんとライブラリとして切り出して NuGet で公開するようにしました。

使い方とかは前回と変わっていないので、新規追加した点についてだけ書きます。

リポジトリは前回と同じなので、興味がある方は適当に参照してください。サンプルコードも少しだけ用意していますが、恐らくは実際に試した方がわかりやすいと思います。

NuGet パッケージは以下になります。.NET Core 3.1 をターゲットにしているので Azure Functions v3 専用になっています。v2 への対応も出来なくはないですが、v3 を使うのが正解なのでこうしています。

名前空間とパッケージ名が微妙なので変更したい気持ちもありますが、良いのが浮かびませんでした。

プロジェクトの作成とパッケージインストール

使う前の準備としては Azure Functions v3 プロジェクトを作成して、NuGet からパッケージをインストールします。エラーが出る場合は Azure Functions のバージョンを間違っているか、テンプレートが古いです。

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

インストール後は適当に HttpTrigger な Function を作成して、以下のようにクラスを修正します。具体的には static を外して HttpFunctionBase を継承するようにします。

public class Function1 : HttpFunctionBase
{
    public Function1(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }
}

コンストラクタの追加は QuickFix や ReSharper がいい感じにやってくれるはずです。

これで準備が出来たので、実際に Function を定義して実装を書いていくことになります。ユースケース毎で適当に紹介していくことにします。

基本的な ActionResult ヘルパー

テンプレートから HttpTrigger な Function を作成すると以下のようになると思いますが、理解のためにこれを HttpFunctionBase を使って書き換えてみます。

public static class Function1
{
    [FunctionName("Function1")]
    public static async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        log.LogInformation("C# HTTP trigger function processed a request.");

        string name = req.Query["name"];

        string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        dynamic data = JsonConvert.DeserializeObject(requestBody);
        name = name ?? data?.name;

        string responseMessage = string.IsNullOrEmpty(name)
            ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
            : $"Hello, {name}. This HTTP triggered function executed successfully.";

        return new OkObjectResult(responseMessage);
    }
}

実際に書き換えたコードが以下の通りです。何故か毎回書いていた OkObjectResultOk メソッドの呼び出しに変わったり、クエリ文字列には Request.Query でアクセスしています。

あまり関係ないですが、おまけでリクエストボディを Azure Functions のバインディングに任せることで、JSON デシリアライズ周りのコードをごっそりと削除しています。

public class Function1 : HttpFunctionBase
{
    public Function1(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName("Function1")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] dynamic data,
        ILogger log)
    {
        log.LogInformation("C# HTTP trigger function processed a request.");

        string name = Request.Query["name"];

        name = name ?? data?.name;

        string responseMessage = string.IsNullOrEmpty(name)
            ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
            : $"Hello, {name}. This HTTP triggered function executed successfully.";

        return Ok(responseMessage);
    }
}

HttpFunctionBase は ASP.NET Core MVC の ControllerBase と同じようなプロパティやメソッドを提供するようにしているので、ASP.NET Core MVC で Web API を書いた人なら取っ付きやすいと思います。

一部しか使わなかったですが、IActionResult のヘルパーはリダイレクト以外は大体用意しています。

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

Web API でリダイレクトが必要な場面が正直思い浮かばなかったのと、非同期 HTTP API 周りのパターンは CreatedAtFunctionAcceptedAtFunction といったメソッドで対応可能なのが理由です。

バリデーションの追加と結果の返却

リクエストのバリデーションは絶対に必要なのにシンプルに実装する方法がなくて地味に辛かったですが、TryValidateModel を使うことで Core MVC と同じような動作が行えます。

ここまでは前のバージョンと同じですが、クライアントに対してバリデーション結果を RFC 7807 に沿った形式で返せるようになりました。このあたりも Core MVC と同じです。

public class Function2 : HttpFunctionBase
{
    public Function2(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName("Function2")]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] SampleRequest model,
        ILogger log)
    {
        if (!TryValidateModel(model))
        {
            return ValidationProblem();
        }

        return Ok(model);
    }
}

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

    [Required]
    [Range(0, 100)]
    public int? Age { get; set; }
}

ValidationProblem のパラメータを全て省略すると、デフォルトの ModelState を使うようになっているので簡単です。バリデーション結果をサクッと返す場合に使えます。

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

適当に空っぽのデータを投げた時に返ってきたバリデーション結果が上になります。あまり使われていない気がしますが、標準的なフォーマットに乗っかっておくと後が楽でしょう。

Function URL の柔軟な生成

これも地味に使い勝手が悪かった URL の生成周りですが、Azure Functions の実装を調べると Function 名でルートが追加される仕組みになっていたので、それを利用すると簡単に URL を生成出来ます。

API という観点では 201 Created や 202 Accepted で使われることが多いと思うので、専用の CreatedAtFunctionAcceptedAtFunction メソッドを用意しています。

public class HttpApi : HttpFunctionBase
{
    public HttpApi(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName(nameof(Function3))]
    public IActionResult Function3(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
        ILogger log)
    {
        return CreatedAtFunction(nameof(Function4));
    }


    [FunctionName(nameof(Function4))]
    public IActionResult Function4(
        [HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req,
        ILogger log)
    {
        return Ok();
    }
}

非同期 HTTP API ではよくある、クライアントには素早く 201 Created を返しつつ Location ではステータス確認用の URL を返すパターンが一瞬で用意できました。

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

認証に関しては別の話なのと Function Key をクエリ文字列に含める方法はいまいちだと思っているので、基本は Easy Auth を使った Bearer Token 認証か Function Key を HTTP ヘッダーに含める方法が良いです。

最後に HttpTriggerRoute も指定した時の例を紹介します。Route にはパラメータを定義することが出来ますが、以下のようなコードでその辺りもいい感じに解決できます。

public class HttpApi : HttpFunctionBase
{
    public HttpApi(IHttpContextAccessor httpContextAccessor)
        : base(httpContextAccessor)
    {
    }

    [FunctionName(nameof(Function3))]
    public IActionResult Function3(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "start")] HttpRequest req,
        ILogger log)
    {
        return CreatedAtFunction(nameof(Function4), new { id = Guid.NewGuid().ToString() }, null);
    }


    [FunctionName(nameof(Function4))]
    public IActionResult Function4(
        [HttpTrigger(AuthorizationLevel.Function, "get", Route = "status/{id}")] HttpRequest req,
        string id,
        ILogger log)
    {
        return Ok();
    }
}

単純に CreatedAtFunction のオーバーロードで routeValues を指定するだけなので簡単です。

実行してみると、ちゃんと指定したパラメータを使って URL が生成されています。

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

一応は HttpFunctionBase には動作する形で IUrlHelper を用意しているので、Function 名 == ルート名ということを知っていれば、自由に URL を生成できます。

暇なときにでも拡張メソッドを足してみるかという気持ちです。必要なものは継続的に足していきます。