ASP.NET CoreでREST APIを作る際のJsonResult拡張クラス

# 概要

ASP.NET CoreでREST APIを作るときに使えそうな、JsonResultの拡張クラスを作りました。

ASP.NET CoreデフォルトのJsonResultはそれだけでも便利なのですが、REST APIで使うときには、ちょっと足りないところがあり、結局自分で色々拡張しないといけません。

- オブジェクトを上手い事Json形式にシリアライズしないといけない（時刻表現やエンコードが云々）
- セキュリティ的なレスポンスヘッダーを付けないといけない

# API仕様

- キャッシュはさせない
    - 描画などキャッシュが重要なリッチな画面には使われない想定
- レスポンスのフォーマット
    - エンベロープは利用しない
    - 基本的にエスケープはしない
        - エスケープしてしまうと、クライアント側で戻す必要があるが、クライアントは環境条件が多岐に渡るため、実装コストが高い
        - 必要なJsonコントロール文字（改行とかダブルクォートとか）のみエスケープ
        - "<" ">"などをエスケープしない代わりに、UTF-7攻撃対策として、レスポンスヘッダにcharset=utf-8を明示する。
     - エラー時は RFC7807 に従う https://www.eisbahn.jp/yoichiro/2017/01/rfc_7807.html

# 実装

## 環境

- Visual Studio 2017
- .NET Core 2.1
- ASP.NET Core 2.1.1

## コード

```c#:CustomJsonResult.cs
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using System;
using System.Collections.Generic;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace WebApiAuthSample
{
    /// <summary>
    /// Json形式のレスポンスを返すためのActionResultクラス
    /// </summary>
    public class CustomJsonResult : JsonResult
    {
        /// <summary>
        /// 日付フォーマット。
        /// yyyy-MM-dd'T'HH:mm:ss.ffK(UTCだとZになる)
        /// </summary>
        private const string DateFormat = "yyyy-MM-dd'T'HH:mm:ss.ffK";

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="code">ステータスコード</param>
        /// <param name="data">データ</param>
        public CustomJsonResult(HttpStatusCode code, object data)
            : base(data)
        {
            base.StatusCode = (int)code;
        }

        /// <summary>
        /// MVCのアクションメソッドの結果を処理
        /// </summary>
        /// <param name="context">実行コンテキスト</param>
        /// <inheritdoc />
        public async override Task ExecuteResultAsync(ActionContext context)
        {
            if (context == null)
            {
                throw new UnauthorizedAccessException("The context of this HTTP request is not defined.");
            }

            HttpResponse response = context.HttpContext.Response;
            await SerializeJsonAsync(response);
        }

        /// <summary>
        /// 指定されたデータをJSONにシリアライズしレスポンスに格納
        /// </summary>
        /// <param name="response">格納するレスポンス</param>
        public async Task SerializeJsonAsync(HttpResponse response)
        {
            if (!String.IsNullOrEmpty(ContentType))
            {
                //MIME設定
                response.ContentType = ContentType;
            }
            else
            {
                response.ContentType = "application/json; charset=utf-8";
            }

            // Content Sniffering 対策
            response.Headers.Add("X-Content-Type-Options", "nosniff");

            // キャッシュ回避
            response.Headers.Add("Pragma", "no-cache");
            response.Headers.Add("Cache-Control", "no-store, no-cache");

            // クロスサイトスクリプティング防御機構を有効化
            response.Headers.Add("X-XSS-Protection", "1; mode=block");

            //CORS設定。クロスドメインアクセスが必要なら、適宜設定する。
            if (response.Headers.ContainsKey("Access-Control-Allow-Origin"))
            {
                response.Headers["Access-Control-Allow-Origin"] = "*";
            }
            else
            {
                response.Headers.Add("Access-Control-Allow-Origin", "*");
            }
            //response.Headers.Add("Access-Control-Allow-Credentials", "true");
            //response.Headers.Add("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Date, X-Api-Version, X-File-Name");
            //response.Headers.Add("Access-Control-Allow-Methods", "POST,GET,PUT,PATCH,DELETE,OPTIONS");

            // HTTPS対応
            response.Headers.Add("Strict-Transport-Security", "max-age=15768000");

            response.StatusCode = StatusCode == null ? StatusCodes.Status200OK : StatusCode.Value;
            if (Value != null)
            {
                // Json.NETでシリアライズ
                var converter = new IsoDateTimeConverter();
                converter.DateTimeStyles = System.Globalization.DateTimeStyles.AdjustToUniversal; //時刻はUTCで
                converter.DateTimeFormat = DateFormat;

                // 結果をレスポンスボディに書き込み
                await response.WriteAsync(JsonConvert.SerializeObject(
                    Value, new JsonSerializerSettings()
                    {
                        ContractResolver = new CamelCasePropertyNamesContractResolver(),
                        Converters = new List<JsonConverter>() { converter },
                        Formatting = Formatting.Indented,
                        StringEscapeHandling = StringEscapeHandling.Default,
                    }),
                    Encoding.UTF8 //指定しなくてもデフォルトでUTF8になるが、念のため明記
                );
                return;
            }
            else if (response.StatusCode != StatusCodes.Status204NoContent) //NoContentは結果を書けないので、それ以外の場合だけ空で埋める
            {
                await response.WriteAsync("", Encoding.UTF8);
            }
            return;
        }
    }
}
```

## 使い方

サーバ側の実装

```c#:ValuesController.cs
        public IActionResult Get(int? id)
        {
            if(id == null)
            {
                return new CustomJsonResult(HttpStatusCode.BadRequest, new
                {
                    Type = this.GetType().FullName,
                    Title = "The Access code is expired or invalid.",
                    Instance = Request?.Path.Value
                });
            }

            var result = new
            {
                Id = id,
                Value = id.ToString()
            };

            return new CustomJsonResult(HttpStatusCode.OK, result);
        }
```

実行結果

```posh
PS Z:\> Invoke-RestMethod -Uri "http://localhost:2192/api/values/1" -Method GET | ConvertTo-JSON
{
    "id":  1,
    "value":  "1"
}
```



# 仕様決めの参考にしたサイト

- GitLab https://docs.gitlab.com/ee/api/README.html
- GitHub https://developer.github.com/v3/
- Twitter API https://developer.twitter.com/en/docs/api-reference-index
- Facebook Graph API https://developers.facebook.com/docs/graph-api
- Google Cloud Kubenetes Engine https://cloud.google.com/kubernetes-engine/docs/reference/