diff --git a/changelogs/fragments/validate_jsonschema_update.yaml b/changelogs/fragments/validate_jsonschema_update.yaml new file mode 100644 index 0000000..1f449b2 --- /dev/null +++ b/changelogs/fragments/validate_jsonschema_update.yaml @@ -0,0 +1,5 @@ +--- +minor_changes: + - validate - Add support for JSON Schema draft 2019-09 and 2020-12 as well as automatically choosing the draft + from the `$schema` field of the criteria. + - validate - Add option `check_format` for the jsonschema engine to disable JSON Schema format checking. diff --git a/galaxy.yml b/galaxy.yml index 3810cea..06fe616 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -13,4 +13,4 @@ issues: https://github.com/ansible-collections/ansible.utils/issues tags: [linux, networking, security, cloud, utilities, data, validation, utils] # NOTE(pabelanger): We create an empty version key to keep ansible-galaxy # happy. We dynamically inject version info based on git information. -version: 2.9.1-dev +version: 2.10.0-dev diff --git a/plugins/sub_plugins/validate/jsonschema.py b/plugins/sub_plugins/validate/jsonschema.py index 4a2274e..1fda0af 100644 --- a/plugins/sub_plugins/validate/jsonschema.py +++ b/plugins/sub_plugins/validate/jsonschema.py @@ -22,17 +22,29 @@ DOCUMENTATION = """ description: - This option provides the jsonschema specification that should be used for the validating the data. The I(criteria) option in the validate - plugin should follow the specification as mentioned by this option - default: draft7 + plugin should follow the specification as mentioned by this option. + If this option is not specified, jsonschema will use the best validator + for the I($schema) field in the criteria. Specifications 2019-09 and + 2020-12 are only available from jsonschema version 4.0 onwards. choices: - draft3 - draft4 - draft6 - draft7 + - 2019-09 + - 2020-12 env: - name: ANSIBLE_VALIDATE_JSONSCHEMA_DRAFT vars: - name: ansible_validate_jsonschema_draft + check_format: + description: If enabled, validate the I(format) specification in the criteria. + type: bool + default: true + env: + - name: ANSIBLE_VALIDATE_JSONSCHEMA_CHECK_FORMAT + vars: + - name: ansible_validate_jsonschema_check_format notes: - The value of I(data) option should be either a valid B(JSON) object or a B(JSON) string. - The value of I(criteria) should be B(list) of B(dict) or B(list) of B(strings) and each @@ -45,11 +57,14 @@ from ansible.errors import AnsibleError from ansible.module_utils._text import to_text from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.six import string_types +from ansible.utils.display import Display from ansible_collections.ansible.utils.plugins.module_utils.common.utils import to_list from ansible_collections.ansible.utils.plugins.plugin_utils.base.validate import ValidateBase +display = Display() + # PY2 compatibility for JSONDecodeError try: from json.decoder import JSONDecodeError @@ -58,6 +73,7 @@ except ImportError: try: import jsonschema + import jsonschema.validators HAS_JSONSCHEMA = True except ImportError: @@ -79,6 +95,34 @@ def json_path(absolute_path): class Validate(ValidateBase): + # All available schema versions with the format_check and validator class names. + _JSONSCHEMA_DRAFTS = { + "draft3": { + "validator_name": "Draft3Validator", + "format_checker_name": "draft3_format_checker", + }, + "draft4": { + "validator_name": "Draft4Validator", + "format_checker_name": "draft4_format_checker", + }, + "draft6": { + "validator_name": "Draft6Validator", + "format_checker_name": "draft6_format_checker", + }, + "draft7": { + "validator_name": "Draft7Validator", + "format_checker_name": "draft7_format_checker", + }, + "2019-09": { + "validator_name": "Draft201909Validator", + "format_checker_name": "draft201909_format_checker", + }, + "2020-12": { + "validator_name": "Draft202012Validator", + "format_checker_name": "draft202012_format_checker", + }, + } + @staticmethod def _check_reqs(): """Check the prerequisites are installed for jsonschema @@ -126,6 +170,28 @@ class Validate(ValidateBase): ) raise AnsibleError(msg) + def _check_drafts(self): + """For every possible draft check if our jsonschema version supports it and exchange the class names with + the actual classes. If it is not supported the draft is removed from the list. + """ + for draft in list(self._JSONSCHEMA_DRAFTS.keys()): + draft_config = self._JSONSCHEMA_DRAFTS[draft] + try: + validator_class = getattr(jsonschema, draft_config["validator_name"]) + except AttributeError: + display.vvv( + 'jsonschema draft "{draft}" not supported in this version'.format(draft=draft), + ) + del self._JSONSCHEMA_DRAFTS[draft] + continue + draft_config["validator"] = validator_class + try: + format_checker_class = validator_class.FORMAT_CHECKER + except AttributeError: + # Older jsonschema version + format_checker_class = getattr(jsonschema, draft_config["format_checker_name"]) + draft_config["format_checker"] = format_checker_class + def validate(self): """Std entry point for a validate execution @@ -141,6 +207,7 @@ class Validate(ValidateBase): """ self._check_reqs() self._check_args() + self._check_drafts() try: self._validate_jsonschema() @@ -153,30 +220,45 @@ class Validate(ValidateBase): error_messages = None draft = self._get_sub_plugin_options("draft") + check_format = self._get_sub_plugin_options("check_format") error_messages = [] for criteria in self._criteria: - if draft == "draft3": - validator = jsonschema.Draft3Validator( - criteria, - format_checker=jsonschema.draft3_format_checker, - ) - elif draft == "draft4": - validator = jsonschema.Draft4Validator( - criteria, - format_checker=jsonschema.draft4_format_checker, - ) - elif draft == "draft6": - validator = jsonschema.Draft6Validator( - criteria, - format_checker=jsonschema.draft6_format_checker, - ) - else: - validator = jsonschema.Draft7Validator( - criteria, - format_checker=jsonschema.draft7_format_checker, - ) + format_checker = None + validator_class = None + if draft is not None: + try: + validator_class = self._JSONSCHEMA_DRAFTS[draft]["validator"] + except KeyError: + display.warning( + 'No validator available for "{draft}", falling back to autodetection. A newer version of jsonschema might support this draft.'.format( + draft=draft, + ), + ) + if validator_class is None: + # Either no draft was specified or specified draft has no validator class + # in installed jsonschema version. Do autodetection instead. + validator_class = jsonschema.validators.validator_for(criteria) + if check_format: + try: + format_checker = validator_class.FORMAT_CHECKER + except AttributeError: + # TODO: Remove when Python 3.6 support is dropped. + # On jsonschema<4.5, there is no connection between a validator and the correct format checker. + # So we iterate through our known list of validators and if one matches the current class + # we use the format_checker from that validator. + for draft, draft_config in self._JSONSCHEMA_DRAFTS.items(): + if validator_class == draft_config["validator"]: + display.vvv( + "Using format_checker for {draft} validator".format(draft=draft), + ) + format_checker = draft_config["format_checker"] + break + else: + display.warning("jsonschema format checks not available") + + validator = validator_class(criteria, format_checker=format_checker) validation_errors = sorted(validator.iter_errors(self._data), key=lambda e: e.path) if validation_errors: diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt index 118f11d..529a414 100644 --- a/tests/integration/requirements.txt +++ b/tests/integration/requirements.txt @@ -1,4 +1,4 @@ -jsonschema ; python_version >= '2.7' +jsonschema netaddr ttp textfsm diff --git a/tests/integration/targets/utils_validate/tests/jsonschema/filter.yaml b/tests/integration/targets/utils_validate/tests/jsonschema/filter.yaml index 5526dcf..e67522c 100644 --- a/tests/integration/targets/utils_validate/tests/jsonschema/filter.yaml +++ b/tests/integration/targets/utils_validate/tests/jsonschema/filter.yaml @@ -25,7 +25,7 @@ - ansible.builtin.assert: that: - - "'value of draft must be one of: draft3, draft4, draft6, draft7, got: draft0' in result.msg" + - "'value of draft must be one of: draft3, draft4, draft6, draft7, 2019-09, 2020-12, got: draft0' in result.msg" - name: invalid engine value ansible.builtin.set_fact: diff --git a/tests/integration/targets/utils_validate/tests/jsonschema/lookup.yaml b/tests/integration/targets/utils_validate/tests/jsonschema/lookup.yaml index 4304a25..c20f961 100644 --- a/tests/integration/targets/utils_validate/tests/jsonschema/lookup.yaml +++ b/tests/integration/targets/utils_validate/tests/jsonschema/lookup.yaml @@ -31,7 +31,7 @@ - ansible.builtin.assert: that: - - "'value of draft must be one of: draft3, draft4, draft6, draft7, got: draft0' in result.msg" + - "'value of draft must be one of: draft3, draft4, draft6, draft7, 2019-09, 2020-12, got: draft0' in result.msg" - name: test invalid plugin configuration option, passed as task varaible ansible.builtin.set_fact: @@ -43,7 +43,7 @@ - ansible.builtin.assert: that: - - "'value of draft must be one of: draft3, draft4, draft6, draft7, got: draft0' in result.msg" + - "'value of draft must be one of: draft3, draft4, draft6, draft7, 2019-09, 2020-12, got: draft0' in result.msg" - name: invalid engine value ansible.builtin.set_fact: diff --git a/tests/integration/targets/utils_validate/tests/jsonschema/module.yaml b/tests/integration/targets/utils_validate/tests/jsonschema/module.yaml index 17391af..b26a0ce 100644 --- a/tests/integration/targets/utils_validate/tests/jsonschema/module.yaml +++ b/tests/integration/targets/utils_validate/tests/jsonschema/module.yaml @@ -52,7 +52,7 @@ that: - "'errors' not in result" - "result['failed'] == true" - - "'value of draft must be one of: draft3, draft4, draft6, draft7, got: draft0' in result.msg" + - "'value of draft must be one of: draft3, draft4, draft6, draft7, 2019-09, 2020-12, got: draft0' in result.msg" - name: invalid engine value ansible.utils.validate: diff --git a/tests/integration/targets/utils_validate/tests/jsonschema/test.yaml b/tests/integration/targets/utils_validate/tests/jsonschema/test.yaml index 2808398..882ab43 100644 --- a/tests/integration/targets/utils_validate/tests/jsonschema/test.yaml +++ b/tests/integration/targets/utils_validate/tests/jsonschema/test.yaml @@ -23,7 +23,7 @@ - ansible.builtin.assert: that: - - "'value of draft must be one of: draft3, draft4, draft6, draft7, got: draft0' in result.msg" + - "'value of draft must be one of: draft3, draft4, draft6, draft7, 2019-09, 2020-12, got: draft0' in result.msg" - name: invalid engine value ansible.builtin.set_fact: diff --git a/tests/unit/plugins/action/test_validate.py b/tests/unit/plugins/action/test_validate.py index 3b2fa3e..0176228 100644 --- a/tests/unit/plugins/action/test_validate.py +++ b/tests/unit/plugins/action/test_validate.py @@ -186,7 +186,7 @@ class TestValidate(unittest.TestCase): result = self._plugin.run(task_vars={"ansible_validate_jsonschema_draft": "draft0"}) self.assertIn( - "value of draft must be one of: draft3, draft4, draft6, draft7, got: draft0", + "value of draft must be one of: draft3, draft4, draft6, draft7, 2019-09, 2020-12, got: draft0", result["msg"], ) @@ -285,3 +285,15 @@ class TestValidate(unittest.TestCase): result = self._plugin.run(task_vars=None) self.assertIn("Validation errors were found", result["msg"]) + + def test_support_for_disabled_format_with_invalid_data(self): + """Check passing valid data as per criteria""" + + self._plugin._task.args = { + "engine": "ansible.utils.jsonschema", + "data": IN_VALID_DATA, + "criteria": CRITERIA_FORMAT_SUPPORT_CHECK, + } + + result = self._plugin.run(task_vars=dict(ansible_validate_jsonschema_check_format=False)) + self.assertIn("All checks passed", result["msg"]) diff --git a/tests/unit/plugins/filter/test_validate.py b/tests/unit/plugins/filter/test_validate.py index f9a5c01..6082d2e 100644 --- a/tests/unit/plugins/filter/test_validate.py +++ b/tests/unit/plugins/filter/test_validate.py @@ -150,7 +150,7 @@ class TestValidate(unittest.TestCase): with self.assertRaises(AnsibleFilterError) as error: validate(*args, **kwargs) self.assertIn( - "value of draft must be one of: draft3, draft4, draft6, draft7, got: draft0", + "value of draft must be one of: draft3, draft4, draft6, draft7, 2019-09, 2020-12, got: draft0", str(error.exception), ) diff --git a/tests/unit/plugins/test/test_validate.py b/tests/unit/plugins/test/test_validate.py index 6338de6..f70125e 100644 --- a/tests/unit/plugins/test/test_validate.py +++ b/tests/unit/plugins/test/test_validate.py @@ -148,7 +148,7 @@ class TestValidate(unittest.TestCase): with self.assertRaises(AnsibleError) as error: validate(*args, **kwargs) self.assertIn( - "value of draft must be one of: draft3, draft4, draft6, draft7, got: draft0", + "value of draft must be one of: draft3, draft4, draft6, draft7, 2019-09, 2020-12, got: draft0", str(error.exception), ) diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt index 7280dcf..90689f5 100644 --- a/tests/unit/requirements.txt +++ b/tests/unit/requirements.txt @@ -1,4 +1,4 @@ -jsonschema ; python_version >= '2.7' +jsonschema netaddr textfsm ttp