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へ対応させると次のようになります。
| YAML | JSON | 変換時の契約 |
|---|---|---|
| scalar | string、number、boolean、null | 暗黙型と対応可能な型を確認する |
| sequence | array | 順序、要素型、最大件数を決める |
| mapping | object | keyを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へ接続できます。
