kintone のアプリをまとめてコピーする

28 min read読了の目安(約25800字

はじめに

こんにちは。

この記事はkintone Advent Calendar 2020の5日目の記事です。

アドベントカレンダー自体が初めて、どころかこうやって技術的な記事を書いて公開するのが初めてです。
所々拙いとは思いますが、暖かい目で見守ってください。よろしくお願いします。

更新情報

  • 2020/12/5: 記事の公開
  • 2020/12/11: ソースコードのリンクを追加、Node.js の動作確認ができたので記事の一部を修正

環境

  • kintone 2020年11月版
  • TypeScript v4.1.2
  • @kintone/rest-api-client v1.7.1

ソースコード

https://github.com/shintaroNagata/copy-app-sample-for-kintone

@kintone/rest-api-client と実行環境

@kintone/rest-api-client(以下、rest-api-client)は、kintone REST API を叩くための JavaScript(及び TypeScript) 向けクライアントライブラリです。
今年2月に v1.0.0 がリリースされ、この記事執筆時点でのバージョンは v1.7.1 です。

基本的には生の REST API を叩くためのクライアントライブラリ[1]なのですが、その特徴として、Node.js とブラウザの両環境に対応している[2]というものがあります。

この特徴により、Node.js で実行するツールで利用することもできるし、あるいは kintone カスタマイズ/プラグインのようなブラウザで実行するコードでも rest-api-client を利用することが出来ます。

認証部分や一部メソッドのパラメータなど所々 Node.js とブラウザ環境で異なる部分はあるのですが、基本的にはあるエンドポイントに向けてリクエストを実行するコードを書く際に、ユーザーはどちらの環境でそのコードが実行されるかを意識する必要がありません。

特に、いくつかの API 呼び出しを組み合わせた汎用メソッドを実装する際にその利点を活かすことが出来ます。
加えて v1.6.0 で各フィールドごとの各種型定義が提供されたことにより、そのような汎用メソッドを書くのがさらに容易になりました。

今回はケーススタディ的に、実際にどのような実装ができるのかをみていこうと思います。

kintone アプリのコピー

題材としては、以下の Developer Network の記事から出発します。

テスト環境で作ったアプリを運用環境にデプロイしてみよう 前編
テスト環境で作ったアプリを運用環境にデプロイしてみよう 後編

5年以上前の、アプリ設定を取得・更新する API が提供され始めた頃に書かれた記事のようですが、題材としてはちょうど良さそうです。
というのも、

  • 記事内では、curl コマンドで REST API を実行する流れになっており、JavaScript のようなプログラムのコードとしては書かれていない
  • 想定ケースが「アプリ1つのみのコピー」で、実際に参考にしたい場合には少し物足りない

といったあたりからの判断です。

後者に関しては、例えばコピーしたいアプリがルックアップ・関連レコード一覧のような「他のアプリを参照している」アプリの場合に、その参照先のアプリをまず先にコピーして、さらにそのアプリが参照しているアプリをコピーして……といった作業が必要になります。

またそのような場合には、フィールド設定をそのままコピーするのではなく、ルックアップの参照先のアプリの設定を書き換えてからの更新が必要になるので、コードにするならこの辺りをうまくやりたいです。

もちろんそのようなケースでは kintone の「アプリテンプレート」を使う手段もあるのですが、

  • REST API で取得できる情報との差分
    • 「アプリテンプレートで書き出せない設定」「REST API で取得できない設定」の両者が存在する
  • 手動実行 vs プログラムでの実行
  • アプリテンプレートの各種制限
    • システム管理権限が必要
    • 紐づくアプリが100を超えるとテンプレートに書き出せない

などの比較ができるので、一概にどちらを選択すればよいという話ではなく、ケースバイケースといった感じだと思われます。

今回の記事では、

  1. 1つのアプリをコピー
  2. ルックアップで参照しているアプリをまとめてコピー
  3. 関連レコードで参照しているアプリをまとめてコピー

のうち、2までをやろうかなと思っています。3は余力があればどこかで別途まとめますが、基本的には2までの部分の応用でできるはずです。

実装

1つのアプリのコピー

アプリ設定の取得

まず前提として問題の簡単化のために、特定のアプリから以下の設定をコピーしたアプリを作成することにします。

  1. アプリの名前
  2. アプリの説明
  3. アプリのフォーム
    • フィールド設定 & レイアウト

とはいえ、他の(REST API で書き出せる)設定に関しては、そこまで労力なく対応できるはずです。[3]
本質的に難しいのはフィールド設定のコピーの部分です。

さてコピーする設定を決めたので、その型定義と、特定のアプリからそれらの情報を取得する関数をまず用意します。

import {
  KintoneFormFieldProperty,
  KintoneFormLayout,
  KintoneRestAPIClient,
} from "@kintone/rest-api-client";

type Properties<
  T extends KintoneFormFieldProperty.OneOf = KintoneFormFieldProperty.OneOf
> = Record<string, T>;
type Layout = KintoneFormLayout.OneOf[];
type AppConfig = {
  name: string;
  description: string;
  properties: Properties;
  layout: Layout;
};

const getAppConfig = async ({
  client,
  appId,
}: {
  client: KintoneRestAPIClient;
  appId: string;
}): Promise<AppConfig> => {
  const { name, description } = await client.app.getAppSettings({ app: appId });
  const { properties } = await client.app.getFormFields({ app: appId });
  const { layout } = await client.app.getFormLayout({ app: appId });
  return { name, description, properties, layout };
};

AppConfigが今回のコピー対象となる設定を表現する型です。

KintoneFormFieldProperty.OneOfKintoneFormLayout.OneOf はそれぞれ rest-api-client が提供する型で、「いずれかのフィールドのフィールド設定」「"ROW","GROUP","SUBTABLE"のいずれかのレイアウト」を表す型です。[4]
逆に特定のフィールドのフィールド設定の型が欲しい場合にはKintoneFormFieldProperty.SingleLineTextのように記述します。
この後のコードでは主にフィールド設定と闘うことになるので、KintoneFormFieldProperty.OneOfは頻繁に出てきます。

Propertiesは例えばGET /k/v1/app/form/fields.jsonを呼び出したときに返ってくるpropertiesパラメータなどが持つ型で、この後出てくる実装の都合上、持ちうるフィールドの情報を型パラメータで差し込めるようにしています。
LayoutGET /k/v1/app/form/layout.jsonなどで返されるlayoutパラメータが持つ型です。こいつはこの後特に操作する機会はないので素朴な型定義になっています。

getAppConfigの実装は至って単純で、それぞれ必要な API を呼び出して、結果をAppConfigに合うようにまとめてから返却しています。

またここで、clientを引数で受け取るようにしています。環境のbaseUrlや認証情報などの環境に紐づく情報は clientに隠蔽されているので、このメソッドの中ではそれらを意識する必要がありません。ただただclientを経由して API を呼び出すだけになっています。
このあとの実装でも基本的にこの形式の I/F を採用します。

アプリの作成

次に、取得したアプリの設定情報を使ってアプリを作成する部分を実装します。
順番としては、

  1. アプリを作成
  2. アプリ説明を反映
  3. フィールドの設定を反映
  4. フィールドのレイアウトを反映
  5. アプリの運用環境への反映

と、まあ多少前後してもいいんですがおおまかにはこうなります。このうち 3 が一番の曲者です。

取得してきたアプリのフィールド設定をそのまま反映できればみんな幸せ大団円なのですが、現実はそうはいきません。
kintone のアプリは、作成した時点で以下のフィールドがすでに存在しています。

  • レコード番号
  • 更新日時
  • 更新者
  • 作成日時
  • 作成者

そしてこれらのフィールドは新たに追加することはできません。
取得してきた設定情報の中には当然コピー元アプリのこれらのフィールド情報が含まれているので、そのまま取得したフィールド設定をすべて追加するとエラーになります。
この現象を回避するために、フィールド設定を反映させる際には少し工夫をする必要があります。

まず、フィールドを3つのカテゴリに分類します。

type BuiltinFieldProperty =
  | KintoneFormFieldProperty.RecordNumber
  | KintoneFormFieldProperty.Modifier
  | KintoneFormFieldProperty.UpdatedTime
  | KintoneFormFieldProperty.Creator
  | KintoneFormFieldProperty.CreatedTime;

type MetaFieldProperty =
  | KintoneFormFieldProperty.Category
  | KintoneFormFieldProperty.Status
  | KintoneFormFieldProperty.StatusAssignee;

type NormalFieldProperty = Exclude<
  KintoneFormFieldProperty.OneOf,
  BuiltinFieldProperty | MetaFieldProperty
>;

BuiltinFieldProperty は先ほど列挙した「アプリ作成時点ですでに存在しているフィールド」を表す型です。
MetaFieldPropertyはカテゴリ・ステータス・作業者の3フィールドを表す型です。取得時には返ってきますが、特に操作することは出来ません。
NormalFieldPropertyはそれら以外の残りのフィールドです。厳密にはLookupFieldPropertyReferenceTableFieldPropertyを他のフィールドと並列に扱うのは怪しいのですが、後々考えるので分類としては一旦何も考えないことにします。

上記の分類に関する Type Guard の実装を与えておきます。

const isBuiltinFieldProperty = (
  fieldProperty: KintoneFormFieldProperty.OneOf
): fieldProperty is BuiltinFieldProperty => {
  const type = fieldProperty.type;
  return (
    type === "RECORD_NUMBER" ||
    type === "MODIFIER" ||
    type === "UPDATED_TIME" ||
    type === "CREATOR" ||
    type === "CREATED_TIME"
  );
};

const isMetaFieldProperty = (
  fieldProperty: KintoneFormFieldProperty.OneOf
): fieldProperty is MetaFieldProperty => {
  const type = fieldProperty.type;
  return type === "CATEGORY" || type === "STATUS" || type === "STATUS_ASSIGNEE";
};

const isNormalFieldProperty = (
  fieldProperty: KintoneFormFieldProperty.OneOf
): fieldProperty is NormalFieldProperty => {
  return (
    !isMetaFieldProperty(fieldProperty) &&
    !isBuiltinFieldProperty(fieldProperty)
  );
};

型の記述をそのまま実装した感じになりましたね。[5]

次に、この分類にしたがってPropertiesの形式のオブジェクトを分類する関数を実装します。

const categorizeFieldProperties = (properties: Properties) => {
  return Object.keys(properties).reduce<{
    normalFieldProperties: Properties<NormalFieldProperty>;
    builtinFieldProperties: Properties<BuiltinFieldProperty>;
    metaFieldProperties: Properties<MetaFieldProperty>;
  }>(
    (acc, fieldCode) => {
      const fieldProperty = properties[fieldCode];
      if (isMetaFieldProperty(fieldProperty)) {
        acc.metaFieldProperties[fieldCode] = fieldProperty;
        return acc;
      }
      if (isBuiltinFieldProperty(fieldProperty)) {
        acc.builtinFieldProperties[fieldCode] = fieldProperty;
        return acc;
      }
      if (isNormalFieldProperty(fieldProperty)) {
        acc.normalFieldProperties[fieldCode] = fieldProperty;
        return acc;
      }
      // exhaustive check
      const _check: never = fieldProperty;
      return _check;
    },
    {
      normalFieldProperties: {},
      builtinFieldProperties: {},
      metaFieldProperties: {},
    }
  );
};

ただただ分類しながら畳み込むだけのシンプルな関数です。
ここで、Properties<NormalFieldProperty>等としている部分が、先ほどPropertiesで型パラメータを差し込めるようにしていた理由です。

さて、では実際にこれらの分類したフィールド設定の反映戦略を考えると

  1. NormalFieldProperty: そのまま追加すれば良い
  2. BuiltinFieldProperty: すでに存在している設定と照らし合わせて更新する必要がある
  3. MetaFieldProperty: 特に何もしなくて良い(捨てる)

となります。
この方針にしたがうと、実際のリクエスト用のパラメータを構築する関数は以下のように書けるはずです。

const buildPropertiesToInitialize = ({
  currentProperties,
  newProperties,
}: {
  currentProperties: Properties;
  newProperties: Properties;
}) => {
  const {
    builtinFieldProperties: currentBuiltinFieldProperties,
  } = categorizeFieldProperties(currentProperties);
  const {
    builtinFieldProperties: newBuiltinFieldProperties,
    // 方針1
    normalFieldProperties: propertiesForAdd,
  } = categorizeFieldProperties(newProperties);

  const propertiesForUpdate = Object.keys(newBuiltinFieldProperties).reduce<
    Properties<BuiltinFieldProperty>
  >((acc, fieldCode) => {
    const newFieldProperty = newBuiltinFieldProperties[fieldCode];
    const currentFieldCode = Object.values(currentBuiltinFieldProperties).find(
      (fieldProperty) => {
        return fieldProperty.type === newFieldProperty.type;
      }
    )?.code;
    if (currentFieldCode) {
      acc[currentFieldCode] = newFieldProperty;
      return acc;
    }
    return acc;
  }, {});
  return { propertiesForAdd, propertiesForUpdate };
};

propertiesForAddが追加するフィールド、propertiesForUpdateが更新するフィールドです。

newBuiltinFieldPropertiescurrentBuiltinFieldProperties を比較して組み合わせるところでは

  1. newBuiltinFieldProperties からフィールドを取り出して(newFieldProperty)、 currentBuiltinFieldProperties の中から同じフィールドタイプを持つフィールドのフィールドコードを取り出す(currentFieldCode
  2. キーがcurrentFieldCode、バリューがnewFieldPropertyになるように値をセットする
  3. newBuiltinFieldProperties の全てのフィールドに関して畳み込む

のようにして、propertiesForUpdateを構築しています。

ここまでできると実際にリクエストを送ることができるので、フィールドの設定部分は以下のように書けます。

const initializeForm = async ({
  client,
  config,
  appId,
  revision,
}: {
  client: KintoneRestAPIClient;
  config: AppConfig;
  appId: string;
  revision?: string;
}) => {
  // 下記1
  const { properties: currentProperties } = await client.app.getFormFields({
    app: appId,
    preview: true,
  });
  // 下記2
  const { propertiesForAdd, propertiesForUpdate } = buildPropertiesToInitialize(
    {
      currentProperties,
      newProperties: config.properties,
    }
  );
  // 下記3、更新
  const { revision: fieldUpdatedRevision } = await client.app.updateFormFields({
    app: appId,
    properties: propertiesForUpdate,
    revision,
  });
  // 下記3、追加
  const { revision: fieldsAddedRevision } = await client.app.addFormFields({
    app: appId,
    properties: propertiesForAdd,
    revision: fieldUpdatedRevision,
  });
  // 下記4
  const { revision: layoutUpdatedRevision } = await client.app.updateFormLayout(
    {
      app: appId,
      layout: config.layout,
      revision: fieldsAddedRevision,
    }
  );

  return { revision: layoutUpdatedRevision };
};

やってることは

  1. 現在のアプリのフィールド設定を取得
  2. 現在のフィールド設定と反映したいフィールド設定を先ほどのbuildPropertiesToInitializeに渡して、パラメータ(propertiesForAdd, propertiesForUpdate)を構築する
  3. それぞれを用いて、フィールドの更新・フィールドの追加を行う
  4. 最後にレイアウトの更新を行う

といった感じです。

あとはこの関数を用いて、一連のアプリ作成を行います。この章の初めに書いた流れと見比べてみてください

const createApp = async ({
  client,
  config,
}: {
  client: KintoneRestAPIClient;
  config: AppConfig;
}) => {
  // 1. アプリを作成
  const { app: appId, revision: createdRevision } = await client.app.addApp({
    name: config.name,
  });

  // 2. アプリ説明を反映
  const {
    revision: settingsUpdatedRevision,
  } = await client.app.updateAppSettings({
    app: appId,
    description: config.description,
    revision: createdRevision,
  });

  // 3. フィールドの設定を反映
  // 4. フィールドのレイアウトを反映
  const { revision: formInitializedRevision } = await initializeForm({
    client,
    config,
    appId,
    revision: settingsUpdatedRevision,
  });

  return { appId, revision: formInitializedRevision };
};

const releaseApp = async (params: {
  client: KintoneRestAPIClient;
  config: AppConfig;
}) => {
  // 上記の 1,2,3,4
  const { appId, revision: createdRevision } = await createApp(params);
  // 5. アプリの運用環境への反映
  await deployAppAndWait({
    client: params.client,
    appId,
    revision: createdRevision,
  });
  return appId;
};

作成手順にしたがって、各メソッドを呼び出しています。
最後のdeployAppAndWaitは以下のような実装です。アプリが運用環境へ反映(or キャンセル、失敗)されるまで待つ処理を実装しています。

const deployAppAndWait = async ({
  client,
  appId,
  revision,
}: {
  client: KintoneRestAPIClient;
  appId: string;
  revision?: string;
}) => {
  client.app.deployApp({
    apps: [{ app: appId, revision }],
  });

  let deployStatus: "PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL" = "PROCESSING";
  while (deployStatus === "PROCESSING") {
    // wait 1 second per loop.
    await new Promise<void>((resolve) => {
      setTimeout(() => {
        resolve();
      }, 1000);
    });
    const { apps } = await client.app.getDeployStatus({
      apps: [appId],
    });
    deployStatus = apps[0].status;
  }
  if (deployStatus !== "SUCCESS") {
    throw new Error(`Release does not succeed: ${deployStatus}`);
  }
};

実際に動かす

実際にアプリのコピーを実行するときには、以下の関数を実行します。

const copySingleApp = async ({
  from,
  to,
}: {
  from: { client: KintoneRestAPIClient; appId: string };
  to: { client: KintoneRestAPIClient };
}) => {
  const fromAppConfig = await getAppConfig({
    client: from.client,
    appId: from.appId,
  });
  return releaseApp({ client: to.client, config: fromAppConfig });
};

fromの情報からコピー元のアプリの設定を取得し、toの情報を使ってアプリを作成 & デプロイして、アプリのコピーを実現しています。

ここまで全て、rest-api-client を用いて、「アプリのコピー」のロジックを記述したので、Node.js/ブラウザのどちらでも実行できるコードになっています。

例えば Node.js、では以下のようなスクリプトを(コンパイルして)必要な環境変数と共に実行すれば、アプリのコピーが実現できます。

const processEnv = (name: string): string => {
  const value = process.env[name];
  if (typeof value !== "string") {
    throw new Error(`${name} does not exist`);
  }
  return value;
};

export const run = () => {
  const fromAppId = process.argv[2];
  const fromBaseUrl = processEnv("FROM_BASE_URL");
  const toBaseUrl = processEnv("TO_BASE_URL");
  const fromUsername = processEnv("FROM_USERNAME");
  const fromPassword = processEnv("FROM_PASSWORD");
  const toUsername = processEnv("TO_USERNAME");
  const toPassword = processEnv("TO_PASSWORD");

  copySingleApp({
    from: {
      appId: fromAppId,
      client: new KintoneRestAPIClient({
        baseUrl: fromBaseUrl,
        auth: { username: fromUsername, password: fromPassword },
      }),
    },
    to: {
      client: new KintoneRestAPIClient({
        baseUrl: toBaseUrl,
        auth: { username: toUsername, password: toPassword },
      }),
    },
  });
};

また、ブラウザで実行されるの kintone カスタマイズのコードとしては、例えば以下のようなコードを(適宜コンパイル & バンドルしてやって)カスタマイズとして適用すれば、レコード一覧画面に現れるボタンを押すたびに対象のアプリのコピーが同環境に作成されます。

kintone.events.on("app.record.index.show", (event: unknown) => {
  const headerSpaceElement = kintone.app.getHeaderSpaceElement();
  if (headerSpaceElement) {
    const button = document.createElement("button");
    button.innerText = "Copy!";
    const client = new KintoneRestAPIClient();
    button.addEventListener(
      "click",
      async () => {
        button.innerText = "...";
        await copySingleApp({
          from: {
            client,
            appId: String(kintone.app.getId()),
          },
          to: { client },
        });
        button.innerText = "Done!";
        button.disabled = true;
      },
      { once: true }
    );
    headerSpaceElement.appendChild(button);
  }
});

ルックアップで参照しているアプリをまとめてコピー

紐づく全てのアプリから設定を取得

さて、ここまでの実装ではルックアップ・関連レコード一覧のことは何も考えてなかったので、それらのフィールドが含まれている場合にはこのままではアプリのコピーが実行出来ません。[6]

ここでは、ルックアップフィールドが含まれているアプリの場合に、そのルックアップの参照から辿れる全てのアプリ(多段階に参照している場合も含む)をまとめてコピーすることを考えます。

ルックアップフィールドの設定には参照先アプリの ID が含まれるので、それをもとに再帰的にアプリの設定を取得すれば良さそうです。
なので流れとしては、

  1. アプリの設定を取得
  2. 含まれてる全てのルックアップフィールドの設定から、そのアプリが参照しているアプリの ID を取得する
  3. 2の ID をもとに、1に戻って繰り返す

とすれば良さそうです。

ここで、ルックアップフィールドを持つアプリを作成する場合には
「ルックアップで参照されているアプリが、参照しているアプリよりも先に作られなければいけない」…★
という基本的な制約があることを考えると、この設定取得の段階で「どの順番でアプリを作るのか」ということが同時にわかっていると良さそうです。

先人の知恵により、★の制約を満たす順番は「トポロジカルオーダー(トポロジカルソート)」という言葉で説明されます。

↓Wikipedia トポロジカルソート より引用

トポロジカルソート(英: topological sort)は、グラフ理論において、有向非巡回グラフ(英: directed acyclic graph, DAG)の各ノードを順序付けして、どのノードもその出力辺の先のノードより前にくるように並べることである。有向非巡回グラフは必ずトポロジカルソートすることができる。

雑に説明するなら、グラフの各ノードに1から順番に番号を振るとして、グラフ内の全ての有向辺について「矢印の根元のノードよりも先のノードの方が必ず大きい番号が振られている」ような番号づけの仕方がトポロジカルオーダーになります。

ルックアップの参照関係を有向辺で表現する(矢印の根元が参照元、先が参照先)と、ルックアップの参照関係で紐づくアプリ群は有向グラフとみなすことが出来ます。
このグラフにおいて、「トポロジカルオーダーの逆順」にアプリを作成していけば★の制約を満たすことが出来そうです。[7]

(厳密には、ルックアップの参照関係に循環が発生している(つまり、グラフが DAG にならない)とトポロジカルオーダーをつけることは出来ないのですが、そのようなケースは例えばアプリテンプレート機能でも書き出せないので、ここでも考えないことにします。)

なので、アプリの設定情報を取得する段階でトポロジカルオーダー(あるいはその逆順)に並べておけば、後のアプリ作成フェーズで考えることを減らすことができます。

さて、実際に実装してみます。

まず、ルックアップフィールドはフィールドタイプからの判定が出来ないので、判定用の関数を Type Guard として実装しておきます。

const isLookupFieldProperty = (
  fieldProperty: KintoneFormFieldProperty.OneOf
): fieldProperty is KintoneFormFieldProperty.Lookup => {
  return "lookup" in fieldProperty;
};

そして、上記の「再帰的なアプリの設定取得」を実行する関数は以下のように書けます。
今回は「トポロジカルオーダーの逆順」を実現するために、Wikipedia に記載されている深さ優先探索によるソートアルゴリズムを参考にしました。

const retrieveAllRelatedApps = async (params: {
  client: KintoneRestAPIClient;
  appId: string;
}) => {
  return retrieveAllRelatedAppsRecursive(params, []);
};

const retrieveAllRelatedAppsRecursive = async (
  params: {
    client: KintoneRestAPIClient;
    appId: string;
  },
  apps: Array<{ appId: string; config: AppConfig }>
): Promise<Array<{ appId: string; config: AppConfig }>> => {
  if (
    apps.some(({ appId }) => {
      return appId === params.appId;
    })
  ) {
    return apps;
  }

  const config = await getAppConfig(params);

  const allRelatedApps = await retrieveAllRelatedAppsFromProperties(
    { client: params.client, properties: config.properties },
    apps
  );
  allRelatedApps.push({ appId: params.appId, config });
  return allRelatedApps;
};

const retrieveAllRelatedAppsFromProperties = (
  params: {
    client: KintoneRestAPIClient;
    properties: Properties;
  },
  apps: Array<{ appId: string; config: AppConfig }>
): Promise<Array<{ appId: string; config: AppConfig }>> => {
  return Object.keys(params.properties).reduce(
    async (acc, fieldCode: string) => {
      const fieldProperty = params.properties[fieldCode];
      if (isLookupFieldProperty(fieldProperty)) {
        const relatedAppId = fieldProperty.lookup.relatedApp.app;
        return retrieveAllRelatedAppsRecursive(
          {
            client: params.client,
            appId: relatedAppId,
          },
          await acc
        );
      }
      if (fieldProperty.type === "SUBTABLE") {
        return retrieveAllRelatedAppsFromProperties(
          { client: params.client, properties: fieldProperty.fields },
          await acc
        );
      }
      return acc;
    },
    Promise.resolve(apps)
  );
};

retrieveAllRelatedAppsRecursiveretrieveAllRelatedAppsFromProperties の相互再帰により実現されています。

retrieveAllRelatedAppsRecursiveでは、アプリの設定情報を取得した後に、取得した設定の中のフィールド設定をもとにretrieveAllRelatedAppsFromPropertiesを呼び出しています。

retrieveAllRelatedAppsFromPropertiesでは、フィールド設定の中からルックアップフィールドを見つけて、その設定から参照先アプリの ID を取得して再度retrieveAllRelatedAppsRecursiveを呼び出します。
またテーブルの中のルックアップフィールドにも対応するため、テーブルフィールドに対してはretrieveAllRelatedAppsFromPropertiesを自己再帰的に呼び出しています。

再帰呼び出しで深さ優先探索を実現していて、retrieveAllRelatedAppsRecursiveの最後にArray.prototype.pushで結果を詰めているので、最終的なretrieveAllRelatedAppsの返り値では、紐づくアプリの設定がトポロジカルオーダーの逆順に並ぶことになります。[8]

紐づくアプリをまとめて作成

retrieveAllRelatedAppsで取得した設定からアプリを作成して、アプリのコピーを実現します。
最終的に作りたいものとしては、以下のような関数です。

const copyMultipleApps = async ({
  from,
  to,
}: {
  from: { client: KintoneRestAPIClient; appId: string };
  to: { client: KintoneRestAPIClient };
}) => {
  const apps = await retrieveAllRelatedApps(from);
  return releaseRelatedApps({ ...to, apps });
};

すでにappsが「トポロジカルオーダーの逆順」に並んでいるので、releaseRelatedAppsは単にappsの先頭から順にアプリを作成していけば良いです。

ただし、アプリの ID がアプリを作成した段階でわかるので、後続のアプリ作成時にはその ID をもとに、ルックアップの参照先を書き換える必要があります。

この処理は以下のように再帰的な関数として実装できます。[9]

const releaseRelatedApps = (params: {
  client: KintoneRestAPIClient;
  apps: Array<{ appId: string; config: AppConfig }>;
}) => {
  return releaseRelatedAppsRecursive(params, [], []);
};

const releaseRelatedAppsRecursive = async (
  {
    client,
    apps,
  }: {
    client: KintoneRestAPIClient;
    apps: Array<{ appId: string; config: AppConfig }>;
  },
  released: string[],
  appIdMap: Array<{ from: string; to: string }>
): Promise<string[]> => {
  if (apps.length === 0) {
    return released;
  }
  const app = apps[0];
  const properties = app.config.properties;
  const newProperties = modifyLookupReferences({ properties, appIdMap });
  const appId = await releaseApp({
    client,
    config: { ...app.config, properties: newProperties },
  });
  return releaseRelatedAppsRecursive(
    { client, apps: apps.slice(1) },
    [...released, appId],
    [...appIdMap, { from: app.appId, to: appId }]
  );
};

再帰の各ステップの最後でappIdMapに作成後のアプリの ID 情報を詰めています。
各アプリの設定情報をreleaseAppに渡す前に、modifyLookupReferencesappIdMap(とproperties)を渡してルックアップの参照先を書き換えます。

あとはmodifyLookupReferencesの実装を与えれば完成です。

const modifyLookupReferences = ({
  properties,
  appIdMap,
}: {
  properties: Properties;
  appIdMap: Array<{ from: string; to: string }>;
}) => {
  // KintoneFormFieldProperty.LookupField requires `lookup.relatedApp.code`,
  // but this can cause unexpected behavior. so, I use `any` keyword in there to avoid it.
  return Object.keys(properties).reduce<Record<string, any>>(
    (acc, fieldCode) => {
      const fieldProperty = properties[fieldCode];
      if (isLookupFieldProperty(fieldProperty)) {
        const relatedAppId = fieldProperty.lookup.relatedApp.app;
        const to = appIdMap.find(({ from }) => from === relatedAppId)?.to;
        if (to) {
          acc[fieldCode] = {
            ...fieldProperty,
            lookup: { ...fieldProperty.lookup, relatedApp: { app: to } },
          };
        }
        return acc;
      }
      if (fieldProperty.type === "SUBTABLE") {
        acc[fieldCode] = {
          ...fieldProperty,
          fields: modifyLookupReferences({
            properties: fieldProperty.fields,
            appIdMap,
          }),
        };
        return acc;
      }
      acc[fieldCode] = fieldProperty;
      return acc;
    },
    {}
  );
};

やってることとしては、propertiesの中からルックアップフィールドを探して、そいつの参照先アプリの ID をappIdMapにしたがって書き換えています。(例によってテーブルフィールドに対しては再帰的に適用)

また途中でanyを使う完全敗北を晒しているのですが、理由としてはコメントに書いた通りで、雑に言うとPropertiesの型が引数として渡すのに適していなく、アプリコードのパラメータを渡さないと型検査に落ちる(とはいえ、アプリコードを渡した場合にはアプリ ID より優先されてしまうので挙動が変わりうる)のでそれを回避しています。

実際に動かす

正直やることはアプリ1つをコピーした時と変わりません。I/Fも完全に揃っているので、copySingleAppcopyMultipleAppsと書き換えるだけです。

Node.js の場合は以下のスクリプトを実行してください。

const processEnv = (name: string): string => {
  const value = process.env[name];
  if (typeof value !== "string") {
    throw new Error(`${name} does not exist`);
  }
  return value;
};

export const run = () => {
  const fromAppId = process.argv[2];
  const fromBaseUrl = processEnv("FROM_BASE_URL");
  const toBaseUrl = processEnv("TO_BASE_URL");
  const fromUsername = processEnv("FROM_USERNAME");
  const fromPassword = processEnv("FROM_PASSWORD");
  const toUsername = processEnv("TO_USERNAME");
  const toPassword = processEnv("TO_PASSWORD");

  copyMultipleApps({
    from: {
      appId: fromAppId,
      client: new KintoneRestAPIClient({
        baseUrl: fromBaseUrl,
        auth: { username: fromUsername, password: fromPassword },
      }),
    },
    to: {
      client: new KintoneRestAPIClient({
        baseUrl: toBaseUrl,
        auth: { username: toUsername, password: toPassword },
      }),
    },
  });
};

ブラウザ用には以下です。

kintone.events.on("app.record.index.show", (event: unknown) => {
  const headerSpaceElement = kintone.app.getHeaderSpaceElement();
  if (headerSpaceElement) {
    const button = document.createElement("button");
    button.innerText = "Copy!";
    const client = new KintoneRestAPIClient();
    button.addEventListener(
      "click",
      async () => {
        button.innerText = "...";
        await copyMultipleApps({
          from: {
            client,
            appId: String(kintone.app.getId()),
          },
          to: { client },
        });
        button.innerText = "Done!";
        button.disabled = true;
      },
      { once: true }
    );
    headerSpaceElement.appendChild(button);
  }
});

まとめ

  1. 1つのアプリをコピー
  2. ルックアップで参照しているアプリをまとめてコピー

について、rest-api-client での実装ができました。(関連レコードは未対応)

rest-api-client の

  • Node.js/ブラウザという環境の差異を吸収してくれている
  • 各フィールドの型情報が提供されている

という機能的特徴をうまく利用して、抽象的な汎用メソッドの実装時には API の組み合わせとデータ処理に集中することができる、という部分がうまく伝わっていれば幸いです。

感想

API のインフラ部分を rest-api-client が吸収してくれていることもあって、抽象的なデータ処理に集中できて結構楽しく実装できました。
ちょっとダラダラと書きすぎたかな……というのは反省です。
rest-api-client は普段から自分でも所々使うのですが、ここまで高級な処理を実際に書いたのは初めてだったので、その体験という点から色々勉強になりました。
3日くらいの突貫工事で作ったので色々微妙な感じもするのですが、まあ書きたいことの中心としては満たせた気もするのでよしとします。
関連レコード一覧に関しては、心に余裕が生まれた時に kintone Advent Calendar 2 の空いているところにでもこっそりと書こうと思います。

P.S. 初めてのアドベントカレンダーが当日の日付変わる直前に仕上がるってどうなの……

脚注
  1. 一部、addAllRecords のような高級な処理を行うメソッドも用意されています。 ↩︎

  2. 参考記事:TypeScript による Isomorphic な API Client 開発 ↩︎

  3. と思いましたが、提供されている型定義がそこまで十分じゃないので、今回みたいな汎用メソッドを作ろうと思うと結構めんどくさいかもしれません。 ↩︎

  4. これら以外の「いずれかのフィールドのレイアウト」を表す型はKintoneFormLayout.Field.OneOfになります。 ↩︎

  5. うまく型の部分と記述を共通化できないか考えたんですが、特にいい案が思いつきませんでした。募集中です。 ↩︎

  6. 厳密には、同じ kintone の環境だとコピーできるにはできるんですが、コピー元のアプリが参照していたアプリをそのまま参照しています。アプリの Shallow Copy とでも呼べばいいんですかね。 ↩︎

  7. 「トポロジカルオーダーの逆順」なので、トポロジカルオーダーで大きい番号を振られているアプリ(アプリAとする)から作成するわけですが、トポロジカルオーダーの性質からこのアプリを参照しているアプリは必ずアプリAより小さい番号がついているため、アプリAより後に作成されます。 ↩︎

  8. 実際にトポロジカルオーダーに並べたい場合には、再帰の各ステップの最後でソート結果配列の先頭にノードを追加するような操作をします。Array.prototype.pushは配列の最後に要素を追加するので、並びが逆順になります。 ↩︎

  9. 畳み込んでるだけなのでArray.prototype.reduceでも良かったかもしれません。 ↩︎