💻

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

2023/01/25に公開約6,500字

はじめに

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

https://github.com/Avanade/Beef

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

単純なエンティティの CRUD 操作ではエンティティのすべての列のデータが更新の対象になります。つまり更新する必要がない列においてもデータを送信する必要があるということです。しかし現実的にはデータの作成および操作は一部の列に対してのみ行うことが多いです。例えば、あるデータのステータスを変更するのにそのほかのデータもいちいち送信しなければいけないというのはナンセンスです。そのような事態に対応するために Web API では PATCH 操作ができるようになっています。PATCH 操作では送信したデータのみを更新の対象とします。Beef でも既定で PATCH 操作をサポートしています。

ただし PATCH 操作にも限界があります。実際の業務では特定の列を更新させないようにする必要があります。これはクライアント側で PATCH 操作を行うことでも実現することができますが、悪意のあるユーザーが直接的に API を呼び出してデータを改竄する可能性を排除できません。これに対応するには 2 種類の方法があります。1 つ目はデータを検証 (Validation) しエラーを返す方法です。2 つ目はそもそも読み取り専用の列を受け付けないようにする方法です。今回はこの 2 つ目の方法を Beef で実装してみたいと思います。

サンプル コード

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

実装方法

プロジェクトの作成

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

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

テーブル定義

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

CREATE TABLE [MyApplication].[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: MyApplication
eventOutbox: false
entityScope: Autonomous
tables:
  - name: Product
    efModel: true

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

dotnet run all

entity.beef.yaml

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

entityScope: Autonomous
eventOutbox: None
eventPublish: None
appBasedAgentArgs: true
webApiAutoLocation: true
refDataText: true
databaseSchema: MyApplication
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

ログインするとコメントできます