Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 147 #148

Draft
wants to merge 6 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ WORKDIR /local
# Poetry fails install without README.md being copied.
COPY pyproject.toml poetry.lock README.md /local/
COPY schema_enforcer /local/schema_enforcer
COPY tests/ /local/tests/

RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi
Expand Down
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,63 @@ pip install 'jsonschema[rfc3987]'

See the "Validating Formats" section in the [jsonschema documentation](https://github.com/python-jsonschema/jsonschema/blob/main/docs/validate.rst) for more information.

### Custom Error Message Support

Schema enforcer is able to handle and return custom error messages that are defined in the JSON Schema itself using reserved keywords. These custom error messages override the default messages returned by JSON Schema. For example consider the following JSON Schema:

```
type: object
additionalProperties: false
properties:
data:
type: object
properties:
name:
$ref: "#/$defs/regex_sub_schema"
aliases:
type: array
items:
$ref: "#/$defs/regex_sub_schema"

$defs:
regex_sub_schema:
type: string
pattern: regex
err_message: "'$instance' is not valid. Please see docs."
```
When validating against test data with invalid entries for `name` and `aliases`, the custom error is returned:

```
FAIL | [ERROR] 'host' is not valid. Please see docs. [FILE] .//test.yml [PROPERTY] data:name
FAIL | [ERROR] 'alias-1' is not valid. Please see docs. [FILE] .//test.yml [PROPERTY] data:aliases:0
FAIL | [ERROR] 'alias-2' is not valid. Please see docs. [FILE] .//test.yml [PROPERTY] data:aliases:1
```
The error messages returned contain the instance data that was being validated at the time of failure. This is achieved by using the `$instance` variable within the custom error message. All occurences of this variable will be replaced with the instance data being validated.

Please bear in mind that custom errors defined at the `root` level of the schema are ignored. For example if we add a custom error at the root level:

```
type: object
additionalProperties: false
err_message: This error message will be ignored.
properties:
data:
[...]
```
Then validate against data with an additional property at the root level, for example:
```
data:
name: host
aliases:
- etc...
data2:
name: host2
```
The error returned will be the default JSON Schema error:
```
FAIL | [ERROR] Additional properties are not allowed ('data2' was unexpected) [FILE] .//test.yml [PROPERTY]
```

### Where To Go Next

Detailed documentation can be found in the README.md files inside of the `docs/` directory.
Expand Down
9 changes: 8 additions & 1 deletion schema_enforcer/schemas/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ def validate(self, data, strict=False):
for err in validator.iter_errors(data):

has_error = True
self.add_validation_error(err.message, absolute_path=list(err.absolute_path))
err_message = err.message

# Custom error message handling
if len(err.absolute_path) > 0 and 'err_message' in err.schema:
err_message = str(err.schema['err_message'])
err_message = err_message.replace('$instance', str(err.instance))

self.add_validation_error(err_message, absolute_path=list(err.absolute_path))

if not has_error:
self.add_validation_pass()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
hosts:
- name: host-1
- name: host-2
aliases:
- test-host-2
- name: host-3
aliases:
- 3
- test-host-three
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
hosts:
- name: host-1
- name: host-2
aliases:
- test-host-2
- name: host-3
aliases:
- test-host-3
- test-host-three
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
hosts:
- name: host-1
- name: host-2
aliases:
- test-host-2
- name: host-3
aliases:
- test-host-3
- test-host-three
hosts_2:
- name: host-1-2
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
aliases:
type: "array"
items:
$ref: "file://../objects/hosts.yml#host_name"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
host_name:
type: "string"
pattern: ^(?!-)[a-zA-Z0-9-]{1,63}(?!-)$
err_message: "'$instance' is not valid. Please see docs."
25 changes: 25 additions & 0 deletions tests/fixtures/test_custom_errors/schema/schemas/hosts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
$schema: "http://json-schema.org/draft-07/schema#"
$id: "schemas/host_names"
type: "object"
additionalProperties: false
err_message: This should be ignored.
properties:
hosts:
type: "array"
items:
type: "object"
additionalProperties: false
required: [ "name" ]
properties:
name:
type: "string"
pattern: ^(?!-)[a-zA-Z0-9-]{1,63}(?!-)$
err_message: "'$instance' is not valid. Please see docs."
aliases:
type: "array"
items:
type: "string"
pattern: ^(?!-)[a-zA-Z0-9-]{1,63}(?!-)$
err_message: "'$instance' is not valid. Please see docs."
required:
- "hosts"
81 changes: 81 additions & 0 deletions tests/test_custom_error_messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# pylint: disable=redefined-outer-name
"""Tests to validate functions defined in jsonschema.py"""
import os
import pytest

from schema_enforcer.schemas.jsonschema import JsonSchema
from schema_enforcer.validation import RESULT_PASS, RESULT_FAIL
from schema_enforcer.utils import load_file

FIXTURES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "fixtures", "test_custom_errors")
LOADED_SCHEMA_DATA = load_file(os.path.join(FIXTURES_DIR, "schema", "schemas", "hosts.yml"))


@pytest.fixture
def schema_instance():
"""JSONSchema schema instance."""
schema_instance = JsonSchema(
schema=LOADED_SCHEMA_DATA,
filename="hosts.yml",
root=os.path.join(FIXTURES_DIR, "schema", "schemas"),
)
return schema_instance


@pytest.fixture
def valid_instance_data():
"""Valid instance data loaded from YAML file."""
return load_file(os.path.join(FIXTURES_DIR, "hostvars", "chi-beijing-hosts", "hosts.yml"))


@pytest.fixture
def invalid_instance_data():
"""Invalid instance data loaded from YAML file."""
return load_file(os.path.join(FIXTURES_DIR, "hostvars", "can-vancouver-hosts", "hosts.yml"))


@pytest.fixture
def invalid_instance_data_root():
"""Invalid instance data with a root level error. Loaded from YAML file."""
return load_file(os.path.join(FIXTURES_DIR, "hostvars", "eng-london-hosts", "hosts.yml"))


class TestCustomErrors:
"""Tests custom error message handling in jsonschema.py"""

@staticmethod
def test_validate(schema_instance, valid_instance_data, invalid_instance_data, invalid_instance_data_root):
"""Tests validate method of JsonSchema class

Args:
schema_instance (JsonSchema): Instance of JsonSchema class
"""
schema_instance.validate(data=valid_instance_data)
validation_results = schema_instance.get_results()
assert len(validation_results) == 1
assert validation_results[0].schema_id == LOADED_SCHEMA_DATA.get("$id")
assert validation_results[0].result == RESULT_PASS
assert validation_results[0].message is None
schema_instance.clear_results()

schema_instance.validate(data=invalid_instance_data)
validation_results = schema_instance.get_results()
assert len(validation_results) == 1
assert validation_results[0].schema_id == LOADED_SCHEMA_DATA.get("$id")
assert validation_results[0].result == RESULT_FAIL
assert validation_results[0].message == "'3' is not valid. Please see docs."
assert validation_results[0].absolute_path == ["hosts", "2", "aliases", "0"]
schema_instance.clear_results()

# Custom error message at root level should be ignored
schema_instance.validate(data=invalid_instance_data_root)
validation_results = schema_instance.get_results()
assert len(validation_results) == 1
assert validation_results[0].schema_id == LOADED_SCHEMA_DATA.get("$id")
assert validation_results[0].result == RESULT_FAIL
assert (
validation_results[0].message
== "Additional properties are not allowed ('hosts_2' was unexpected)"
)
assert validation_results[0].absolute_path == []
schema_instance.clear_results()