メインコンテンツまでスキップ

Command入力schemaの契約

実装済み・未リリース

この文書は、今回実装したCommand入力schemaの契約です。これらの追加機能は @zeltjs/core@0.11.0 には含まれていません。使い方の概要はコマンドを参照してください。

利用者は入力の契約を一度schemaに宣言します。Zeltはその宣言から入力の検証、TypeScriptの入力型、ヘルプを導きます。追加したAPIも @zeltjs/core から提供し、宣言・パース・型推論・検証・ヘルプ生成はZeltで実装しています。

schemaを渡して入力を受け取る​

既存の cliSchema({ args, options }) の書き方を拡張します。schemaはモジュールレベルの定数にし、run() のデフォルト引数で args(schema) に渡します。

import { Command, args, cliSchema } from '@zeltjs/core';

const checkGuideSchema = cliSchema({
  options: [
    { name: 'snapshot', type: 'string', required: true },
    { name: 'guide', type: 'string', required: true },
  ],
});

@Command({ name: 'check-guide' })
export class CheckGuideCommand {
  run(input = args(checkGuideSchema)) {
    // input.snapshot: string
    // input.guide: string
    console.log(input.snapshot, input.guide);
  }
}

HTTPの request(schema) と同じく、schemaを渡すことで入力型が推論されます。@Command へのschemaの二重登録や、run() の入力型の手書きは要求しません。constructorでの inject() による依存性の注入も維持します。

必須オプション​

required: true は、そのオプションをコマンドラインで明示する契約です。長い名前と alias のどちらで指定しても条件を満たします。省略された場合、args(schema) は入力を返しません。

宣言未指定時推論される型
type: 'string'undefinedstring | undefined
type: 'string', required: true入力エラーstring
type: 'number'undefinednumber | undefined
type: 'number', required: true入力エラーnumber
type: 'boolean'falseboolean
type: 'boolean', required: true入力エラーboolean
type: 'string', default: 'main''main'string
type: 'number', default: 30003000number
type: 'boolean', default: truetrueboolean

文字列・数値オプションは、名前を指定しても値がなければ入力エラーです。数値に変換できない値や有限でない数値も拒否します。空文字列を明示した文字列オプションは、指定済みとして扱います。文字列の長さや数値の範囲などの業務上の検証は、この必須条件に含みません。

上の checkGuideSchema に対する契約は次のとおりです。

コマンドへの入力結果
check-guide --snapshot snapshot.json --guide guide.json{ snapshot: 'snapshot.json', guide: 'guide.json' }
check-guide --guide guide.json --snapshot snapshot.json同じ入力値を受け取る
check-guide --snapshot snapshot.json必須の --guide がないため拒否
check-guide --snapshot snapshot.json --guide--guide の値がないため拒否
check-guide --snapshot snapshot.json --guide guide.json extra宣言していない位置引数のため拒否

契約: required: true と default の併用は、schemaの宣言エラーにします。明示指定を求める契約と、省略時に値を補う契約を一つのオプションに重ねません。required: true のない既存のデフォルト値の扱いは維持します。

可変長位置引数​

variadic: { min, max } は、残りの位置引数を配列として受け取る契約です。

import { cliSchema } from '@zeltjs/core';

const analyzeSchema = cliSchema({
  args: [
    {
      name: 'directories',
      type: 'string',
      variadic: { min: 1 },
      description: '解析対象のディレクトリ',
    },
  ],
});
pathdency src packages/a packages/b

この入力から { directories: ['src', 'packages/a', 'packages/b'] } を受け取ります。配列の順序は入力順です。

宣言許可する個数未指定時推論される型
type: 'string', variadic: { min: 0 }0個以上[]string[]
type: 'string', variadic: { min: 1 }1個以上入力エラーstring[]
type: 'number', variadic: { min: 1, max: 3 }1〜3個入力エラーnumber[]

配列の個数は実行時に検証します。min や max からタプル型は生成しません。数値の可変長引数では、各要素を変換・検証し、一つでも不正なら入力全体を拒否します。

契約: min の省略は 0、max の省略は上限なしとします。

宣言には次の条件があります。

  • 可変長引数は、一つのschemaに一つだけ、args の最後に置きます。その前には固定の位置引数を置けます。
  • min と max は0以上の有限の整数です。両方ある場合は min <= max が必要です。
  • 可変長引数には optional: true を併用しません。0個を許可する場合は min: 0 と書きます。
  • variadic のない位置引数は既存どおり単一の値です。省略可否は optional: true で指定します。

固定の位置引数を宣言順に割り当て、その後に残った値を可変長引数に渡します。可変長引数の min を満たすために、前の固定引数を飛ばして割り当て直すことはありません。

たとえば variadic: { min: 1, max: 3 } なら、0個・4個は拒否し、1個・3個は受理します。上限を超えた値を捨てたり、配列を切り詰めたりしません。

有効な入力をunionで宣言する​

入力同士の排他は、受け付ける入力の形を別々のschemaにして cliUnion() に渡します。

import { Command, args, cliSchema, cliUnion } from '@zeltjs/core';

const snapshotSchema = cliSchema({
  options: [
    { name: 'snapshot', type: 'string', required: true },
  ],
});

const directoriesSchema = cliSchema({
  args: [
    { name: 'directories', type: 'string', variadic: { min: 0 } },
  ],
  options: [
    { name: 'base', type: 'string' },
  ],
});

const configSchema = cliSchema({
  options: [
    { name: 'config', type: 'string', required: true },
    { name: 'base', type: 'string' },
  ],
});

const serveSchema = cliUnion([
  snapshotSchema,
  directoriesSchema,
  configSchema,
]);

@Command({ name: 'serve' })
export class ServeCommand {
  run(input = args(serveSchema)) {
    if (input.snapshot !== undefined) {
      // input.snapshot: string
      console.log('snapshot', input.snapshot);
    } else if (input.config !== undefined) {
      // input.config: string
      // input.base: string | undefined
      console.log('config', input.config, input.base);
    } else {
      // input.directories: string[]
      // input.base: string | undefined
      console.log('directories', input.directories, input.base);
    }
  }
}

枝の選択​

各枝は、コマンド名を除く入力全体に対して検証します。別の枝に宣言されていても、現在の枝にない入力を受け入れません。枝の宣言をまとめてパースしたり、受理できた部分だけを返したりしません。

コマンドへの入力結果
serve --snapshot saved.jsonsnapshotの枝。snapshot は 'saved.json'
serve src packages/adirectoriesの枝。directories は ['src', 'packages/a']
serve src --base maindirectoriesの枝。base は 'main'
serve --config pathdency.json --base mainconfigの枝。config と base を受け取る
servedirectoriesの枝。directories は []、base は undefined
serve --base maindirectoriesの枝。directories は []、base は 'main'
serve --snapshot saved.json --base mainどの枝も入力全体を受理できないため拒否
serve --snapshot saved.json src同上
serve src --config pathdency.json同上
serve --snapshot saved.json --config pathdency.json同上
serve --snapshop saved.json宣言していないオプションのため拒否

オプションの順序を入れ替えても、同じ組み合わせの受理・拒否は変わりません。

契約: 入力全体を受理する枝が一つだけの場合に成功します。0個なら入力エラー、複数なら曖昧な入力として拒否します。配列の先頭の枝を優先する規則は設けません。たとえば、省略可能なオプションしかない二つのschemaをunionにすると、空の入力は両方に一致するため拒否します。枝の順番を入れ替えても、結果は変わりません。

デフォルト値は、その枝の未指定オプションだけに適用します。ある枝が失敗したことで別の枝に値が混ざることはありません。snapshotの枝を受理した場合、解析の枝の base にデフォルト値が宣言されていても、結果に base は追加しません。デフォルト値だけで required: true を満たすこともありません。

推論される入力型​

args(serveSchema) は、各枝の入力型のunionを返します。各枝にない項目は ?: never として型に表し、選ばれた枝の結果にその項目を追加しません。上の例の推論結果は次の形です。

type ServeInput =
  | {
      snapshot: string;
      directories?: never;
      config?: never;
      base?: never;
    }
  | {
      directories: string[];
      base: string | undefined;
      snapshot?: never;
      config?: never;
    }
  | {
      config: string;
      base: string | undefined;
      snapshot?: never;
      directories?: never;
    };

この型は説明用です。利用者が定義して run() に付ける必要はありません。InferSchema<typeof serveSchema> でも同じ型を取得できます。mode など、schemaに宣言していない識別子は自動で追加しません。

宣言外の入力は拒否する​

cliSchema() と cliUnion() は、未知のオプションと余分な位置引数を常に拒否します。strict の切り替えは設けません。

入力契約
宣言したオプション名・alias宣言した型と必須条件で検証する
宣言していない長いオプション・alias値の有無によらず拒否する
固定の位置引数の個数を超える入力拒否する
可変長引数の max を超える入力拒否する
-- より後のトークン位置引数として扱い、同じ型・個数の検証を行う

たとえば cliSchema({}) は空の入力だけを受理し、--unknown や位置引数を拒否します。単一の位置引数だけを宣言したschemaで first second を渡すと、二つ目を捨てずに拒否します。

args(schema) を run() のデフォルト引数に置いた場合、入力エラーなら run() の本体には入りません。execCommand() は既存の失敗結果である exitCode: 1 と ZeltCommandExecutionError(context.reason: 'argv_parse_error')を返します。利用者が必須・個数・排他・未知名の検証を run() に書き足す必要はありません。

エラーの説明では、不足したオプション名、不正な値、許可する個数と渡された個数、受理できない入力の組み合わせなど、修正に必要な原因を示します。unionの失敗を「一致する枝がない」だけで終わらせません。説明文の完全一致は契約に含めません。

この契約は args(schema) に渡した入力の検証を対象にします。args() を呼ばないcommandへのschemaの自動適用は、この契約に含めません。

ヘルプも同じschemaから導く​

ヘルプには、宣言した名前、alias、型、description、必須・省略可、デフォルト値、可変長の個数を反映します。unionでは入力の選択肢ごとにusageを表示し、別の枝の入力を自由に混ぜられるようには表示しません。

Usage:
  check-guide --snapshot <snapshot> --guide <guide>

Usage:
  serve --snapshot <snapshot>
  serve [directories...] [--base <base>]
  serve --config <config> [--base <base>]

<directories...> は1個以上、[directories...] は0個以上を表します。たとえば min: 1, max: 3 なら、usageに加えて「1〜3個」を記載します。選択肢ごとに入力できるオプションと必須条件を読み取れることを契約とし、空白や装飾は固定しません。

formatCommandHelp(commandName, schema) は、同じschemaからヘルプの文字列を返します。引数をパースしたり、コマンドを実行したり、標準出力へ書き込んだりしません。--help の自動処理は追加していません。

import { cliSchema, formatCommandHelp } from '@zeltjs/core';

const schema = cliSchema({
  args: [{ name: 'directories', type: 'string', variadic: { min: 1, max: 3 } }],
});
console.log(formatCommandHelp('pathdency', schema));

schema宣言の整合性​

利用者の入力エラーと、開発者のschema宣言エラーは区別します。宣言エラーのschemaで入力を受理してはいけません。

  • 同じ枝で、出力に使う name が位置引数・オプションを通して重複しないこと。
  • 同じ枝で、オプション名・aliasが別のオプション名・aliasと衝突しないこと。
  • type と default の型が一致すること。
  • required と default、可変長引数の配置・個数・optional が上記の条件を満たすこと。
  • cliUnion() の選択肢が二つ以上あること。別の枝で同じ名前やaliasを使うことは許可する。

リテラルから判定できる不整合は、可能な限りTypeScriptで拒否します。動的な値からschemaを作った場合も、同じ宣言条件を実行時に検証します。cliSchema()、cliUnion()、formatCommandHelp() は、宣言エラーの場合に ZeltAppConfigurationError(context.reason: 'invalid_command_schema')をthrowします。名前・aliasの重複や可変長引数の配置はリテラルから型検査し、数値の境界は実行時に検証します。

実装した仕様と範囲​

契約として実装した仕様は、次の四点です。

  1. required: true と default の併用を宣言エラーにする。
  2. 可変長引数は min の省略を0、max の省略を上限なしとし、型は配列のままにする。
  3. unionで複数の枝に一致する入力を拒否し、宣言順による優先順位を設けない。
  4. cliUnion() には二つ以上のschemaを渡す。

run(input = args(schema)) の書き方とconstructorでのDIを維持しています。--help の自動処理や、schemaを使わないcommandへの自動検証は追加していません。