ASP.NET Web API のパラメーター バインド

ASP.NET Core Web API の使用を検討してください。 ASP.NET 4.x Web API に対して次の利点があります:

  • ASP.NET Core は、Windows、macOS、Linux で最新のクラウド ベースの Web アプリを構築するための、オープン ソースのクロスプラットフォーム フレームワークです。
  • ASP.NET Core MVC コントローラーと Web API コントローラーが統合されています。
  • テストの容易性を考慮したアーキテクチャ。
  • Windows、macOS、Linux 上で開発および実行できること。
  • オープン ソースでコミュニティ重視。
  • 最新のクライアント側フレームワークと開発ワークフローの統合。
  • クラウド対応で環境ベースの構成システム。
  • 組み込まれている依存性の注入。
  • 軽量で高パフォーマンスのモジュール化された HTTP 要求パイプライン。
  • KestrelIISHTTP.sysNginxApacheDocker でホストすることができます。
  • side-by-side でのバージョン管理。
  • 最新の Web 開発を簡単にするツール。

この記事では、Web API でパラメーターをバインドする方法と、バインド プロセスをカスタマイズする方法について説明します。 Web API が、コントローラーでメソッドを呼び出すときには、パラメーターの値を設定する必要があります。これはバインディングと呼ばれるプロセスです。

既定では、Web API は次の規則を使用してパラメーターをバインドします:

  • パラメーターが "単純" 型の場合、Web API は URI から値を取得しようとします。 単純型には、.NET プリミティブ型 (intbool doubleなど) に加えて、TimeSpanDateTimeGuiddecimalstringほか、文字列から変換できる型コンバーターを持つ任意の型が含まれます。 (型コンバーターの詳細については、後で説明します。)
  • 複合型の場合、Web API はメディア型フォーマッタを使用して、メッセージ本文から値を読み取ろうとします。

たとえば、一般的な Web API コントローラー メソッドを次に示します:

HttpResponseMessage Put(int id, Product item) { ... }

id パラメーターは "単純" 型であるため、Web API は要求 URI から値を取得しようとします。 項目 パラメーターは複合型であるため、Web API はメディア型フォーマッタを使用して要求本文から値を読み取ります。

URI から値を取得するために、Web API はルート データと URI クエリ文字列を検索します。 ルート データは、ルーティング システムが URI を解析し、ルートと照合するときに設定されます。 詳細については、「ルーティングとアクションの選択」を参照してください。

この記事の残りの部分では、モデル バインド プロセスをカスタマイズする方法について説明します。 ただし、複合型の場合は、可能な限りメディア型フォーマッタの使用を検討してください。 HTTP の重要な原則は、コンテンツ ネゴシエーションを使用してリソースがメッセージ本文で送信され、リソースの表現を指定することです。 メディア型フォーマッタは、まさにこの目的のために設計されました。

[FromUri] を使用する

Web API で URI から複合型を強制的に読み取るためには、[FromUri] 属性をパラメーターに追加します。 次の例では、URI から GeoPoint を取得するコントローラー メソッドと共に GeoPoint 型を定義します。

public class GeoPoint
{
    public double Latitude { get; set; } 
    public double Longitude { get; set; }
}

public ValuesController : ApiController
{
    public HttpResponseMessage Get([FromUri] GeoPoint location) { ... }
}

クライアントは、クエリ文字列に緯度と経度の値を配置することができ、Web API はそれらを使用して GeoPoint を構築します。 次に例を示します。

http://localhost/api/values/?Latitude=47.678558&Longitude=-122.130989

[FromBody] を使用する

Web API で要求本文から単純型を強制的に読み取るためには、[FromBody] 属性をパラメーターに追加します:

public HttpResponseMessage Post([FromBody] string name) { ... }

この例では、Web API はメディア型フォーマッタを使用して、要求本文から name の値を読み取ります。 クライアント要求の例を次に示します。

POST http://localhost:5076/api/values HTTP/1.1
User-Agent: Fiddler
Host: localhost:5076
Content-Type: application/json
Content-Length: 7

"Alice"

パラメーターに [FromBody] がある場合、Web API は Content-Type ヘッダーを使用してフォーマッタを選択します。 この例では、コンテンツ タイプは "application/json" で、要求本文は生の JSON 文字列 (JSON オブジェクトではありません) です。

メッセージ本文からの読み取りは、最大 1 つのパラメーターで許可されます。 したがって、これは機能しません:

// Caution: Will not work!    
public HttpResponseMessage Post([FromBody] int id, [FromBody] string name) { ... }

この規則の理由は、要求本文が、1 回しか読み取りできないバッファーされていないストリームに格納されている可能性があるためです。

型コンバーター

TypeConverter を作成し、文字列変換を指定することで、Web API でクラスを単純な型として扱うことができます (つまり Web API が URI からバインドしようとします)。

次のコードは、地理的なポイントを表す GeoPoint クラスと、文字列から GeoPoint インスタンスに変換する TypeConverter を示しています。 GeoPoint クラスは、[TypeConverter] 属性で修飾され、型コンバーターを指定します。 (この例は、Mike Stall 氏のブログ投稿 「MVC/WebAPI のアクション シグネチャでカスタム オブジェクトにバインドする方法」に着想を得ました。)

[TypeConverter(typeof(GeoPointConverter))]
public class GeoPoint
{
    public double Latitude { get; set; } 
    public double Longitude { get; set; }

    public static bool TryParse(string s, out GeoPoint result)
    {
        result = null;

        var parts = s.Split(',');
        if (parts.Length != 2)
        {
            return false;
        }

        double latitude, longitude;
        if (double.TryParse(parts[0], out latitude) &&
            double.TryParse(parts[1], out longitude))
        {
            result = new GeoPoint() { Longitude = longitude, Latitude = latitude };
            return true;
        }
        return false;
    }
}

class GeoPointConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        if (sourceType == typeof(string))
        {
            return true;
        }
        return base.CanConvertFrom(context, sourceType);
    }

    public override object ConvertFrom(ITypeDescriptorContext context, 
        CultureInfo culture, object value)
    {
        if (value is string)
        {
            GeoPoint point;
            if (GeoPoint.TryParse((string)value, out point))
            {
                return point;
            }
        }
        return base.ConvertFrom(context, culture, value);
    }
}

Web API は GeoPoint を単純型として扱うようになりました。つまり、URI から GeoPoint パラメーターをバインドしようとします。 パラメーターに [FromUri] を含める必要はありません。

public HttpResponseMessage Get(GeoPoint location) { ... }

クライアントは、次のような URI を使用してメソッドを呼び出すことができます:

http://localhost/api/values/?location=47.678558,-122.130989

モデル バインダー

型コンバーターよりも柔軟なオプションは、カスタム モデル バインダーを作成することです。 モデル バインダーを使用すると、HTTP 要求、アクションの説明、ルート データの生の値などにアクセスできます。

モデル バインダーを作成するには、IModelBinder インターフェイスを実装します。 このインターフェイスは、BindModel という 1 つのメソッドを定義します:

bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext);

GeoPoint オブジェクトのモデル バインダーを次に示します。

public class GeoPointModelBinder : IModelBinder
{
    // List of known locations.
    private static ConcurrentDictionary<string, GeoPoint> _locations
        = new ConcurrentDictionary<string, GeoPoint>(StringComparer.OrdinalIgnoreCase);

    static GeoPointModelBinder()
    {
        _locations["redmond"] = new GeoPoint() { Latitude = 47.67856, Longitude = -122.131 };
        _locations["paris"] = new GeoPoint() { Latitude = 48.856930, Longitude = 2.3412 };
        _locations["tokyo"] = new GeoPoint() { Latitude = 35.683208, Longitude = 139.80894 };
    }

    public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelType != typeof(GeoPoint))
        {
            return false;
        }

        ValueProviderResult val = bindingContext.ValueProvider.GetValue(
            bindingContext.ModelName);
        if (val == null)
        {
            return false;
        }

        string key = val.RawValue as string;
        if (key == null)
        {
            bindingContext.ModelState.AddModelError(
                bindingContext.ModelName, "Wrong value type");
            return false;
        }

        GeoPoint result;
        if (_locations.TryGetValue(key, out result) || GeoPoint.TryParse(key, out result))
        {
            bindingContext.Model = result;
            return true;
        }

        bindingContext.ModelState.AddModelError(
            bindingContext.ModelName, "Cannot convert value to GeoPoint");
        return false;
    }
}

モデル バインダーは、値プロバイダーから生の入力値を取得します。 この設計では、次の 2 つの異なる関数が分離されています:

  • 値プロバイダーは HTTP 要求を受け取り、キーと値のペアのディクショナリを設定します。
  • モデル バインダーは、このディクショナリを使用してモデルを設定します。

Web API の既定値プロバイダーは、ルート データとクエリ文字列から値を取得します。 たとえば、URI が http://localhost/api/values/1?location=48,-122 の場合、値プロバイダーは次のキーと値のペアを作成します:

  • id = "1"
  • location = "48,-122"

(既定のルート テンプレート ("api/{controller}/{id}") を想定しています。)

バインドするパラメーターの名前は、ModelBindingContext.ModelName プロパティに格納されます。 モデル バインダーは、ディクショナリでこの値を持つキーを検索します。 値が存在し、GeoPoint に変換できる場合、モデル バインダーはバインドされた値を ModelBindingContext.Model プロパティに割り当てます。

モデル バインダーは単純型変換に限定されないことに注意してください。 この例では、モデル バインダーは最初に既知の場所のテーブルを検索し、失敗した場合は型変換を使用します。

モデル バインダーを設定する

モデル バインダーを設定する方法はいくつかあります。 まず、パラメーターに [ModelBinder] 属性を追加できます。

public HttpResponseMessage Get([ModelBinder(typeof(GeoPointModelBinder))] GeoPoint location)

[ModelBinder] 属性を型に追加することもできます。 Web API は、その型のすべてのパラメーターに対して、指定されたモデル バインダーを使用します。

[ModelBinder(typeof(GeoPointModelBinder))]
public class GeoPoint
{
    // ....
}

最後に、モデル バインダー プロバイダーを HttpConfiguration に追加することもできます。 モデル バインダー プロバイダーは、単にモデル バインダーを作成するファクトリ クラスです。 ModelBinderProvider クラスから派生することで、プロバイダーを作成できます。 ただし、モデル バインダーが 1 つの型を処理する場合は、この目的のために設計された組み込みの SimpleModelBinderProvider を使用する方が簡単です。 この方法を次のコードに示します。

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        var provider = new SimpleModelBinderProvider(
            typeof(GeoPoint), new GeoPointModelBinder());
        config.Services.Insert(typeof(ModelBinderProvider), 0, provider);

        // ...
    }
}

モデル バインド プロバイダーでは、[ModelBinder] 属性をパラメーターに追加して、メディア型フォーマッタではなくモデル バインダーを使用する必要があることを Web API に伝える必要があります。 ただし、属性でモデル バインダーの種類を指定する必要はありません:

public HttpResponseMessage Get([ModelBinder] GeoPoint location) { ... }

値プロバイダー

モデル バインダーが値プロバイダーから値を取得することをすでに説明しました。 カスタム値プロバイダーを記述するには、IValueProvider インターフェイスを実装します。 要求の Cookie から値をプルする例を次に示します:

public class CookieValueProvider : IValueProvider
{
    private Dictionary<string, string> _values;

    public CookieValueProvider(HttpActionContext actionContext)
    {
        if (actionContext == null)
        {
            throw new ArgumentNullException("actionContext");
        }

        _values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var cookie in actionContext.Request.Headers.GetCookies())
        {
            foreach (CookieState state in cookie.Cookies)
            {
                _values[state.Name] = state.Value;
            }
        }
    }

    public bool ContainsPrefix(string prefix)
    {
        return _values.Keys.Contains(prefix);
    }

    public ValueProviderResult GetValue(string key)
    {
        string value;
        if (_values.TryGetValue(key, out value))
        {
            return new ValueProviderResult(value, value, CultureInfo.InvariantCulture);
        }
        return null;
    }
}

ValueProviderFactory クラスから派生して、値プロバイダー ファクトリを作成する必要もあります。

public class CookieValueProviderFactory : ValueProviderFactory
{
    public override IValueProvider GetValueProvider(HttpActionContext actionContext)
    {
        return new CookieValueProvider(actionContext);
    }
}

次のように、値プロバイダー ファクトリを HttpConfiguration に追加します。

public static void Register(HttpConfiguration config)
{
    config.Services.Add(typeof(ValueProviderFactory), new CookieValueProviderFactory());

    // ...
}

Web API はすべての値プロバイダーを構成するため、モデル バインダーが ValueProvider.GetValue を呼び出すと、モデル バインダーは、それを生成できる最初の値プロバイダーから値を受け取ります。

または、次のように ValueProvider 属性を使用して、パラメーター レベルで値プロバイダー ファクトリを設定できます:

public HttpResponseMessage Get(
    [ValueProvider(typeof(CookieValueProviderFactory))] GeoPoint location)

これにより、指定された値プロバイダー ファクトリでモデル バインドを使用し、他の登録済み値プロバイダーを使用しないように Web API に指示することができます。

HttpParameterBinding

モデル バインダーは、より一般的なメカニズムの特定のインスタンスです。 [ModelBinder] 属性を見ると、抽象 ParameterBindingAttribute クラスから派生していることがわかります。 このクラスは、HttpParameterBinding オブジェクトを返す 1 つのメソッド GetBinding を定義します:

public abstract class ParameterBindingAttribute : Attribute
{
    public abstract HttpParameterBinding GetBinding(HttpParameterDescriptor parameter);
}

HttpParameterBinding は、パラメーターを値にバインドする役割を担います。 [ModelBinder] の場合、この属性は、IModelBinder を使用して実際のバインドを実行する HttpParameterBinding 実装を返します。 独自の HttpParameterBindingを実装することもできます。

たとえば、要求の if-match ヘッダーおよび if-none-match ヘッダーの ETag を取得したいとします。 まず、ETag を表すクラスを定義します。

public class ETag
{
    public string Tag { get; set; }
}

また、if-match ヘッダーまたは if-none-match ヘッダーから ETag を取得するかどうかを示す列挙型も定義します。

public enum ETagMatch
{
    IfMatch,
    IfNoneMatch
}

目的のヘッダーから ETag を取得し、ETag 型のパラメーターにバインドする HttpParameterBinding を次に示します:

public class ETagParameterBinding : HttpParameterBinding
{
    ETagMatch _match;

    public ETagParameterBinding(HttpParameterDescriptor parameter, ETagMatch match) 
        : base(parameter)
    {
        _match = match;
    }

    public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider, 
        HttpActionContext actionContext, CancellationToken cancellationToken)
    {
        EntityTagHeaderValue etagHeader = null;
        switch (_match)
        {
            case ETagMatch.IfNoneMatch:
                etagHeader = actionContext.Request.Headers.IfNoneMatch.FirstOrDefault();
                break;

            case ETagMatch.IfMatch:
                etagHeader = actionContext.Request.Headers.IfMatch.FirstOrDefault();
                break;
        }

        ETag etag = null;
        if (etagHeader != null)
        {
            etag = new ETag { Tag = etagHeader.Tag };
        }
        actionContext.ActionArguments[Descriptor.ParameterName] = etag;

        var tsc = new TaskCompletionSource<object>();
        tsc.SetResult(null);
        return tsc.Task;
    }
}

ExecuteBindingAsync メソッドがバインディングを実行します。 このメソッド内で、バインドされたパラメーター値を HttpActionContextActionArgument ディクショナリに追加します。

Note

ExecuteBindingAsync メソッドが要求メッセージの本文を読み取る場合は、WillReadBody プロパティをオーバーライドして true を返すようにします。 要求本文は、1 回しか読み取れないバッファーなしのストリームである可能性があるため、Web API では、最大 1 つのバインディングでメッセージ本文を読み取ることができるルールが適用されます。

カスタム HttpParameterBinding を適用するには、ParameterBindingAttribute から派生する属性を定義します。 ETagParameterBinding では、if-match ヘッダー用と if-none-match ヘッダー用に 2 つの属性を定義します。 どちらも抽象基底クラスから派生します。

public abstract class ETagMatchAttribute : ParameterBindingAttribute
{
    private ETagMatch _match;

    public ETagMatchAttribute(ETagMatch match)
    {
        _match = match;
    }

    public override HttpParameterBinding GetBinding(HttpParameterDescriptor parameter)
    {
        if (parameter.ParameterType == typeof(ETag))
        {
            return new ETagParameterBinding(parameter, _match);
        }
        return parameter.BindAsError("Wrong parameter type");
    }
}

public class IfMatchAttribute : ETagMatchAttribute
{
    public IfMatchAttribute()
        : base(ETagMatch.IfMatch)
    {
    }
}

public class IfNoneMatchAttribute : ETagMatchAttribute
{
    public IfNoneMatchAttribute()
        : base(ETagMatch.IfNoneMatch)
    {
    }
}

[IfNoneMatch] 属性を使用するコントローラー メソッドを次に示します。

public HttpResponseMessage Get([IfNoneMatch] ETag etag) { ... }

ParameterBindingAttribute 以外にも、カスタム HttpParameterBindingを追加するための別のフックがあります。 HttpConfiguration オブジェクトでは、ParameterBindingRules プロパティは、型 ( HttpParameterDescriptor->HttpParameterBinding) の匿名関数のコレクションです。 たとえば、GET メソッドの任意の ETag パラメーターで ETagParameterBindingif-none-match を使用するというルールを追加できます:

config.ParameterBindingRules.Add(p =>
{
    if (p.ParameterType == typeof(ETag) && 
        p.ActionDescriptor.SupportedHttpMethods.Contains(HttpMethod.Get))
    {
        return new ETagParameterBinding(p, ETagMatch.IfNoneMatch);
    }
    else
    {
        return null;
    }
});

この関数は、バインドが適用されないパラメーターに対して null を返します。

IActionValueBinder

パラメーター バインド プロセス全体は、IActionValueBinder というプラグ可能なサービスによって制御されます。 IActionValueBinder の既定の実装では、次の処理が行われます:

  1. パラメーターで ParameterBindingAttribute を探します。 これには、[FromBody][FromUri][ModelBinder]、またはカスタム属性が含まれます。

  2. または、HttpConfiguration.ParameterBindingRules で null 以外の HttpParameterBinding を返す関数を探します。

  3. または、前に説明した既定の規則を使用します。

    • パラメーター型が "単純" の場合、または型コンバーターがある場合は、URI からバインドします。 これは、パラメーターに [FromUri] 属性を配置することと同じです。
    • または、メッセージ本文からパラメーターを読み取ってみてください。 これは、パラメーターに [FromBody] を配置することと同じです。

必要であれば、IActionValueBinder サービス全体をカスタム実装に置き換えることができます。

その他のリソース

カスタム パラメーター バインドのサンプル

Mike Stall 氏は、Web API パラメーター バインドに関する良質な一連のブログ記事を作成しました: