「YAMLをJSONへ安全に変換する設計|暗黙型・重複key・alias・Python」の内容を表す技術イラスト
UNIT 00LAB NOTES FIG.00 · EYECATCH

YAMLをJSONへ安全に変換する設計|暗黙型・重複key・alias・Python

YAMLをJSONへ安全に変換する設計を解説。暗黙型、重複key、非文字列key、anchor・alias、循環参照、NaN、Python実装、schema検証とversion管理まで整理します。

DOC NO.EKP-00-1163
DATE
REVB
READ約 13 分
目次

YAMLを読めてもJSONへ安全に渡せるとは限らない

YAMLは設定ファイルや人が編集するデータに向き、JSONはAPIやDB連携で広く使われます。そのため「YAMLで入力し、JSONへ変換して他systemへ渡す」構成は便利です。しかし、構文解析に成功しただけでは安全な変換になりません。

YAMLには暗黙の型解決、anchorとalias、任意のmapping key、tagなど、JSONより広い表現があります。parserによってはyesが真偽値、2026-10-23が日付object、0012が整数として読み込まれます。同じkeyを二度書いたとき、後の値で上書きする実装もあります。

変換境界では、YAMLの自由度をそのままJSONへ持ち込まず、受理する型と構造を明示的に狭める必要があります。この記事では、YAML 1.2.2のdata modelを踏まえ、PythonとPyYAMLで重複key、非文字列key、循環alias、非有限数を拒否する設計を作ります。

YAMLとJSONのdata modelを対応させる

YAMLの基本nodeはscalar、sequence、mappingです。JSONへ対応させると次のようになります。

YAMLJSON変換時の契約
scalarstring、number、boolean、null暗黙型と対応可能な型を確認する
sequencearray順序、要素型、最大件数を決める
mappingobjectkeyをstringに限定し、重複を拒否する
anchor/alias直接対応なし共有参照を展開し、循環を拒否する
tag直接対応しないものがある許可する標準tagだけを受理する

YAMLのmapping keyは任意nodeになれますが、相互運用するJSON objectのmember名は文字列として扱うのが基本です。数値keyを自動的に文字列へ変えると、1と"1"が衝突する可能性があります。変換時に黙って補正せず、文字列以外のkeyをerrorにします。

最小データと期待するJSON

部品設定を次のYAMLで受け取るとします。

schema_version: "1.0"
part_id: "0012"
active: true
mass_kg: 2.75
released_at: null
tags:
  - prototype
  - hydraulic
material:
  material_id: AL-6061
  name: Aluminum alloy

JSON出力は次の形へ固定します。

{
  "active": true,
  "mass_kg": 2.75,
  "material": {
    "material_id": "AL-6061",
    "name": "Aluminum alloy"
  },
  "part_id": "0012",
  "released_at": null,
  "schema_version": "1.0",
  "tags": [
    "prototype",
    "hydraulic"
  ]
}

part_idは計算対象ではないためstringです。released_atのNULLとfield未指定も区別します。YAMLで引用符を外した値がparserによって別型へ解決されないよう、IDやversionは明示的に引用し、変換後もschemaで型を検査します。

暗黙型を入力契約へ持ち込まない

plain scalarには引用符がなく、loaderのresolverが型を決めます。YAML 1.2.2にはfailsafe、JSON、coreなどのschemaがありますが、libraryが採用する規則は実装とversionで確認が必要です。

PyYAML 6.0.3のSafeLoaderでは、たとえばyesやonがbool、2026-10-23がdatetime.date、0012が整数へ変換されます。これらはYAML 1.2のcore schemaだけを想定した結果と一致しない場合があります。

対策は「見た目で型を推測する」のではなく、次の二段階に分けることです。

  • YAML loaderが返したPython型をJSON互換型へ限定する
  • JSON Schemaなどでfieldごとの期待型、必須、pattern、範囲を検証する

日時objectを自動的にISO文字列へ変換すると、timezoneや精度の判断が隠れます。変換層では未対応型として拒否し、入力を引用したRFC 3339文字列へ直すなど、field契約側で解決します。

重複keyは上書きせず拒否する

YAML 1.2.2のmapping keyは一意である必要があります。それでもlibraryによっては次の入力を{"mass_kg": 3.0}として読み込み、最初の値を失います。

part_id: P-001
mass_kg: 2.5
mass_kg: 3.0

人は最初の値を見て承認し、programは最後の値を使うかもしれません。設定、権限、単位、送信先のようなfieldでは危険です。重複をwarningで済ませず、pathとkeyを示して入力全体を拒否します。

anchorとaliasはtreeへ収まるか確認する

anchorとaliasは同じnodeを再利用できます。重複記述を減らせますが、YAMLは循環するgraphも表現できます。

loop: &loop
  self: *loop

JSONはobjectとarrayを入れ子にしたtreeとして交換するため、この循環を直列化できません。また、小さな入力から非常に大きな出力へ展開される構造はmemoryを消費します。

入力byte数だけでなく、alias数、変換後node数、nesting深さにも上限を設けます。共有参照は通常のobjectやarrayとして展開できますが、循環を検出したらerrorにします。

安全な入力からJSON出力までの流れ

  • UTF-8で復号し、入力byte数を制限する
  • SafeLoader系で任意のPython object生成を禁止する
  • mappingの重複keyと非文字列keyを拒否する
  • alias数、循環参照、出力node数を検査する
  • JSON互換型だけへ正規化する
  • JSON Schemaなどで業務schemaを検証する
  • allow_nan=FalseでJSONを直列化する
  • hash、schema version、変換器versionを記録する

safe_loadは任意classの生成を抑える重要な境界ですが、data量、業務上の型、重複keyまで自動的に保証するものではありません。複数の制約を重ねます。

Pythonで厳格な変換器を実装する

次の例は、PyYAMLのSafeLoaderを基に重複keyと非文字列keyを拒否します。さらに、循環alias、非有限数、未対応型、過大な出力を検出してからJSONへ直列化します。

from __future__ import annotations

import json
import math

import yaml
from yaml.constructor import ConstructorError
from yaml.nodes import MappingNode


class StrictJSONLoader(yaml.SafeLoader):
    pass


def construct_unique_mapping(
    loader: StrictJSONLoader,
    node: MappingNode,
    deep: bool = False,
) -> dict[str, object]:
    mapping: dict[str, object] = {}
    for key_node, value_node in node.value:
        key = loader.construct_object(key_node, deep=deep)
        if not isinstance(key, str):
            raise ConstructorError(
                None, None, "mapping keys must be strings", key_node.start_mark
            )
        if key in mapping:
            raise ConstructorError(
                None, None, f"duplicate key: {key}", key_node.start_mark
            )
        mapping[key] = loader.construct_object(value_node, deep=deep)
    return mapping


StrictJSONLoader.add_constructor(
    yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
    construct_unique_mapping,
)


def normalize_json_value(
    value: object,
    path: str = "$",
    active: set[int] | None = None,
    budget: list[int] | None = None,
) -> object:
    active = set() if active is None else active
    budget = [10_000] if budget is None else budget
    budget[0] -= 1
    if budget[0] < 0:
        raise ValueError("output node limit exceeded")

    if value is None or isinstance(value, (str, bool, int)):
        return value
    if isinstance(value, float):
        if not math.isfinite(value):
            raise ValueError(f"non-finite number at {path}")
        return value

    if isinstance(value, (list, dict)):
        object_id = id(value)
        if object_id in active:
            raise ValueError(f"circular alias at {path}")
        active.add(object_id)
        try:
            if isinstance(value, list):
                return [
                    normalize_json_value(item, f"{path}[{index}]", active, budget)
                    for index, item in enumerate(value)
                ]
            return {
                key: normalize_json_value(item, f"{path}.{key}", active, budget)
                for key, item in value.items()
            }
        finally:
            active.remove(object_id)

    raise TypeError(f"unsupported type at {path}: {type(value).__name__}")


def yaml_to_json(text: str, max_bytes: int = 1_000_000) -> str:
    if len(text.encode("utf-8")) > max_bytes:
        raise ValueError("input size limit exceeded")

    alias_count = sum(
        isinstance(event, yaml.events.AliasEvent)
        for event in yaml.parse(text, Loader=StrictJSONLoader)
    )
    if alias_count > 50:
        raise ValueError("alias limit exceeded")

    loaded = yaml.load(text, Loader=StrictJSONLoader)
    normalized = normalize_json_value(loaded)
    return json.dumps(
        normalized,
        ensure_ascii=False,
        allow_nan=False,
        sort_keys=True,
        indent=2,
    )

このloaderはSafeLoaderの許可範囲を継承するため、任意のPython classを生成する通常のLoaderは使いません。独自tagやmerge keyも無条件に許可せず、必要なら仕様を定義して個別に追加します。

JSONのallow_nan=FalseはNaN、Infinity、-Infinityを拒否します。ensure_ascii=Falseは日本語をそのまま出力しますが、file自体のencodingはUTF-8に固定します。

正常系と異常系を固定testにする

最低限、次のcaseを自動testへ入れます。

  • 正常なmapping、sequence、scalarを変換できる
  • 同じkeyの二重定義を拒否する
  • 数値key、boolean key、複合keyを拒否する
  • yes、on、日付、先頭ゼロIDの実際の型を確認する
  • null、空文字、field未指定を区別する
  • NaNと無限大を拒否する
  • 循環aliasとalias上限超過を拒否する
  • 複数documentを単一recordとして受理しない
  • 入力byte数と出力node数の上限を確認する
  • 変換後のJSON Schema検証が成功する

errorには公開用code、YAML上の位置、JSON pathを分けて持たせます。入力全文をlogへ出すとAPI keyや個人情報が漏れるため、秘密fieldをmaskし、原文はアクセス制御された隔離領域へ保存します。

冪等性とversion管理

同じ入力と同じ変換器versionから同じJSONを得るには、key順、encoding、改行、数値規則を固定します。例ではsort_keys=Trueを使っていますが、これは同一実装内の比較を安定させる設定であり、system間で使える完全なJSON正規化仕様ではありません。署名や共通hashが必要なら、別途合意したcanonicalizationを使います。

manifestには入力hash、出力hash、YAML parser version、変換規則version、業務schema versionを記録します。規則を変更したときは過去dataを黙って再解釈せず、新versionとして再変換します。

DB・API・CADへつなげる設計

変換後JSONは、保存前に業務schemaを検証します。IDはstring、日時はtimezone付き文字列、数量はvalueとunit、真偽値はbooleanへ固定します。DBではNOT NULL、CHECK、外部キー、一意制約を最終防衛線にします。

CADやBOMでは、部品ID、revision、単位、座標系、材質IDを明示します。YAMLのcommentやanchor名はJSONへ残らないため、業務上必要な説明や来歴をcommentだけに置かず、fieldとしてdata化します。

公開用設定と秘密設定も分離します。外部へ渡すJSONにcredential、内部path、原価、個人情報を混ぜず、secret managerや権限の異なる保存領域から実行時に注入します。

まとめ

YAMLからJSONへの変換で重要なのは、syntaxを置き換えることではなく、表現範囲を安全に狭めることです。

  • SafeLoader系で任意object生成を防ぐ
  • 暗黙型を確認し、field schemaで期待型を固定する
  • 重複keyと非文字列keyを拒否する
  • aliasの循環と展開量を制限する
  • NULL、空文字、未指定、ID、日時、単位を区別する
  • JSON SchemaとDB制約で業務上の意味を検証する
  • parserと変換規則をversion管理する
  • 秘密情報と公開dataを分離する

入力、変換規則、出力schema、test、manifestを一緒に管理すれば、人が編集しやすいYAMLを、API・DB・CADで再利用できる安定したJSONへ接続できます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次