(PowerAppsカスタムコネクタ) C#カスタムコードの開発環境を構築する

概要

Power Apps/Automateのカスタムコネクタがアップデートされ、C#のコードでレスポンスを変形したり、処理内容そのものをオーバーライドできるようになりました。

ただし、現状コンパイルエラーのトレースができないためローカルの環境でテストしてから登録する必要があります。 そこで、公式サンプルに従いC#のコードをテストできる環境を作ってみました。
※エンジニアが言うTestではありません。

素人が考えたものなので間違っているかも知れません。

docs.microsoft.com

カスタムコネクタに記述する公式サンプルコードの一例

public override async Task<HttpResponseMessage> ExecuteAsync()
{
    // Create a new response
    var response = new HttpResponseMessage();

    // Set the content
    // Initialize a new JObject and call .ToString() to get the serialized JSON
    response.Content = CreateJsonContent(new JObject
    {
        ["greeting"] = "Hello World!",
    }.ToString());

    return response;
}

準備

まずは、VisualStudioの新規プロジェクトで、空の.NET Coreのコンソールアプリを作成します。

今回は.NET5の機能を試したかったので、バージョンは.NET5 C# 9.0作ってみます。
ただしカスタムコネクタのScriptクラス本体は.NET Core3.1 C#8.0相当のコードでないと動きませんので注意が必要です。
実際に開発される場合は.NET Core3.1相当で作成してください。

構成

  • Program.cs:MainメソッドとScript(カスタムコード)を呼び出すメソッドを記述。
  • Script.cs:カスタムコードの本体部分
  • CodeBase.cs:公式サンプルのScriptBaseやIScriptContextインターフェースを定義

CodeBase.csの作成

適当な場所に新規クラスを作成し、公式のテスト用サンプルから以下をコピーして貼り付けます。
そのままでは動かないので少し修正して使用します。
赤線が出る部分はCtrl + . でusingやパッケージ追加を行います。

    public abstract class ScriptBase {
        // Context object
        public IScriptContext Context { get; init; }

        // CancellationToken for the execution
        public CancellationToken CancellationToken { get; init; }

        // Helper: Creates a StringContent object from the serialized JSON
        public static StringContent CreateJsonContent(string serializedJson) {
            return new StringContent(serializedJson, Encoding.UTF8, "application/json");
        }

        // Abstract method for your code
        public abstract Task<HttpResponseMessage> ExecuteAsync();
    }

    public interface IScriptContext {
        // Correlation Id
        string CorrelationId { get; }

        // Connector Operation Id
        string OperationId { get; }

        // Incoming request
        HttpRequestMessage Request { get; }

        // Logger instance
        ILogger Logger { get; }

        // Used to send an HTTP request
        // Use this method to send requests instead of HttpClient.SendAsync
        Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request,
            CancellationToken cancellationToken);
    }
  • CreateJsonContentの実装がなかったので作成しました。
  • ContexやCancellationTokenはテスト用に任意で書き換えるため init;をつけて初期化できるように。
    ※initは.NET5の機能です。

Program.cs

①静的メンバーとMainメソッド

  • HttpClient とCancellationTokenSource を初期化。
  • メインメソッドをasync Taskに変更して非同期に。
  • あとで作成するScriptTest1を呼び出し。
        static HttpClient _client = new();
        static CancellationTokenSource _cts = new();

        static async Task Main(string[] args) {

            await ScriptTest1();

        }

②ScriptTest1(テスト呼び出し部分)

  • ScriptTest1メソッドを作成。
  • ContextのMockを作成し、任意のrequestMessage やOperationIdを実装します。
  • そしてScriptインスタンスの初期化時にContextとCancellationToken(一応)をセットします。

※ScriptBaseクラスのプロパティにinitアクセサを付けため、オブジェクト初期化子からでも初期化できます。
※new()の記述は.NET5の機能です。

        static async Task ScriptTest1() {

            //Contextの実装
            var moq = new Mock<IScriptContext>();
            moq.Setup(x => x.OperationId).Returns("GetUser");
            moq.Setup(x => x.CorrelationId).Returns("123456");

            //Request
            var json = new JObject {
                ["greeting"] = "Hello World!"
            }.ToString();
            HttpRequestMessage requestMessage = new(HttpMethod.Post, "https://api.contoso.com/Hello") {
                Content = new StringContent(json, Encoding.UTF8, "application/json")
            };
            moq.Setup(x => x.Request).Returns(requestMessage);

            //SendAsync
            moq.Setup(x => x.SendAsync(It.IsAny<HttpRequestMessage>(), It.IsAny<CancellationToken>()))
                .Returns(async (HttpRequestMessage r, CancellationToken c) => await _client.SendAsync(r, c));

            //Scriptの呼び出し
            Script script = new() {
                CancellationToken = _cts.Token,
                Context = moq.Object
            };
            var response = await script.ExecuteAsync();

            Console.WriteLine(await response.Content.ReadAsStringAsync());            
        }

又は、 MockではなくIScriptContextインターフェースを実装したクラスを作ってContextにセットしてもOKです。

    public class MockContext : IScriptContext {
        public static HttpClient _client = new();

        public string CorrelationId => "123456";

        public string OperationId => "GetUser";

        public HttpRequestMessage Request { get;}

        public ILogger Logger => throw new NotImplementedException();

        public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
            return await _client.SendAsync(request, cancellationToken);
        }

        public MockContext() {

            var json = new JObject {
                ["greeting"] = "Hello World!"
            }.ToString();

            Request = new HttpRequestMessage(HttpMethod.Post, "https://api.contoso.com/Hello") {
                Content = new StringContent(json, Encoding.UTF8, "application/json")
            };

        }
    }

Script.cs

  • コード記述の本体となる部分、Script.csを作成。
  • 公式サンプルに従いScriptBase を継承したクラスを作成し、ExecuteAsyncを実装します。
  • こちらはC#8.0のコードで記述します。上記の「initアクセサ」や、「T hoge = new();」みたいなのはC#9.0なのでカスタムコネクタ上では動きません。

ExecuteAsync内がカスタムコネクタで編集するコードです。
今回はMockの内容が反映されているかを確認するためのコードとなっています。

    //この中にテストコード
    public class Script:ScriptBase {

        public override async Task<HttpResponseMessage> ExecuteAsync() {
            // Create a new response
            var response = new HttpResponseMessage();

            var contentAsJson = JObject.Parse(await Context.Request.Content.ReadAsStringAsync().ConfigureAwait(false));
            // Set the content
            // Initialize a new JObject and call .ToString() to get the serialized JSON
            response.Content = CreateJsonContent(new JObject {
                ["greeting"] = (string)contentAsJson["greeting"],
                ["RequestUri"] = Context.Request.RequestUri,
                ["OperationId"] = Context.OperationId
            }.ToString());

            return response;
        }

    }

ローカルでのテスト

テストを実行すると、コンソールにresponseのbodyが表示されます。

カスタムコネクタ上でのテスト

Power Appのカスタムコネクタを作成し、テストコードに合わせてアクションや要求を作成。

最初のAPIのURLもapi.contoso.comなど適当なものでOKです。
※誤って要求されても大丈夫そうなURLで

Script.csに書いたコードをコピーして貼り付けます。

貼り付けたらコネクタの更新をクリックします。
他のタブで情報を更新して「コネクタの更新」をするとコードが反映されず元のURIに要求されてしまうため、最後はコードの画面でコードを編集してから「コネクタの更新」をクリックします。
保存のときに画像のような表示になればOKです。

実行して確認。

テストに成功したら、応答スキーマを登録します。
これをやらないとPower Appsから参照できず、カスタムコネクタの応答がbool値になってしまいます。
応答を編集した後は、再度コードを空編集→「コネクタの更新」をクリックしてコードを更新しますw

分かっている制約

  • コードはすべてScriptクラス内に記述する必要がある。
    • 他のクラスを書いて登録しても、コンパイル前に削除されコンパイルエラーになる。
    • ローカルでは動いたのにカスタムコネクタで動かない原因はこれでした。
    • 同様にstatic classを追加できないため拡張メソッドが使えない。
  • よってメソッドの追加でコードを書いていく必要があり手続き的な処理となる。
  • classを入れ子にしてScriptクラスに貼り付けたらコンパイルも通って動きました。
  • 関数の起動初回はAzure Functionの無料枠と同じく数秒のインスタンス起動時間がかかっている
    • 裏側はFunctionsなのでしょう
    • これにより、カスタムコード本体は、.Net Core3.1相当で作成する必要があります!
    • カスタムコネクタの保存≒コンパイルは通ったから5が使えるものだと思ってました。
  • その他、実行時間、ファイルサイズ、使用できるnamespaceの制約などがあり公式参照。

GitHubリポジトリ

github.com

カスタムコードで遊んでみた例

Qiitaの方に投稿しました。 良かったらLGTM頂けると喜びます。

qiita.com

はてな初投稿でした。

お勉強ノート

ラムダ式でasyncを記述する場合は async () => await DoAsync();

UIスレッド以外から呼ぶTask.ConfigureAwait(false)とは?
非同期メソッドをWaitしてデッドロックが発生するのはUIスレッドから呼んだ場合だけ。
公式ソースではawaitで待機してさらにConfigureAwait(false)のはなぜ?
このあたりが理由なのか?はっきりとは分かりません・・
c# - ConfigureAwait(false) not needed in Console/Win service apps, right? - Stack Overflow

→コードが他のアプリケーションでも利用される可能性があり、どのアプリケーションでも安全に動作させるため

'21/8/20 追記

  • mockにSendAsyncとCancellationTokenの実装を追加
  • 分かっている制約を追加

'21/8/23 修正

  • Program.csのコードを修正。ScriptクラスのMockをオブジェクト初期化子内で設定、
  • Scriptクラスのpartialを不要にした。

'21/8/27

  • カスタムコード本体は.Net Core3.1で作成するよう追記。