Test-Jsonがないならつくればいいじゃない

# はじめに

PowerShellには `Test-Json` というコマンドレットがあります。

https://learn.microsoft.com/ja-jp/powershell/module/microsoft.powershell.utility/test-json?view=powershell-7.4

上記ドキュメントではこういう風に説明されています。

> `Test-Json` コマンドレットは、文字列が有効な JavaScript Object Notation (JSON) ドキュメントであるかどうかをテストし、必要に応じて、指定されたスキーマに対してその JSON ドキュメントを検証できます。

とくに

> 指定されたスキーマに対してその JSON ドキュメントを検証できます。

の部分が便利で、JSONスキーマに照らし合わせてのチェックもできる優れものです。

ところで、このコマンドレットはPowerShell 6.1から導入された機能なのでWindows標準装備の5.1では使えないです。残念...

そんななか、5.1のPowerShellしか使えない環境向けにスクリプトを移植する過程で Test-Json どうする問題がでてしまい、最終的に Test-Json っぽいものを自作して解決したのでその話を紹介したいと思います。

# Test-Jsonとは

最初に Test-Json を少し紹介しておきます。

冒頭でも書いたように Test-Json は文字列が有効なJSONかをテストしたりスキーマに合致しているかをテストできるコマンドレットです。

たとえば以下のように有効なJSON文字列であればTrueが返ってきて、無効な文字列ならエラーとFalseが返ってきます。

```txt
> Test-Json -Json '{}'
True

> Test-Json -Json 'I am JSON.'
Test-Json: Cannot parse the JSON.
False
```

もちろんPowerShellらしくパイプラインを使っても書けます。

```txt
> '{}' | Test-Json
True
```

またJSONスキーマを指定すると定義に合致していればTrueが返ってきて、合致していなければエラーとFalseが返ってきます。

```txt
> Test-Json -Json '{"foo":10,"bar":"hello"}' -Schema '{"type":"object","required":["bar"]}'
True

> Test-Json -Json '{"foo":10}' -Schema '{"type":"object","required":["bar"]}'
Test-Json: The JSON is not valid with the schema: Required properties ["bar"] are not present at ''
False
```

JSONスキーマは[公式ドキュメント](https://json-schema.org/overview/what-is-jsonschema)に

> JSON Schema is a declarative language for defining structure and constraints for JSON data.

とあるように、JSON構造を定義する言語で、それ自体もJSON構造になっています。

例えば[公式サンプル](https://json-schema.org/learn/miscellaneous-examples#basic)に以下のようなスキーマが載っていて、この定義は

* トップレベルはオブジェクトである。
* トップレベルのオブジェクトは "firstName", "lastName", "age" というプロパティを持ちうる。（持ってなくてもいい）
* "firstName" というプロパティの値は文字列型である。
* "lastName" というプロパティの値は文字列型である。
* "age" というプロパティの値は整数型である。
* "age" というプロパティの値の最小値は0である。

ということを表しています。

```json
{
  "$id": "https://example.com/person.schema.json",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Person",
  "type": "object",
  "properties": {
    "firstName": {
      "type": "string",
      "description": "The person's first name."
    },
    "lastName": {
      "type": "string",
      "description": "The person's last name."
    },
    "age": {
      "description": "Age in years which must be equal to or greater than zero.",
      "type": "integer",
      "minimum": 0
    }
  }
}
```

定義可能なルールは[いろいろあります](https://json-schema.org/understanding-json-schema/keywords)が、今回は以下のルールだけを使っています。

* [type](https://www.learnjsonschema.com/2020-12/validation/type/)
  * 型情報。"object"や"string"など型名の文字列を持つ。
  * 例）`"type": "string"`

* [properties](https://www.learnjsonschema.com/2020-12/applicator/properties/)
  * オブジェクトのプロパティ情報。オブジェクト型で、プロパティ名がキーでバリューがスキーマ定義のプロパティを持つ。
  * 例）`"properties": { "hoge": { "type": "string" }, "fuga": { "fuga": "integer" } }`

* [required](https://www.learnjsonschema.com/2020-12/validation/required/)
  * オブジェクトの必須プロパティ情報。必須プロパティの名前を文字列リストで持つ。
  * 例）`"required": ["hoge", "fuga"]`

* [items](https://www.learnjsonschema.com/2020-12/applicator/items/)
  * 配列の要素の型情報。スキーマ定義オブジェクトを持つ。
  * 例）`"items": { "type": "string" }`

## 閑話休題: Test-Jsonのエラーハンドリング

少し話が変わりますが Test-Json のエラーハンドリングについても一苦労あったので書いておきます。

Test-Json でJSONスキーマにマッチしなかったとき、エラー応答としてその理由が**すべて**返されます。

```txt
> '{"a":1, "b":"x"}' | Test-Json -Schema '{"type":"object","properties":{"a":{"type":"string"},"b":{"type":"integer"}}}'
Test-Json: The JSON is not valid with the schema: Value is "integer" but should be "string" at '/a'
Test-Json: The JSON is not valid with the schema: Value is "string" but should be "integer" at '/b'
False
```

ただし、これらは例外ではないのでtry-catchではトラップできないです。
またtry-catchでトラップできるように `-ErrorAction` オプションに `Stop` をつけてしまうと1つめのエラーが見つかった時点で終了してしまいます。

じゃあどうすればいいのかというと `-ErrorVariable` オプションで解決できました。

```powershell:test.ps1
$Json = '{"a":1, "b":"x"}'
$Schema = '{"type":"object", "properties":{"a":{"type":"string"}, "b":{"type":"integer"}}}'
$IsValid = Test-Json -Json $Json -Schema $Schema -ErrorVariable TestJsonError 2>$null
if ($IsValid) {
  Write-Host "チェック結果: OK"
}
else {
  Write-Host "チェック結果: NG"
  $TestJsonError | ForEach-Object {
    Write-Hosr "エラー理由: $_"
  }
}
```

```txt
> test.ps1
チェック結果: NG
エラー理由: The JSON is not valid with the schema: Value is "integer" but should be "string" at '/a'
エラー理由: The JSON is not valid with the schema: Value is "string" but should be "integer" at '/b'
```

`-ErrorVariable` は指定した名前の変数にエラーが格納されるオプションなので、チェック結果がFalseならその変数を参照してエラー理由をすべて出力できます。

ちなみにオプションをつけてもコンソールにはエラーが出てしまうのでエラー出力をnullにリダイレクトしています。

# Test-Jsonつくってみた

ということで？上記のような動きをする Test-Json を関数として自作してみました。

はじめにことわっておきますが、今回実装したのは Test-Json の一部の機能のみです。あくまで自分の用途で使っていた機能の移植版なので。
なので、例えば文字列のpatternチェックや数値の範囲チェックなどは未実装です。

以下はコード全量です。

<details><summary>Test-Json.psm1</summary>

```powershell
# ConvertFrom-Jsonの戻り値のオブジェクトをOrderedDictionaryに変換する関数
function ConvertTo-OrderedDictionary {
  Param(
    [pscustomobject] $obj
  )
  $dict = [ordered] @{}
  foreach ($prop in $obj.PSObject.Properties) {
    $k = $prop.Name
    $v = $prop.Value
    if ($v -is [pscustomobject]) {
      $v = ConvertTo-OrderedDictionary $v
    }
    if ($v -is [array] -and $v[0] -is [pscustomobject]) {
      $v = @($v | % { ConvertTo-OrderedDictionary $_ })
    }
    $dict.Add($k, $v)
  }
  return $dict
}

# エラー文言のPrefix
$ErrorPrefix = "The JSON is not valid with the schema: "

# 再帰的にJSONをチェックする関数
function Invoke-RecursiveCheck {
  Param(
    [System.Collections.Specialized.OrderedDictionary] $Schema,
    [object] $Data,
    [string] $Path,
    [ref] $ErrList
  )
  if ($Data -eq $null) {
    $Type = "null"
  }
  else {
    $Type = $Data.GetType().Name
  }
  if ("type" -cnotin $Schema.Keys) {
    # 本来は必須ではないがtypeを必須で処理する
    return
  }
  switch ($Schema.type) {
    "object" {
      Write-Verbose ("スキーマの{0}型を処理します。" -f "オブジェクト")
      if ($Data -isnot [System.Collections.Specialized.OrderedDictionary]) {
        Write-Verbose ("型の不一致: 対象データは{0}型ですが期待していたのは{1}型です。" -f $Type, "object")
        $ErrList.Value += "${ErrorPrefix}Value is `"${Type}`" but should be `"object`" at '${Path}'"
      }
      else {
        $NotFoundPropList = @()
        if ("required" -cin $Schema.Keys) {
          foreach ($PropName in $Schema.required) {
            if ($PropName -cnotin $Data.Keys) {
              $NotFoundPropList += $PropName
            }
          }
          if ($NotFoundPropList.Count -gt 0) {
            $NotFoundProps = $NotFoundPropList -join '","'
            $ErrList.Value += "${ErrorPrefix}Required properties [`"${NotFoundProps}`"] are not present at '${Path}'"
          }
        }
        if ("properties" -cin $Schema.Keys) {
          foreach ($PropName in $Schema.properties.Keys) {
            if ($PropName -cin $Data.Keys) {
              Invoke-RecursiveCheck `
                -Schema $Schema.properties[$PropName] `
                -Data $Data[$PropName] `
                -Path "${Path}/${PropName}" `
                -ErrList $ErrList
            }
          }
        }
      }
    }
    "array" {
      Write-Verbose ("スキーマの{0}型を処理します。" -f "配列")
      if ($Data -isnot [array]) {
        Write-Verbose ("型の不一致: 対象データは{0}型ですが期待していたのは{1}型です。" -f $Type, "object")
        $ErrList.Value += "${ErrorPrefix}Value is `"${Type}`" but should be `"array`" at '${Path}'"
      }
      else {
        if ("items" -cin $Schema.Keys) {
          $Data | % { $i = 0 } {
            Invoke-RecursiveCheck `
              -Schema $Schema.items `
              -Data $_  `
              -Path "${Path}/${i}" `
              -ErrList $ErrList
            $i += 1
          }
        }
      }
    }
    "string" {
      Write-Verbose ("スキーマの{0}型を処理します。" -f "文字列")
      if ($Data -isnot [string]) {
        Write-Verbose ("型の不一致: 対象データは{0}型ですが期待していたのは{1}型です。" -f $Type, "object")
        $ErrList.Value += "${ErrorPrefix}Value is `"${Type}`" but should be `"string`" at '${Path}'"
      }
    }
    "integer" {
      Write-Verbose ("スキーマの{0}型を処理します。" -f "整数")
      if ($Data -isnot [int]) {
        Write-Verbose ("型の不一致: 対象データは{0}型ですが期待していたのは{1}型です。" -f $Type, "object")
        $ErrList.Value += "${ErrorPrefix}Value is `"${Type}`" but should be `"integer`" at '${Path}'"
      }
    }
    default {
      Write-Host ("型{0}の処理は未実装です。" -f $Schema.type)
    }
  }
}

# Test-Jsonの自作版
function Test-Json {

  [OutputType('System.Boolean')]
  [CmdletBinding(DefaultParameterSetName = "JsonSet")]
  Param(
    [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "JsonSet")]
    [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "JsonSchemaSet")]
    [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "JsonSchemaFileSet")]
    [string] $Json,

    [Parameter(Mandatory = $true, ParameterSetName = "PathSet")]
    [Parameter(Mandatory = $true, ParameterSetName = "PathSchemaSet")]
    [Parameter(Mandatory = $true, ParameterSetName = "PathSchemaFileSet")]
    [string] $Path,

    [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSet")]
    [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSchemaSet")]
    [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSchemaFileSet")]
    [string] $LiteralPath,

    [Parameter(Mandatory = $true, ParameterSetName = "JsonSchemaSet")]
    [Parameter(Mandatory = $true, ParameterSetName = "PathSchemaSet")]
    [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSchemaSet")]
    [string] $Schema,

    [Parameter(Mandatory = $true, ParameterSetName = "JsonSchemaFileSet")]
    [Parameter(Mandatory = $true, ParameterSetName = "PathSchemaFileSet")]
    [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSchemaFileSet")]
    [string] $SchemaFile
  )

  process {
    switch ($PSCmdlet.ParameterSetName) {
      "JsonSet" {
        $JsonStr = $Json
        $SchemaStr = ""
        break
      }
      "PathSet" {
        $JsonStr = Get-Content -Raw -Path $Path
        $SchemaStr = ""
        break
      }
      "LiteralPathSet" {
        $JsonStr = Get-Content -Raw -LiteralPath $LiteralPath
        $SchemaStr = ""
        break
      }
      "JsonSchemaSet" {
        $JsonStr = $Json
        $SchemaStr = $Schema
        break
      }
      "PathSchemaSet" {
        $JsonStr = Get-Content -Raw -Path $Path
        $SchemaStr = $Schema
        break
      }
      "LiteralPathSchemaSet" {
        $JsonStr = Get-Content -Raw -LiteralPath $LiteralPath
        $SchemaStr = $Schema
        break
      }
      "JsonSchemaFileSet" {
        $JsonStr = $Json
        $SchemaStr = Get-Content -Raw -LiteralPath $SchemaFile
        break
      }
      "PathSchemaFileSet" {
        $JsonStr = Get-Content -Raw -Path $Path
        $SchemaStr = Get-Content -Raw -LiteralPath $SchemaFile
        break
      }
      "LiteralPathSchemaFileSet" {
        $JsonStr = Get-Content -Raw -LiteralPath $LiteralPath
        $SchemaStr = Get-Content -Raw -LiteralPath $SchemaFile
        break
      }
    }

    try {
      $JsonData = ConvertTo-OrderedDictionary ($JsonStr | ConvertFrom-Json)
    }
    catch {
      Write-Error -Message "Cannot parse the JSON."
      return $false
    }

    if ($SchemaStr -ne "") {
      try {
        $SchemaData = ConvertTo-OrderedDictionary ($SchemaStr | ConvertFrom-Json)
      }
      catch {
        Write-Debug $_.ScriptStackTrace
        Write-Error -Message "Cannot parse the JSON schema."
        return $false
      }
      $ErrList = [ref] @()
      Invoke-RecursiveCheck `
        -Schema $SchemaData `
        -Data $JsonData `
        -Path '' `
        -ErrList $ErrList
    }

    if ($ErrList.Value.Count -gt 0) {
      $ErrList.Value | % {
        Write-Error -Message "$_"
      }
      return $false
    }
    else {
      return $true
    }
  }
}

# モジュールとして関数をエクスポート
Export-ModuleMember -Function Test-Json
```

</details>

使い方としては、例えばバージョンをチェックしたり Test-Json の有無をチェックしてからこのモジュールをインポートするようにすれば常に Test-Json が使えるようになるかと思います。

```powershell
# 方法1: バージョンで判断
if ($PSVersionTable.PSEdition -eq "Desktop") {
  Import-Module "~~/Test-Json.psm1" -Function "Test-Json"
}
```

```powershell
# 方法2: コマンドレットの有無で判断
if (Get-Command "Test-Json" 2>$null) {
  Import-Module "~~/Test-Json.psm1" -Function "Test-Json"
}
```

以降ではいくつかポイントにわけて解説してみようと思います。

## OrderedDictionary (順序付き辞書型)

`ConvertFrom-Json` コマンドレットで文字列をオブジェクトに変換すると返り値は `[pscustomobject]` 型になります。
このままでも処理できますが、辞書型の方がキーバリューの取得がより簡単になるのと、OrderedDictionary型ならスキーマの "properties" の順どおりに処理できてうれしいかも？と思い変換しています。
※今回は変換処理を自分で実装していますが、PowerShell 6.1以降なら `-AsHashtable` オプションをONにすることで始めからOrderedDictionaryで結果を取得できます。

```txt:キーの取得方法の違い
> $data = '{"x":1,"y":2}' | ConvertFrom-Json
> ($data.PSObject.Properties | % { $_.Name }) -join ','
x,y

> $data = '{"x":1,"y":2}' | ConvertFrom-Json -AsHashtable
> $data.Keys -join ','
x,y
```

```txt:順序付きの違い
> (@{"a"=1;"b"=2;"c"=3}).Keys -join ','
c,a,b

> ([ordered] @{"a"=1;"b"=2;"c"=3}).Keys -join ','
a,b,c
```

## 再帰チェック

モジュールとしてエクスポートしているのは Test-Json ですが、メイン処理は Invoke-RecursiveCheck で、これで再帰的にスキーマとJSONを照合しています。

Invoke-RecursiveCheck ではまず "type" をみてスキーマの型を判別しています。

```powershell
switch ($Schema.type) {
  "object" { ~~ }
  "array" { ~~ }
  "string" { ~~ }
  "integer" { ~~ }
}
```

ちなみに本物の Test-Json は "type" というプロパティがなくても他の項目から型を判別してくれるみたいです。
ただ今回はJSONスキーマが常に "type" を持つことが保証されていたので処理を簡単にするために "type" を必須とみなしています。

"type" をみてスキーマの型を判別したあとはデータがその型に合うかチェックし、型別に他のチェックも行うという感じです。
今回は実装してないですが文字列のpatterや数値のrangeをチェックするならここかなと思います。

```powershell
if ($Data -isnot [string]) {
  $ErrList.Value += "${ErrorPrefix}Value is `"${Type}`" but should be `"string`" at '${Path}'"
}
```

そしてobjectまたはarrayの場合は子要素に対して再帰的に Invoke-RecursiveCheck を実行してチェックしています。

```powershell
# [配列の場合の処理]
# 1. スキーマにitemsがあれば
if ("items" -cin $Schema.Keys) {
  # 2. 配列型のdataの各要素に対して
  $Data | % { $i = 0 } {
    # 3. itemsのスキーマ定義で再帰的にチェックする
    Invoke-RecursiveCheck `
      -Schema $Schema.items `
      -Data $_  `
      -Path "${Path}/${i}" `
      -ErrList $ErrList
    $i += 1
  }
}
```

## 参照渡し

Invoke-RecursiveCheck を再帰実行する中ででてくるエラーを Test-Json で最後にまとめて投げるために ErrList を参照型変数で渡すことで引き継いでいます。

```powershell
$ErrList = [ref] @()
Invoke-RecursiveCheck `
  -Schema $SchemaData `
  -Data $JsonData `
  -Path '' `
  -ErrList $ErrList
```

`[ref]` はオブジェクトを参照渡し用にラップする感じで、参照する際はValueプロパティを使って中身の配列にアクセスします。

```powershell
# 書き込み
$ErrList.Value += "message"

# 読み取り
$ErrList.Value | % { Write-Error -Message $_ }
```

ただまあ、こんな仕組みにしなくても Invoke-RecursiveCheck の中でそのまま Write-Error しちゃえば十分そうな気もしてきました...

## Test-Jsonの引数パターン

引数の名前やパターン、パイプでの受け取りや位置パラメータ諸々は本物のTest-Jsonコマンドレットに寄せてつくってみました。まあここをこだわる意味はなかったんですけども。

```powershell
[OutputType('System.Boolean')]
[CmdletBinding(DefaultParameterSetName = "JsonSet")]
Param(
  [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "JsonSet")]
  [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "JsonSchemaSet")]
  [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "JsonSchemaFileSet")]
  [string] $Json,

  [Parameter(Mandatory = $true, ParameterSetName = "PathSet")]
  [Parameter(Mandatory = $true, ParameterSetName = "PathSchemaSet")]
  [Parameter(Mandatory = $true, ParameterSetName = "PathSchemaFileSet")]
  [string] $Path,

  [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSet")]
  [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSchemaSet")]
  [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSchemaFileSet")]
  [string] $LiteralPath,

  [Parameter(Mandatory = $true, ParameterSetName = "JsonSchemaSet")]
  [Parameter(Mandatory = $true, ParameterSetName = "PathSchemaSet")]
  [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSchemaSet")]
  [string] $Schema,

  [Parameter(Mandatory = $true, ParameterSetName = "JsonSchemaFileSet")]
  [Parameter(Mandatory = $true, ParameterSetName = "PathSchemaFileSet")]
  [Parameter(Mandatory = $true, ParameterSetName = "LiteralPathSchemaFileSet")]
  [string] $SchemaFile
)
```

ざっと説明すると

* `OutputType` で戻り値がBoolean型であることを明示しています。
* `CmdletBinding` でErrorVariableやDebugなどの基本パラメータも使用できるようにしています。
* Jsonは `ValueFromPipeline = $true` でパイプ受け取り可能にしています。
* Jsonは `Position = 0` でパラメータ名の指定なしでも受け取れるようにしています。
* `ParameterSetName` で引数の指定パターンを制限していて、例えばJsonとPathの同時指定ができないようにしています。

# おわりに

いかがでしたか？

正直この記事を書いている途中で「イケてないかも？」と思うことが何度かあったのでもっと洗練できるかと思います。

ただ Test-Json をがっつり使い倒しているとかでなければ今回のような実装で十分代替できると思うので、もし自分と同じように移植に悩んでいる方がいればよければ当記事を参考にしてみてください。

最後まで読んでいただきありがとうございました。