Avanade Beef でエンティティ構成に独自の項目を追加する

# はじめに
[Avanade Beef](https://github.com/Avanade/Beef) (以下、Beef) は ASP.NET Core をベースとする Web API の自動生成ツールです。

Beef についての説明はこのあたりを参照。
- [Web API 開発を加速しよう - Avanade Beef のご紹介](https://speakerdeck.com/karamem0/accelerate-your-web-api-development-introducing-avanade-beef)
- [Avanade Beef のレイヤー構造を理解する](https://zenn.dev/karamem0/articles/2022_06_15_200000)

テンプレートの記載については前記事を参照。
- [Avanade Beef でテンプレートを使用して生成されるコードをカスタマイズする](https://qiita.com/24zaki/items/487323e6a380196ba239)

今回は、Beefでコードを自動生成するための情報として既存の構成に含まれないものが必要なときに、構成やテンプレートをどのように定義するかについて記載します。

# Beef のエンティティ定義

Beef では、エンティティの定義を yaml ファイルで行うことができます。
プロジェクトの作成を行った時点で、作成されるサンプルのエンティティを記載します。

```yaml
entityScope: Autonomous
eventSubjectRoot: The24zaki
eventSubjectFormat: NameOnly
eventActionFormat: PastTense
eventCasing: Lower
eventSourceRoot: The24zaki/Sample
eventSourceKind: Relative
appBasedAgentArgs: true
webApiAutoLocation: true
refDataText: true
entities:
  # The following is an example Entity with CRUD operations defined accessing a SQL Database using Stored Procedures.
- { name: Person, text: Person, collection: true, collectionResult: true, validator: PersonValidator, webApiRoutePrefix: persons, get: true, create: true, update: true, patch: true, delete: true,
    properties: [
      { name: Id, text: '{{Person}} identifier', type: Guid, uniqueKey: true, identifierGenerator: IGuidIdentifierGenerator },
      { name: FirstName },
      { name: LastName },
      { name: Gender, type: ^Gender },
      { name: Birthday, type: DateTime, dateTimeTransform: DateOnly },
      { name: ETag, type: string },
      { name: ChangeLog, type: ChangeLog }
    ],
    operations: [
      { name: GetByArgs, type: GetColl, paging: true,
        parameters: [
          { name: Args, type: PersonArgs, validator: PersonArgsValidator }
        ]
      }
    ]
  }

- { name: PersonArgs, text: '{{Person}} arguments',
    properties: [
      { name: FirstName },
      { name: LastName },
      { name: Genders, type: ^Gender, refDataList: true }
    ]
  }
```

ここで定義した内容を基にテンプレートを適用してコードを生成するため、テンプレートを変更することで生成されるコードを変更することができます。
加えて、Beef では、標準の項目で表しきれない内容についてエンティティ定義に独自の要素を追加することが可能で、追加した要素をテンプレートで処理することでさらに柔軟にコード生成を行うことができます。

以下、その方法について記載します。

## 準備
プロジェクトテンプレートで作成されたエンティティでは、エンティティに対する CRUD 処理を属性によって自動生成します。この状態だと独自の属性を追加しづらいので、自動生成ではなく明示的に CRUD 処理を生成するよう エンティティ定義を変更します。

```diff_yaml
- - { name: Person, text: Person, collection: true, collectionResult: true, validator: PersonValidator, webApiRoutePrefix: persons, get: true, create: true, update: true, patch: true, delete: true,
+ - { name: Person, text: Person, collection: true, collectionResult: true, validator: PersonValidator, webApiRoutePrefix: persons, get: false, create: false, update: false, patch: false, delete: false,
      properties: [
        { name: Id, text: '{{Person}} identifier', type: Guid, uniqueKey: true, identifierGenerator: IGuidIdentifierGenerator },
        { name: FirstName },
        { name: LastName },
        { name: Gender, type: ^Gender },
        { name: Birthday, type: DateTime, dateTimeTransform: DateOnly },
        { name: ETag, type: string },
        { name: ChangeLog, type: ChangeLog }
      ],
      operations: [
+       { name: Get, type: Get, uniqueKey: true, webApiRoute: '{id}' },
+       { name: Create, type: Create, webApiRoute: '' },
+       { name: Update, type: Update, uniqueKey: true, webApiRoute: '{id}' },
+       { name: Patch, type: Patch, uniqueKey: true, webApiRoute: '{id}' },
+       { name: Delete, type: Delete, webApiRoute: '{id}',
+         parameters: [
+           { name: Id, property: Id, text: '{{Person}} identifier', isMandatory: true }
+         ]
+       },
        { name: GetByArgs, type: GetColl, paging: true,
          parameters: [
            { name: Args, type: PersonArgs, validator: PersonArgsValidator }
          ]
        }
      ]
    }
```

## 独自の属性の追加

これは簡単で、yaml ファイルには任意の属性を記載することが可能です。
たとえば、追加のコメントをあらわす additionalComment 属性を操作に追加することとします。

```diff_yaml
-       { name: Get, type: Get, uniqueKey: true, webApiRoute: '{id}', autoImplement: None },
+       { name: Get, type: Get, uniqueKey: true, webApiRoute: '{id}', autoImplement: None, additionalComment: 'Getの追加コメント' },
-       { name: Create, type: Create, webApiRoute: '', autoImplement: None },
+       { name: Create, type: Create, webApiRoute: '', autoImplement: None, additionalComment: 'Createの追加コメント' },
```

前述のとおり、Beef では構成に独自の属性を追加することが可能なので、この時点ではエラーは出ませんが、独自の属性はテンプレートで使用していないため生成されるコードは変わりません。

## 独自の属性を使用するテンプレート

独自の属性は、プログラムで処理するオブジェクトモデルでは ExtraProperties 属性に格納されます。また、この値はテンプレート内で`{{lookup ExtraProperties '(属性名)'}}`で取得できるので、以下のようにテンプレートに追加してコードを生成してみます。

うまくいかない例
```diff_handlebars
          /// <summary>
          /// {{{SummaryText}}}
          /// </summary>
    {{#each PagingLessParameters}}
     {{#ifeq WebApiFrom 'FromEntityProperties'}}
       {{#each RelatedEntity.Properties}}
          /// <param name="{{ArgumentName}}">{{{ParameterSummaryText}}}</param>
       {{/each}}
     {{else}}
          /// <param name="{{ArgumentName}}">{{{SummaryText}}}</param>
     {{/ifeq}}
    {{/each}}
    {{#if HasReturnValue}}
          /// <returns>{{{WebApiReturnText}}}</returns>
    {{/if}}
+         /// <remarks>
+         /// {{lookup ExtraProperties 'additionalComment'}}
+         /// </remarks>

```

すると、`Handlebars template references 'additionalComment' which is undefined.` というエラーになりました。理由は、このテンプレートを additionalComment が定義されていない要素（今回の例では、Get, Create以外）に対して実行したためです。
これを回避する方法はいくつかあります。
1. すべての要素に対して属性を定義する
1. 属性をループで探す
1. カスタム処理を定義し、CustomProperties コレクションに値を格納する
1. ヘルパー関数を定義する

### すべての要素に対して属性を定義する
これは簡単だと思うので特にやり方は記載しません。やりたい内容次第では、この方法で済む場合もあります。

### 属性をループで探す
直接 lookup するのではなく、ExtraProperties の各属性をループで取得します。
```diff_handlebars
+         /// <remarks>
+         /// {{#each ExtraProperties}}{{#ifeq @key 'additionalComment'}}{{@value}}{{/ifeq}}{{/each}}
+         /// </remarks>
```

これで動作します。しかし、この方法だと2つ問題があります。
- 見づらい(特定の項目を取得していることがわかりづらい)
- 項目がない場合の処理を記載することができない

ここまでの方法はテンプレートの記載だけで実現できますが、以降の方法はそれ以外の部分も変更が必要となります。

### カスタム処理を定義し、CustomProperties コレクションに値を格納する

テンプレートに対応するスクリプトファイル(今回は EntityWebApiCoreAgent.yaml)を埋め込みリソースとしてプロジェクトに追加します。
```Templates/EntityWebApiCoreAgent.yaml
configType: Beef.CodeGen.Config.Entity.CodeGenConfig, Beef.CodeGen.Core
inherits: [ 'EntityBusiness.yaml' ]

generators:
- { type: 'Beef.CodeGen.Generators.EntityWebApiControllerCodeGenerator, Beef.CodeGen.Core', template: 'EntityWebApiController_cs', file: '{{Name}}Controller.cs', directory: '{{Root.PathApi}}/Controllers/Generated', EntityScope: 'Common', text: 'EntityWebApiControllerCodeGenerator: Api/Controllers' }
- { type: 'Beef.CodeGen.Generators.EntityWebApiAgentCodeGenerator, Beef.CodeGen.Core', template: 'EntityWebApiAgent_cs', file: '{{Name}}Agent.cs', directory: '{{Root.PathCommon}}/Agents/Generated', EntityScope: 'Common', text: 'EntityWebApiAgentCodeGenerator: Common/Agents' }
- { type: 'Beef.CodeGen.Generators.EntityWebApiAgentArgsCodeGenerator, Beef.CodeGen.Core', template: 'EntityWebApiAgentArgs_cs', file: '{{Root.AppName}}WebApiAgentArgs.cs', directory: '{{Root.PathCommon}}/Agents/Generated', EntityScope: 'Common', text: 'EntityIWebApiAgentArgsCodeGenerator: Common/Agents' }
- { type: 'Beef.CodeGen.Generators.EntityGrpcProtoCodeGenerator, Beef.CodeGen.Core', template: 'Grpc_proto', file: '{{lower Root.PathCommon}}.grpc.proto', directory: '{{Root.PathCommon}}/Grpc/Generated', EntityScope: 'Common', text: 'EntityGrpcProtoCodeGenerator: Common/Grpc' }
- { type: 'Beef.CodeGen.Generators.EntityGrpcProtoCodeGenerator, Beef.CodeGen.Core', template: 'GrpcTransformers_cs', file: 'Transformers.cs', directory: '{{Root.PathCommon}}/Grpc/Generated', EntityScope: 'Common', text: 'EntityGrpcProtoCodeGenerator: Common/Grpc' }
- { type: 'Beef.CodeGen.Generators.EntityGrpcServiceCodeGenerator, Beef.CodeGen.Core', template: 'EntityGrpcService_cs', file: '{{Name}}Service.cs', directory: '{{Root.PathApi}}/Grpc/Generated', EntityScope: 'Common', text: 'EntityGrpcServiceCodeGenerator: Api/Grpc' }
- { type: 'Beef.CodeGen.Generators.EntityGrpcAgentCodeGenerator, Beef.CodeGen.Core', template: 'EntityGrpcAgent_cs', file: '{{Name}}Agent.cs', directory: '{{Root.PathCommon}}/Grpc/Generated', EntityScope: 'Common', text: 'EntityGrpcAgentCodeGenerator: Common/Grpc' }
```

```diff_xml
  <Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
      <OutputType>Exe</OutputType>
      <TargetFramework>net6.0</TargetFramework>
      <Nullable>enable</Nullable>
    </PropertyGroup>
    <ItemGroup>
      <PackageReference Include="Beef.CodeGen.Core" Version="4.2.16" />
    </ItemGroup>
    <ItemGroup>
      <EmbeddedResource Include="./Templates/EntityWebApiController_cs.hbs" />
+     <EmbeddedResource Include="./Scripts/EntityWebApiCoreAgent.yaml" />
    </ItemGroup>
  </Project>
```

スクリプトファイルに editorType 属性を定義し、ConfigEditor を指定します。
```diff_yaml
  configType: Beef.CodeGen.Config.Entity.CodeGenConfig, Beef.CodeGen.Core
  inherits: [ 'EntityBusiness.yaml' ]
+ editorType: The24zaki.Sample.CodeGen.Config.SampleConfigEditor, The24zaki.Sample.CodeGen
  generators:
```

指定した ConfigEditor をプロジェクトに追加し、実装します。
```Config/SampleConfigEditor.cs
using Beef.CodeGen.Config.Entity;
using System.Threading.Tasks;
using OnRamp.Config;
using Newtonsoft.Json.Linq;

namespace The24zaki.Sample.CodeGen.Config
{
    public class SampleConfigEditor: IConfigEditor
    {
        public Task AfterPrepareAsync(IRootConfig config)
        {
            var cgc = (CodeGenConfig)config;
            if (cgc.Entities == null)
                return Task.CompletedTask;

            // Entity → Operation の順にたどる
            foreach (var entity in cgc.Entities)
            {
                if (entity.Operations == null)
                    continue;

                foreach (var operation in entity.Operations)
                {
                    if (operation.TryGetExtraProperty("additionalComment", out JValue val) && val!.Value != null)
                    {
                        operation.CustomProperties["additionalCommentDefined"] = true;
                        operation.CustomProperties["additionalComment"] = val!.Value.ToString()!;
                    }
                    else
                    {
                        operation.CustomProperties["additionalCommentDefined"] = false;
                    }
                }
            }

            return Task.CompletedTask;
        }
    }
}
```

定義した CustomProperty を使用するようテンプレートを実装します。
```diff_handlebars
+         /// <remarks>
+         /// {{#if (lookup CustomProperties 'additionalCommentDefined')}}{{lookup CustomProperties 'additionalComment'}}{{/if}}
+         /// </remarks>
```

この方法だと、前の方法の欠点であった、項目を取得していることがわかりづらい、項目がない場合の処理を記載することができない、という2点は解消されます。ただし、この方法にも1点問題があります。
- 項目ごとに個別に実装が必要となるため、項目を追加した場合の作業が増える

### ヘルパー関数を定義する

Beef が使用している [HandleBars.Net](https://github.com/Handlebars-Net/Handlebars.Net) の[ヘルパー関数](https://github.com/Handlebars-Net/Handlebars.Net#registering-helpers)を使用します。

ヘルパー関数の登録は、テンプレートの適用前にアプリケーション内で1回行えばよいものなので、わかりやすくするためにProgram.csから登録を呼び出すことにします。

```diff_c_sharp
-         public static Task<int> Main(string[] args) -> CodeGenConsole.Create("The24zaki", "Sample").Supports(entity: true, refData: true).RunAsync(args);
+         public static Task<int> Main(string[] args) {
+             HandlebarsFunctions.Register();
+             return CodeGenConsole.Create("The24zaki", "Sample").Supports(entity: true, refData: true).RunAsync(args);
+         }
```

ヘルパー関数を定義します。

```HandlebarsFunctions.cs
using HandlebarsDotNet;
using Newtonsoft.Json.Linq;
using System;

namespace The24zaki.Sample.CodeGen
{
    public static class HandlebarsFunctions
    {
        public static void Register()
        {
            RegisterHasKey();
            RegisterValueOf();
        }

        private static void RegisterHasKey()
        {
            Handlebars.RegisterHelper("hasKey", (context, args) =>
            {
                if (args.Length != 2)
                    throw new System.ArgumentException("hasKey should receive collection and index");

                var index = args[1] as string;
                bool result = false;
                bool ContainsKeyInCollectionOf<T>()
                {
                    var collection = args[0] as System.Collections.Generic.IDictionary<string, T>;
                    if (collection != null && index != null)
                    {
                        result = collection.ContainsKey(index);
                        return true;
                    }
                    return false;
                }

                if (args[0] == null)
                    return false;
                // for ExtraProperties
                if (ContainsKeyInCollectionOf<JToken>())
                    return result;
                // for CustomProperties
                if (ContainsKeyInCollectionOf<object>())
                    return result;
                throw new System.ArgumentException("hasKey should receive IDictionary<string, JToken> or IDictionary<string, object>");
            });
        }

        private static void RegisterValueOf()
        {
            Handlebars.RegisterHelper("valueOf", (context, args) =>
            {
                if (args.Length != 2)
                    throw new System.ArgumentException("valueOf should receive collection and index");

                var index = args[1] as string;
                object? result = null;
                bool GetValueInCollectionOf<T>(Func<T?, object?> converter)
                {
                    var collection = args[0] as System.Collections.Generic.IDictionary<string, T>;
                    if (collection != null && index != null)
                    {
                        T? collectionValue;
                        if (collection.TryGetValue(index, out collectionValue))
                            result = converter(collectionValue);
                        
                        return true;
                    }
                    return false;
                }

                // for ExtraProperties
                if (GetValueInCollectionOf<JToken>(t => t?.Value<string>()))
                    return result;
                // for CustomProperties
                if (GetValueInCollectionOf<object>(t => t))
                    return result;

                throw new System.ArgumentException("valueOf should receive IDictionary<string, JToken> or IDictionary<string, object>");
            });
        }
    }
}
```

定義したヘルパー関数を使用するようテンプレートを実装します。
```diff_handlebars
+         /// <remarks>
+         /// {{#if (hasKey ExtraProperties 'additionalComment')}}{{valueOf ExtraProperties 'additionalComment'}}{{/if}}
+         /// </remarks>
```
この方法だと、前の方法の欠点も解消しています。

# おわりに
今回は、Beef を使用したアプリケーションでエンティティに独自の項目を追加し、それをテンプレートで使用する方法について記載しました。
本稿を参考にすることで、Beef を使用した開発が少しでも楽になれば幸いです。