diff --git a/json_validator.py b/json_validator.py new file mode 100644 index 0000000..9dd0ff7 --- /dev/null +++ b/json_validator.py @@ -0,0 +1,244 @@ +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}")