Newer
Older
json-validator / json_validator.py
Nomura Kei 27 days ago 9 KB UPDATE
import re
import sys
from typing import Callable, Dict, List, Tuple


class JsonValidationError(Exception):
    """Json データの比較に失敗した場合に発生する例外クラス"""

    def __init__(self, message: str) -> None:
        super().__init__(message)
        self.message = message


class JsonValidator:
    """Json データの比較を行うクラス"""

    SPECIAL_COMPARE_PATTERN = r"^(#.*)\((.*)\)$"
    """ 特殊比較パターン。 """

    def __init__(self, expected: Dict | List) -> None:
        """指定された JSON (辞書型) と合致するか否かを検証するバリデータを構築します。
        Args:
            expected (Dict | List): 期待する JSON
        """
        self.expected = expected
        self.allow_extra_keys = False

        self.special_compare_handlers: Dict[str, Callable] = {
            "#REGEX": self._compare_value_as_regex,
            "#RANGE": self._compare_value_as_range,
        }

    def is_valid(self, actual: Dict | List) -> Tuple[bool, str]:
        try:
            self.validate(actual)
            return True, "成功"
        except JsonValidationError as e:
            return False, e.message

    def validate(self, actual: Dict | List) -> None:
        """指定された JSON データが期待するデータと一致するか検証します。

        Args:
            actual (Dict | List): JSON データ(辞書型またはリスト)

        Raises:
            JsonValidationError: 検証NGの場合に発生します。
        """
        self._compare(self.expected, actual)

    def _compare(self, expected: Dict | List, actual: Dict | List) -> None:
        """指定されたデーさを比較し、一致するか検証します。

        Args:
            expected (Dict | List): 期待する JSON データ(辞書型またはリスト)
            actual (Dict | List): 実際の JSON データ(辞書型またはリスト)

        Raises:
            JsonValidationError: 検証NGの場合に発生します。
        """
        if isinstance(expected, Dict):
            self._compare_dict(expected, actual)
        elif isinstance(expected, List):
            self._compare_list(expected, actual)
        else:
            self._compare_value(expected, actual)

    def _compare_dict(self, expected: Dict, actual: Dict | List) -> None:
        """指定された辞書を比較します。

        Args:
            expected (Dict): 期待する辞書
            actual (Dict | List): 実際のデータ

        Raises:
            JsonValidationError: 検証NGの場合に発生します。
        """
        if isinstance(actual, List):
            # 辞書を期待するが、リストの場合、検証NG
            # raise JsonValidationError(f"Expected dict, Actual list: {actual}")
            raise JsonValidationError(
                f"辞書型を期待しているが、リスト型になっています。Actual: {actual}"
            )

        for key, value in expected.items():
            if key not in actual:
                # raise JsonValidationError(f"Expected key: {key}, Actual: {actual.keys()}")
                raise JsonValidationError(
                    f"期待するキー ({key}) がありません。Actual: {actual.keys()}"
                )
            self._compare(value, actual[key])

        if not self.allow_extra_keys:
            # 追加キーが許容されない場合、追加キーがないか確認する。
            expected_keys = set(expected.keys())
            actual_keys = set(actual.keys())
            if not actual_keys.issubset(expected_keys):
                # 実際の辞書が、期待する辞書に含まれていなきキーがあるため検証NG
                diff_keys = actual_keys - expected_keys
                # raise JsonValidationError(f"Unexpected keys: {diff_keys}")
                raise JsonValidationError(f"不要なキーがあります。 不要なキー: {diff_keys}")

    def _compare_list(self, expected: List, actual: Dict | List) -> None:
        """指定されたリストを比較します。

        Args:
            expected (List): 期待するリスト
            actual (Dict | List): 実際のデータ

        Raises:
            JsonValidationError: 検証NGの場合に発生します。
        """
        if isinstance(actual, Dict):
            # リストを期待するが、辞書の場合、検証NG
            # raise JsonValidationError(f"Expected list, Actual dict: {actual}")
            raise JsonValidationError(
                f"リストを期待していますが、辞書型になっています。 Actual: {actual}"
            )

        if len(expected) != len(actual):
            # リストサイズが異なるため検証NG
            # raise JsonValidationError(
            #     f"Expected length: {len(expected)}, Actual length: {len(actual)}"
            # )
            raise JsonValidationError(
                f"リストのサイズが異なります。(期待するサイズ: {len(expected)}, 実際のサイズ: {len(actual)})"
            )

        for expected_item, actual_item in zip(expected, actual):
            # リスト中のアイテムが一致するか検証
            self._compare(expected_item, actual_item)

    def _compare_value(
        self,
        expected: str | int | float | bool | None,
        actual: str | int | float | bool | None,
    ) -> None:

        # 特殊比較
        if isinstance(expected, str):
            match = re.search(JsonValidator.SPECIAL_COMPARE_PATTERN, expected)
            if match:
                key = match.group(1)
                temp_args = match.group(2).split(",")
                args = list(map(str.strip, temp_args))
                self.special_compare_handlers[key](args, actual)
                return

        # 通通比較
        if expected != actual:
            # raise JsonValidationError(f"Expected: {expected}, Actual: {actual}")
            raise JsonValidationError(
                f"値が異なります。(期待する値: {expected}, 実際の値: {actual}"
            )

    def _compare_value_as_regex(
        self, expected: List[str], actual: str | int | float | bool | None
    ) -> None:
        """指定された値が正規表現に合致するか検証します。

        Args:
            expected: 期待する値(正規表現)
            actual: 実際の値

        Raises:
            JsonValidationError: 検証NGの場合に発生します。
        """
        pattern: str = expected[0] if len(expected) >= 1 else ""
        if not re.match(pattern, str(actual)):
            # raise JsonValidationError(f"Expected: {expected}, Actual: {actual}")
            raise JsonValidationError(
                f"正規表現にマッチしません。(期待する正規表現: {pattern}, 実際の値: {actual})"
            )

    def _compare_value_as_range(
        self, expected: List[str], actual: str | int | float | bool | None
    ) -> None:
        """指定された値が、指定範囲内に入っているか検証します。

        Args:
            expected: 期待する値(範囲)
            actual: 実際の値

        Raises:
            JsonValidationError: 検証NGの場合に発生します。
        """
        if not isinstance(actual, (int, float)):
            # raise JsonValidationError(f"Expected: number, Actual: {actual}")
            raise JsonValidationError(
                f"数値を期待していますが、数値型になっていません。 (実際の値:{actual})"
            )

        if len(expected) != 2:
            # raise JsonValidationError(f"Expected: #RANGE(min,max), Actual: {expected}")
            raise JsonValidationError(
                f"期待する値の指定が誤っています。#RANGE(min,max) 形式で指定ください。(指定された期待する値: {expected})"
            )

        min_value = float(expected[0]) if expected[0] else -sys.float_info.max
        max_value = float(expected[1]) if expected[1] else sys.float_info.max
        actual_value = float(actual)

        if (min_value <= actual_value) and (actual_value <= max_value):
            # 範囲内
            pass
        else:
            # 範囲外
            # raise JsonValidationError(
            #     f"Expected: {min_value} - {max_value}, Actual: {actual_value}"
            # )
            raise JsonValidationError(
                f"値が範囲外です。(期待する範囲: {expected[0]}~{expected[1]}, 実際の値: {actual})"
            )


expected = {
    "name": "John Doe",
    "age": "#RANGE(20,30)",
    "email": "#REGEX(^[a-zA-Z0-9]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$)",
    "address": {
        "street": "123 Main St",
        "city": "Anytown",
        "state": "CA",
        "zip": "#RANGE(10,)",
    },
}

actual = {
    "name": "John Doe",
    "age": 25,
    "email": "H0Pv2@example.com",
    "address": {
        "street": "123 Main St",
        "city": "Anytown",
        "state": "CA",
        "zip": 123456,
    },
}


validator = JsonValidator(expected)
result, detail = validator.is_valid(actual)

print(f"{result}, {detail}")