kintoneで宣言的にフィールド値の検証を行う

先日、 [Tynder](https://github.com/shellyln/tynder) という、TypeScript/JavaScript用のスキーマ検証ライブラリを公開しました。

![tynder.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185253/693832d8-902c-745e-c0eb-15a450ce7fb5.png)


[Tynder](https://github.com/shellyln/tynder) は、**TypeScriptのサブセット＋独自の拡張文法**から成る**DSL**によって

1. 型の検査
1. 単独の項目の必須・値の長さ・範囲や文字列パターンの検証
1. 複数項目の相関や整合性検証の一部 (Union typeによる)

を**宣言的**に行うことができます。
また、**カスタムエラーメッセージ**を表示することができます。

今回はTynderを使用して、**kintone**アプリのフィールド値を宣言的に検証したいと思います。

# 動機
kintoneは、標準で項目の必須チェック、文字列項目の文字数レンジチェック、数値項目のレンジチェック機能等を備えていますが、やや複雑な条件でのチェックを行うためには、JavaScriptによるカスタマイズを行う必要があります(例えば、条件付き必須、文字列のパターンマッチ)。
しかし、イベントハンドラ(または、分離した関数)で手続き的にチェックを記述するのは、可読性が低く、また、チェック対象のデータはフィールド型等のメタデータも含んでおり、データの階層が深く、あまり直接触りたくありません。


# 完成イメージ
サブテーブルを含む各フィールドに検証エラーのエラーメッセージを表示します。
以下のサンプルでは、敢えて標準の必須項目等は設定していませんが、必須などのエラーが表示されました。

![tynder-kintone-validation.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185253/39432f36-19c2-4584-d8f1-23a8bfdc4496.png)

# 設定方法
アプリの設定画面から、以下のJavaScriptファイルを追加してください。

* [tynder.min.js](https://github.com/shellyln/tynder/releases)
* lib.js (下述)
* app.js (下述)

# コード

```javascript:lib.js
// `event.record`をメタデータ(フィールド型等)を含まない普通のデータに変換します
function mapRecord(rec) {
    const ret = {};
    const keys = Object.keys(rec);
    const subTableMapper = x => mapRecord(x.value);
    for (const k of keys) {
        if (rec[k].value === void 0) {
            continue;
        }
        switch (rec[k].type) {
        case 'NUMBER':
            ret[k] = typeof rec[k].value === 'string' ?
                Number(rec[k].value.replace(/[,]/g, '')) :
                rec[k].value;
            if (Number.isNaN(ret[k])) {
                ret[k] = null;
            }
            break;
        case 'SUBTABLE':
            ret[k] = rec[k].value.map(subTableMapper);
            break;
        default:
            ret[k] = rec[k].value;
            break;
        }
    }
    return ret;
}

// サブテーブルのブランク行を削除します(新規行のみ)
function removeBlankTableRow(rec, tableFieldCode) {
    const validRecs = [];
    for (const r of rec[tableFieldCode].value) {
        if (r.id !== null) {
            validRecs.push(r);
            continue;
        }
        const keys = Object.keys(r.value);
        for (const k of keys) {
            const q = r.value[k];
            if (q.value !== void 0 && q.value !== null && q.value !== '') {
                validRecs.push(r);
                break;
            }
        }
    }
    rec[tableFieldCode].value = validRecs;
    return rec;
}

// フィールドにエラーメッセージを表示します
function displayValidationErrorMessages(event, ctx) {
    for (const m of ctx.errors) {
        const dp = m.dataPath.split('.').map(x => x.split(':').slice(-1)[0]);
        const fieldCode = dp[0];
        if (m.dataPath.includes('repeated).')) {
            const index = /\.\(([0-9]+):repeated\)\./.exec(m.dataPath);
            if (index) {
                const subFieldCode = dp[dp.length - 1];
                event.record[fieldCode].value[Number(index[1])].value[subFieldCode].error = m.message;
            }
        } else {
            if (event.record[fieldCode]) {
                event.record[fieldCode].error = m.message;
            }
        }
    }
    event.error = 'Validation error';
    return event;
}
```

```javascript:app.js
(function() {
"use strict";

// アプリのレコード型を定義します
// interfaceの各フィールドは、kintoneアプリのフィールドコードと一致させてください
const definition = `
/** サブテーブル */
interface Table {
    itemName: string;
    itemValue: number;
}

/** 全ステータス共通のフィールド */
interface AppBase {
}

/** アプリ (ステータス1) */
interface App1 extends AppBase {
    @minLength(2)
    name: string;
    amount: number;
    table: Table[];
}
// レコードのステータス毎に定義を持てば、殆どの条件付きチェックは
// 条件分岐を含まない形の分解できる
`;

// イベントハンドラ
kintone.events.on([
    'app.record.create.submit',
    'mobile.app.record.create.submit',
    'app.record.edit.submit',
    'mobile.app.record.edit.submit',
    'app.record.index.edit.submit',
    ], function(event) {
    event.record = removeBlankTableRow(event.record, 'table');

    // スキーマ検証を行います
    const schema = tynder.compile(definition);
    const ctx = {checkAll: true, schema, stereotypes: new Map(tynder.stereotypes)};
    const validated = tynder.validate(mapRecord(event.record), tynder.getType(schema, 'App1'), ctx);
    if (! validated) {
        const errText = JSON.stringify(ctx.errors, null, 2);
        console.error(errText);

        // エラーを表示します
        displayValidationErrorMessages(event, ctx);
    }
    return event;
});

})();
```


# 追記(2020/3/2) 型情報を自動生成する

型定義をゼロから手動作成するのも面倒ですので、自動生成できるようにしました。
一覧画面にボタンを作成して、ボタンクリックで画面に表示します。
（上記アプリとは別の、空のアプリに組み込みます）

```js:genTypeDef.js
(function() {
"use strict";

const escapeString = (s) => {
    return (s
        .replace(/\x08/g, '\\b')
        .replace(/\f/g, '\\f')
        .replace(/\n/g, '\\n')
        .replace(/\r/g, '\\r')
        .replace(/\t/g, '\\t')
        .replace(/\v/g, '\\v')
        .replace(/\\/g, '\\\\')
        .replace(/\'/g, '\\\'')
        .replace(/\"/g, '\\\"')
        .replace(/\`/g, '\\\`')
    );
};


const getAppMetaInfo = (appId) => {
    // returns promise
    return kintone.api(kintone.api.url('/k/v1/app/form/fields', true), 'GET', {app: appId});
};


const getFields = (interfaceName, fields, nestLv) => {
    let code = '';
    let subTableCode = '';
    const keys = Object.keys(fields);

    OUTER: for (const k of keys) {
        let isArray = false;
        let typeName = 'any';
        let decorators = '';
        const field = fields[k];

        switch (field.type) {
        case 'STATUS':
            typeName = 'string';
            break;
        case 'CATEGORY':
            typeName = 'string';
            break;
        case 'NUMBER':
            typeName = 'number';
            break;
        case 'SINGLE_LINE_TEXT':
            typeName = 'string';
            break;
        case 'MULTI_LINE_TEXT':
            typeName = 'string';
            break;
        case 'RICH_TEXT':
            typeName = 'string';
            break;
        case 'LINK':
            typeName = 'string';
            break;
        case 'DATE':
            decorators += `@stereotype('lcdate')`;
            typeName = 'string';
            break;
        case 'DATETIME':
            decorators += `@stereotype('lcdatetime')`;
            typeName = 'string';
            break;
        case 'TIME':
            decorators += `@match(/^[0-2]\d:[0-5]\d$/)`;
            typeName = 'string';
            break;
        case 'RADIO_BUTTON': case 'DROP_DOWN':
            typeName = Object.keys(field.options)
                .map(x => `'${escapeString(x)}'`).join(' | ');
            break;
        case 'CHECK_BOX': case 'MULTI_SELECT':
            isArray = true;
            typeName = `(${Object.keys(field.options)
                .map(x => `'${escapeString(x)}'`).join(' | ')})[${field.required ? ', 1..' : ''}]`;
            break;
        case 'USER_SELECT':
            isArray = true;
            typeName = `Array<{code: string, name: string}${field.required ? ', 1..' : ''}>`;
            break;
        case 'GROUP_SELECT':
            isArray = true;
            typeName = `Array<{code: string, name: string}${field.required ? ', 1..' : ''}>`;
            break;
        case 'ORGANIZATION_SELECT':
            isArray = true;
            typeName = `Array<{code: string, name: string}${field.required ? ', 1..' : ''}>`;
            break;
        case 'SUBTABLE':
            {
                isArray = true;
                typeName = field.code[0].toUpperCase() + field.code.slice(1);
                const sub = getFields(typeName, field.fields, nestLv);
                subTableCode += sub.subTableCode + sub.code;
                typeName = `${typeName}[${field.required ? ', 1..' : ''}]`;
            }
            break;

        case 'CALC':
        case 'FILE':
        case 'STATUS_ASSIGNEE':
            continue OUTER;

        // system fields
        case 'RECORD_NUMBER':
        case 'CREATED_TIME':
        case 'CREATOR':
        case 'UPDATED_TIME':
        case 'MODIFIER':
            continue OUTER;

        // UI elements (no record fields)
        case 'REFERENCE_TABLE':
        case 'GROUP':
        default:
            continue OUTER;
        }

        if (field.maxValue) {
            let v = '';
            if (field.type === 'NUMBER') {
                v = field.maxValue;
            } else {
                v = `'${escapeString(field.maxValue)}'`;
            }
            decorators += `${decorators ? ' ' : ''}@maxValue(${v})`;
        }
        if (field.minValue) {
            let v = '';
            if (field.type === 'NUMBER') {
                v = field.minValue;
            } else {
                v = `'${escapeString(field.minValue)}'`;
            }
            decorators += `${decorators ? ' ' : ''}@minValue(${v})`;
        }
        if (field.maxLength) {
            let v = '';
            if (field.type === 'NUMBER') {
                v = field.maxLength;
            } else {
                v = `'${escapeString(field.maxLength)}'`;
            }
            decorators += `${decorators ? ' ' : ''}@maxLength(${v})`;
        }
        if (field.minLength) {
            let v = '';
            if (field.type === 'NUMBER') {
                v = field.minLength;
            } else {
                v = `'${escapeString(field.minLength)}'`;
            }
            decorators += `${decorators ? ' ' : ''}@minLength(${v})`;
        }
        code += `${''.padEnd((nestLv + 1) * 4)}${
            decorators ? decorators + '\n' + ''.padEnd((nestLv + 1) * 4) : ''}${
            /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field.code) ? field.code : `'${escapeString(field.code)}'`}${
            (isArray || field.required) ? '' : '?'}: ${typeName};\n`;
    }
    code = `\n\ninterface ${interfaceName} {\n${code}}\n\n`;
    return ({subTableCode, code});
};


const outputAppTypes = async (appId) => {
    const metaInfo = await getAppMetaInfo(appId);
    const fields = metaInfo.properties;
    const {subTableCode, code} = getFields('App', fields, 0);
    return subTableCode + code;
};
    
kintone.events.on('app.record.index.show', (event) => {
    if (document.getElementById('my_index_button') !== null) {
        return;
    }
    const myIndexButton = document.createElement('button');
    myIndexButton.id = 'my_index_button';
    myIndexButton.innerText = 'Display App Types';
    const myAppIdInput = document.createElement('input');
    myAppIdInput.id = 'my_appid_input';
    myAppIdInput.value = '38'; // ← AppId

    myIndexButton.onclick = async () => {
        const myHeaderSpace = kintone.app.getHeaderSpaceElement();
        const myListHeaderDiv = document.createElement('pre');

        const code = await outputAppTypes(Number(document.getElementById('my_appid_input').value));
        myListHeaderDiv.innerText = code;

        myHeaderSpace.innerText = '';
        myHeaderSpace.appendChild(myListHeaderDiv);
    };

    kintone.app.getHeaderMenuSpaceElement().appendChild(myAppIdInput);
    kintone.app.getHeaderMenuSpaceElement().appendChild(myIndexButton);
});

})();
```


# さいごに
書かなくて良いコードは書かずに、楽しくプログラミングしたいですね