🧑‍🦲

TypeScript Compiler APIでKintoneの型情報を自動生成するCLIツールを作った

2022/01/09に公開2

この度kintone-form-model-generatorというCLIツールをnpmで公開したのでそのご紹介と、作って学んだことを書きます

https://www.npmjs.com/package/kintone-form-model-generator

なぜ作ったか

副業でKintoneをかれこれ3年くらい使っています
ビジネスに合わせて変化するユーザのバックオフィス業務をすばやく構築することができるだけでなく、
Webhookがあったりカスタムプラグインが作れたりなど、標準機能で実現できない要件を自由に作り込むことも可能で、とても気に入っています

https://kintone.cybozu.co.jp/

カスタマイズの一環として、Kintoneは公式にREST API を公開しており、またJS・TS向けには@kintone/rest-api-client という専用のクライアントが提供されているのですが、
こいつは getRecord addRecord といった各エンドポイントのインターフェースについては規定してくれるものの、
各ユーザーで構築しているアプリのスキーマ情報については特に定義してくれたり出力してくれるものではありません
公式でもIssueであがっているようですが、現時点で具体的な機能までにはなっていない模様です

https://github.com/kintone/js-sdk/issues/445

このため、今までは手で定義を書いたり、型ガードを使ったりなどできるだけ安全に実装できるよう工夫していたのですが、
同僚の@yuichkunが書いたTypeScript の型データから自動で factory を作るという記事や、Slackで Compiler API を使ってあれこれ試しているのをみて、
これを使えばKintoneの型定義をいい感じに自動生成できるのでは...? と思い至りました

ので、やってみました 🐞

作ったもの

kintone-form-model-generator という名前でnpmに公開しています

https://www.npmjs.com/package/kintone-form-model-generator

コードはこちらです 🐍

https://github.com/yktakaha4/kintone-form-model-generator

使い方としては、環境変数に認証情報を入れて generate サブコマンドを実行するだけです
成功すると、 out/ ディレクトリ配下に全アプリケーションの読み込み・登録用のモデルが生成されます
-a オプションで対象のアプリケーションを限定することもできます

# v12以上の環境が必要です
$ node -v
v12.20.0

# 以下の環境変数を指定
$ printenv | grep KINTONE
KINTONE_PASSWORD=blabla
KINTONE_USERNAME=hogehoge
KINTONE_BASE_URL=https://piyopiyo.cybozu.com

# 実行
$ npx kintone-form-model-generator generate -a=54
Need to install the following packages:
  kintone-form-model-generator
Ok to proceed? (y) yes
fetch Kintone app info...
target app count: 1
generating models...
app: id=54, name=入力項目テストアプリ, code=-
file: out/index.ts
done.

出力されたファイルの中身はこんな感じです

/* tslint:disable */
/* eslint-disable */
/**
 * This file is auto generated by kintone-form-model-generator version 1.0.9.
 * Don`t edit this file manually.
 * @see https://github.com/yktakaha4/kintone-form-model-generator
 */
import { Record } from "@kintone/rest-api-client/lib/client/types";
import { Calc, GroupSelect, ID, Revision } from "@kintone/rest-api-client/lib/KintoneFields/types/field";
/**
 * KintoneApp54Record
 * 入力項目テストアプリ
 * id: 54
 * revision: 4
 * @see https://xxxxx.cybozu.com/k/54/
 */
export interface KintoneApp54Record extends Record {
  /**
   * ID
   * @type ID
   */
  $id: ID;
  /**
   * Revision
   * @type Revision
   */
  $revision: Revision;
  /**
   * カテゴリー
   * CATEGORY
   * @type Category
   */
  カテゴリー: Category;
  /**
   * グループ選択
   * GROUP_SELECT
   * @type GroupSelect
   */
  グループ選択: GroupSelect;

  // とてつもなく長いので略
}

生成された型定義は自分のリポジトリに取り込んで使ってください
図は実際に補完が効いているところです🌏
ここは咽び泣くところです

その他の使い方は README をご覧ください

やったこと

設計

基本的な方針としては、Kintoneから公開されているフォーム設計情報取得 APIから以下のようなレスポンスが取れるので、これを走査しCompiler APIで型定義を組み上げていくことになります

https://developer.cybozu.io/hc/ja/articles/201941834

{
  "revision": "4",
  "properties": {
    "レコード番号": {
      "type": "RECORD_NUMBER",
      "code": "レコード番号",
      "label": "レコード番号",
      "noLabel": false
    },
    "リッチエディター": {
      "type": "RICH_TEXT",
      "code": "リッチエディター",
      "label": "リッチエディター",
      "noLabel": false,
      "required": false,
      "defaultValue": ""
    },
    // 略

スキーマ情報は生成されないものの、KintoneのTSのクライアントはそれぞれのフィールドの型情報を定義してくれているので、
これらを適宜参照しつつ、適合したinterfaceを作っていく…という感じになります

Compiler API すごい。ただしなにも分からん

としれっと書きましたが、Compiler APIが自分にとってはむずすぎて開発はだいぶ大変でした
大変さのひとつは、Compiler APIそのものの複雑さによるもので、typescript.factory から生える無数の createほげほげ メソッドの中から、
自分が作りたい型定義に合致したものを見つけ出し適切に構成する必要があります

イメージをわかりやすくするために自身で実装時に書いたテストから引用します

例えば、以下のような定義を生成したい場合、

import {
  ID,
  Subtable,
  SingleLineText,
} from "@kintone/rest-api-client/lib/KintoneFields/types/field";
export interface InterfaceName {
  idField: ID;
  subTableField: Subtable<{
    singleLineText: SingleLineText;
  }>;
  objectLiteralField: {
    objectInnerField: string;
  };
}

必要な実装はこちらになります 🤢

import ts, { factory as f } from "typescript";

const fieldImportStringLiteral = f.createStringLiteral(
  "@kintone/rest-api-client/lib/KintoneFields/types/field"
);
const idTypeIdentifier = f.createIdentifier("ID");
const interfaceNameIdentifier = f.createIdentifier("InterfaceName");
const idFieldPropertyNameIdentifier = f.createStringLiteral("idField");
const subtableIdentifier = f.createIdentifier("Subtable");
const subtableFieldPropertyNameIdentifier =
  f.createStringLiteral("subTableField");

const singleLineTextFieldIdentifier = f.createIdentifier("SingleLineText");
const singleLineTextFieldPropertyNameIdentifier =
  f.createStringLiteral("singleLineText");

const objectLiteralFieldPropertyNameIdentifier =
  f.createStringLiteral("objectLiteralField");
const objectInnerFieldPropertyNameIdentifier =
  f.createStringLiteral("objectInnerField");
const objectInterFieldIdentifier = f.createIdentifier("string");

const nodes = [
  f.createImportDeclaration(
    undefined,
    undefined,
    f.createImportClause(
      false,
      undefined,
      f.createNamedImports([
        f.createImportSpecifier(false, undefined, idTypeIdentifier),
        f.createImportSpecifier(false, undefined, subtableIdentifier),
        f.createImportSpecifier(
          false,
          undefined,
          singleLineTextFieldIdentifier
        ),
      ])
    ),
    fieldImportStringLiteral
  ),
  f.createInterfaceDeclaration(
    undefined,
    [f.createToken(ts.SyntaxKind.ExportKeyword)],
    interfaceNameIdentifier,
    undefined,
    undefined,
    [
      f.createPropertySignature(
        undefined,
        idFieldPropertyNameIdentifier,
        undefined,
        f.createTypeReferenceNode(idTypeIdentifier)
      ),
      f.createPropertySignature(
        undefined,
        subtableFieldPropertyNameIdentifier,
        undefined,
        f.createTypeReferenceNode(subtableIdentifier, [
          f.createTypeLiteralNode([
            f.createPropertySignature(
              undefined,
              singleLineTextFieldPropertyNameIdentifier,
              undefined,
              f.createTypeReferenceNode(singleLineTextFieldIdentifier)
            ),
          ]),
        ])
      ),
      f.createPropertySignature(
        undefined,
        objectLiteralFieldPropertyNameIdentifier,
        undefined,
        f.createTypeLiteralNode([
          f.createPropertySignature(
            undefined,
            objectInnerFieldPropertyNameIdentifier,
            undefined,
            f.createTypeReferenceNode(objectInterFieldIdentifier)
          ),
        ])
      ),
    ]
  ),
];

自分の書き方がなにか間違っている可能性も高いですが、
メソッド名が長かったり引数の渡し方がオブジェクトでなく位置引数で undefined が何を未定義にしているか分かりづらかったりなど、
各メソッドの役割が頭に入ってくると慣れてくるものの、特に最初は心が折れそうになりました

また、とにかくまず完成させて生成した型を実際の開発に使いたかったので、
型の組み上げをおこなっている generate.ts は700行ほどの大作になってしまっています 💩

https://github.com/yktakaha4/kintone-form-model-generator/blob/main/src/generate.ts

これについては、テストを色々と書いてデグレードを起こさないように気をつけました
client.ts はKintone APIアクセスのためカバレッジが低いですが、
それ以外のコードについてはAPIレスポンスをモックして頻繁にテストできるようにしたり、スナップショットテスト(Jestの機能は使ってませんが)を作ったりなど工夫しています

https://www.mizdra.net/entry/2021/02/04/003728

開発中は特に意識していなかったのですが、ここに貼るためにカバレッジを取ってみたら90%ほどあったので、思ったよりテストが書けててそれはよかったです🦛

--------------|---------|----------|---------|---------|---------------------
File          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------|---------|----------|---------|---------|---------------------
All files     |   90.49 |    81.65 |   84.61 |   89.68 |
 src          |   96.29 |       85 |     100 |   96.15 |
  generate.ts |   95.76 |    82.35 |     100 |   95.65 | 357,421-426,529,554
  main.ts     |     100 |      100 |     100 |     100 |
 src/util     |   83.17 |    77.55 |   73.33 |   80.64 |
  args.ts     |     100 |      100 |     100 |     100 |
  ast.ts      |     100 |      100 |     100 |     100 |
  client.ts   |   58.53 |    63.63 |      20 |    57.5 | 23,29-62,79
  config.ts   |     100 |    93.75 |     100 |     100 | 21
  out.ts      |   92.85 |    71.42 |     100 |    92.3 | 18
--------------|---------|----------|---------|---------|---------------------

Test Suites: 7 passed, 7 total
Tests:       27 passed, 27 total
Snapshots:   0 total
Time:        4.196 s
Ran all test suites.
Done in 4.75s.

もう一点難しかったのは、Kintone APIそのものが期待するインターフェースがそこそこ複雑だったことでした

公式リファレンスから引用すると、例えば、テーブル型式のフィールドデータ登録には、REST APIの場合以下のようなJSONを送る必要があります
さっきの例であのコード量でしたので、これを出力するためにはやはり謎の createほにゃほにゃ 関数たちとの格闘が避けられません👹

{
  "<フィールドコード>": {
    "value": [
      {
        "id": "48291",
        "value": {
          "文字列__1行__0": {
            "value": "サンプル0"
          },
          "数値_0": {
            "value": "0"
          }
        }
      },
      {
        "value": {
          "文字列__1行__0": {
            "value": "サンプル1"
          },
          "数値_0": {
            "value": "1"
          }
        }
      }
    ]
  }
}

加えてKintoneの仕様として登録レコードと取得レコードのレイアウトが異なることから、同じフィールド型式に対してざっくり2種類の型定義を用意するイメージになります
また、フィールド型式も豊富でたった今数えたら30ほどありましたので、重複しているものを加味しても10数パターンの型をCompiler APIで定義するハンズオンを体験できます⚰️

ただ、終わってみると色々なCompiler APIの書き方に親しむことができたのと、そもそもASTを扱うプログラムを書いたことがなかったので素振りという意味ではとてもためになって良かったなと思います(いい話)

Compiler APIについてはこちらの記事が参考になりました

https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API

https://zenn.dev/panda_program/articles/typescript-compiler-api

https://shadeglare.medium.com/typescript-code-generation-using-its-compiler-api-4c50ad9f7884

TS での CLI ツールのつくりかた

Compilerの思い出話が長かったので、あとはサッといきますが、
CLIツールをTypeScriptで書くのがはじめてだったのでハマる部分も多いかと思いましたが、
process.argv などCLIに関連する部分への依存をちゃんと分離しておけば普通のWebアプリと同じ書き方で作っていけるな…という印象でした

コマンドライン引数の解釈については利用経験があったyargsを使いました

記事としては、こちらがとても参考になりました

https://qiita.com/suzuki_sh/items/f3349efbfe1bdfc0c634

https://blog.shibayu36.org/entry/2020/08/05/183000

npm の公開

npmの公開やバージョンのインクリメントについては、GitHub Actions上で行うようにしました🐙
こちらも、良さげなActionsを定義してくださっている方がいたので助かりました
ワークフロー上でパッケージバージョンを上げるコミットの発行と、npmへのpublishをセットでやっています

https://github.com/phips28/gh-action-bump-version

ワークフローはこちらになります

on:
  push:
    branches:
      - main
    paths:
      - "src/**.ts"
  workflow_dispatch:

name: Publish

jobs:
  publish:
    runs-on: ubuntu-20.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v1

      - uses: actions/setup-node@v2
        with:
          node-version: "16"
          registry-url: https://registry.npmjs.org
          always-auth: true

      - uses: phips28/gh-action-bump-version@8d1fb3d7cdc88a2df8252eac3db53d31958b98e7
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - run: yarn install

      - run: yarn build

      - run: yarn publish --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}

今までGitHub Private Registryしか使ったことなかったのですが、
実際に自分の書いたツールがnpm公開されているのを見るのもなかなか気持ちがいいですね💕

https://www.npmjs.com/package/kintone-form-model-generator

おわりに

できたばっかでこの記事を書いているのもあり、生成されるコードがまだ不十分の可能性も大いにありますので、
これから自分で実際に利用して直していけたらと思います👯
コントリビュートもお待ちしています!

https://github.com/yktakaha4/kintone-form-model-generator/issues/new/choose

Discussion

yuichkunyuichkun

素晴らしい記事をありがとうございます!!

TS Compiler APIそのままだとちょっと使いづらいところ多いですよね…

記事書くの忘れてたのですが、ts-morph という TS Compiler APIをラップして使いやすくしてくれているライブラリがありまして、そっちを使うとめっっちゃ楽になるので、もし今後機会があったらぜひお試しください🙌

また、ASTの構造の確認には、
https://ts-ast-viewer.com/#
とかが結構使いやすくてよかったです!

Yuuki TakahashiYuuki Takahashi

ありがとうございます!
そう言われてみると確かにラッパー経由でやられてましたね...使ってみればよかったです
リファクタのタイミングがあったら試してみます

viewerは、作りたい型を適当に書いて出てきたASTから呼び出すべきメソッドと渡すデータ構造を推測するために使ってました
こちらも紹介すればよかったですね...