📜

Avanade Beef で列を読み取り専用にする

に公開

はじめに

Avanade Beef (以下、Beef) は ASP.NET Core をベースとした Web API の自動生成ツールです。

https://github.com/Avanade/Beef

概要については以下のスライドもご覧ください。

単純なエンティティの CRUD 操作では、エンティティのすべての列のデータが更新対象になります。つまり、更新する必要がない列についてもデータを送信する必要があります。しかし、現実的にはデータの作成や操作は一部の列に対してのみ行うことが多いです。たとえば、あるデータのステータスを変更する際に、その他のデータも毎回送信しなければならないのは非効率です。そのような場合に対応するため、Web API では PATCH 操作が利用できます。PATCH 操作では送信したデータのみを更新対象とします。Beef でも既定で PATCH 操作をサポートしています。

ただし、PATCH 操作にも限界があります。実際の業務では特定の列を更新させないようにする必要があります。これはクライアント側で PATCH 操作をすることで実現できますが、悪意のあるユーザーが直接 API を呼び出してデータを改ざんする可能性を排除できません。これに対応する方法は 2 種類あります。

  • データを検証 (Validation) し、エラーを返す方法
  • そもそも読み取り専用の列を受け付けないようにする方法

今回は 2 つ目の方法を Beef で実装する方法を紹介します。

サンプルコード

https://github.com/karamem0/samples/tree/main/avanade-beef-readonly-column

実行手順

プロジェクトの作成

今回はデータソースを EntityFramework としてプロジェクトを作成します。

dotnet new beef --company Karamem0 --appname SampleApplication --datasource EntityFramework

テーブル定義

Product テーブルを作成します。

CREATE TABLE [SampleApplication].[Product] (
  [ProductId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY,
  [ProductName] NVARCHAR (255) NOT NULL,
  [Price] DECIMAL (15, 0) NOT NULL,
  [CreatedBy] NVARCHAR(250) NULL,
  [CreatedDate] DATETIME2 NULL,
  [UpdatedBy] NVARCHAR(250) NULL,
  [UpdatedDate] DATETIME2 NULL
);

コードの修正

database.beef.yaml

Entity Framework のモデルを定義します。

schema: SampleApplication
eventOutbox: false
entityScope: Autonomous
tables:
  - name: Product
    efModel: true

コードを自動生成します。

dotnet run all

entity.beef.yaml

エンティティを定義します。今回は ProductName を読み取り専用とし、更新は Price のみできるようにします。ProductPrice という Price のみを定義するエンティティを作成し、UpdatePrice では ProductPrice をパラメーターとして受け取るようにします。UpdatePrice は managerCustom: true とすることでドメイン ロジック層のカスタマイズを有効にします。ここでは ProductPrice を Price に変換した上でサービス インターフェイス層を呼び出すように変更します。

entityScope: Autonomous
eventOutbox: None
eventPublish: None
appBasedAgentArgs: true
webApiAutoLocation: true
refDataText: true
databaseSchema: SampleApplication
entities:
  - name: Product
    webApiRoutePrefix: products
    get: true
    getAll: true
    create: true
    delete: true
    collection: true
    collectionResult: true
    validator: ProductValidator
    managerCtorParams:
      - AutoMapper.IMapper^mapper
    entityFrameworkModel: EfModel.Product
    autoImplement: EntityFramework
    properties:
      - name: ProductId
        type: Guid
        uniqueKey: true
        identifierGenerator: IGuidIdentifierGenerator
      - name: ProductName
        type: string
      - name: Price
        type: decimal
      - name: ChangeLog
        type: ChangeLog
    operations:
      - name: Update
        type: Update
        uniqueKey: true
        excludeWebApi: true
        excludeWebApiAgent: true
        excludeIManager: true
        excludeManager: true
      - name: UpdatePrice
        type: Update
        uniqueKey: true
        valueType: ProductPrice
        validator: ProductPriceValidator
        webApiRoute: '{productId}/price'
        managerCustom: true
        excludeIDataSvc: true
        excludeData: true
        excludeIData: true
        excludeDataSvc: true
  - name: ProductPrice
    properties:
      - name: ProductId
        type: Guid
        uniqueKey: true
      - name: Price
        type: decimal

コードを自動生成します。

dotnet run all

Business/ProductManager.cs

managerCustom: true としたので、自動生成される ProductManager.cs の実装は以下のようになります。

/// <summary>
/// Updates an existing <see cref="ProductPrice"/>.
/// </summary>
/// <param name="value">The <see cref="ProductPrice"/>.</param>
/// <param name="productId">The Product Id.</param>
/// <returns>The updated <see cref="Product"/>.</returns>
public Task<Product> UpdatePriceAsync(ProductPrice value, Guid productId) => ManagerInvoker.Current.InvokeAsync(this, async () =>
{
    await value.Validate().Mandatory().RunAsync(throwOnError: true).ConfigureAwait(false);

    value.ProductId = productId;
    return Cleaner.Clean(await UpdatePriceOnImplementationAsync(value, productId).ConfigureAwait(false));
}, BusinessInvokerArgs.Update);

ここで呼び出される UpdatePriceOnImplementationAsync メソッドを実装します。ProductManager.cs をパーシャル クラスとして追加します。Generated フォルダーにある ProductManager.cs は直接修正しないでください。

/// <summary>
/// Updates an existing <see cref="ProductPrice"/>.
/// </summary>
/// <param name="value">The <see cref="ProductPrice"/>.</param>
/// <param name="productId">The Product Id.</param>
/// <returns>The updated <see cref="Product"/>.</returns>
public async Task<Product> UpdatePriceOnImplementationAsync(ProductPrice value, Guid productId)
{
    Cleaner.CleanUp(productId);
    await value.Validate().Mandatory().RunAsync(throwOnError: true).ConfigureAwait(false);
    var data = Cleaner.Clean(await _dataService.GetAsync(productId).ConfigureAwait(false));
    var result = Cleaner.Clean(await _dataService.UpdateAsync(_mapper.Map(value, data)).ConfigureAwait(false));
    return result;
}

ここで実施している内容は次の通りです。

  • 現在のデータを GetAsync メソッドで取得
  • AutoMapper で現在のデータにパラメーターの ProductPrice を反映
  • 反映したデータを UpdateAsync で更新

Data/AutoMapperProfile.cs

AutoMapper の定義を追加します。AddAutoMapper でアセンブリを指定しているため、AutoMapper の定義は Profile クラスを継承していればどこに記述しても問題ありません。

public class AutoMapperProfile : Profile
{
    /// <summary>
    /// Gets the <i>Beef</i> <see cref="AutoMapperProfile"/> <see cref="System.Reflection.Assembly"/>.
    /// </summary>
    public static Assembly Assembly => typeof(AutoMapperProfile).Assembly;

    /// <summary>
    /// Initializes a new instance of the <see cref="AutoMapperProfile"/> class.
    /// </summary>
    public AutoMapperProfile()
    {
        CreateMap<ProductPrice, Product>();
    }
}

実行結果

プロジェクトをデバッグ実行します。ブラウザーで http://localhost:5000/swagger を起動します。まずは /products に POST リクエストを実行してデータを作成します。

作成されたことを確認します。

次に /products/price を実行します。

Price が更新されていることを確認します。

ちなみに Name を指定しても更新されません。

おわりに

エンドポイントをわけることで、IAuthorizationFilter インターフェースを利用し、特定のユーザーだけが操作できるように制御できます。要件や業務フローに応じて柔軟に設計し、セキュリティや運用性を高めることが重要です。

Discussion