Commit e47bc0c1 authored by Jérome Perrin's avatar Jérome Perrin

slap: improve invalid instance parameters reporting

 - warn with all json validation errors, not just the first one
 - make validation error message easier to understand by not including
   so many useless details
 - do not let jsonschema fetch schema references by providing our own
   fetch implementation
parent 38b40d67
Pipeline #36492 failed with stage
in 0 seconds
...@@ -43,13 +43,12 @@ from functools import wraps ...@@ -43,13 +43,12 @@ from functools import wraps
import warnings import warnings
import json import json
import jsonschema
import six import six
from .exception import ResourceNotReady, ServerError, NotFoundError, \ from .exception import ResourceNotReady, ServerError, NotFoundError, \
ConnectionError ConnectionError
from .hateoas import SlapHateoasNavigator, ConnectionHelper from .hateoas import SlapHateoasNavigator, ConnectionHelper
from slapos.util import (SoftwareReleaseSchema, from slapos.util import (SoftwareReleaseSchema, SoftwareReleaseSchemaValidationError,
bytes2str, calculate_dict_hash, dict2xml, dumps, loads, bytes2str, calculate_dict_hash, dict2xml, dumps, loads,
unicode2str, xml2dict) unicode2str, xml2dict)
...@@ -105,9 +104,9 @@ class SlapRequester(SlapDocument): ...@@ -105,9 +104,9 @@ class SlapRequester(SlapDocument):
software_release, software_release,
software_type, software_type,
).validateInstanceParameterDict(parameter_dict) ).validateInstanceParameterDict(parameter_dict)
except jsonschema.ValidationError as e: except SoftwareReleaseSchemaValidationError as e:
warnings.warn( warnings.warn(
"Request parameters do not validate against schema definition:\n{e}".format(e=e), "Request parameters do not validate against schema definition:\n{e}".format(e=e.format_error(indent=2)),
UserWarning, UserWarning,
) )
except Exception as e: except Exception as e:
......
...@@ -903,6 +903,7 @@ class TestComputerPartition(SlapMixin): ...@@ -903,6 +903,7 @@ class TestComputerPartition(SlapMixin):
"$ref": "./schemas-definitions.json#/foo" "$ref": "./schemas-definitions.json#/foo"
} }
}, },
"additionalProperties": False,
"type": "object" "type": "object"
}) })
if url.path.endswith('/schemas-definitions.json'): if url.path.endswith('/schemas-definitions.json'):
...@@ -930,18 +931,37 @@ class TestComputerPartition(SlapMixin): ...@@ -930,18 +931,37 @@ class TestComputerPartition(SlapMixin):
if PY3: if PY3:
warn.assert_called_with( warn.assert_called_with(
"Request parameters do not validate against schema definition:\n" "Request parameters do not validate against schema definition:\n"
"'bar' was expected\n\n" " $.foo: 'bar' was expected",
"Failed validating 'const' in schema['properties']['foo']:\n" UserWarning
" {'const': 'bar', 'type': 'string'}\n\n"
"On instance['foo']:\n 'baz'", UserWarning
) )
else: # BBB else: # BBB
warn.assert_called_with( warn.assert_called_with(
"Request parameters do not validate against schema definition:\n" "Request parameters do not validate against schema definition:\n"
"u'bar' was expected\n\n" " $.foo: u'bar' was expected",
"Failed validating u'const' in schema[u'properties'][u'foo']:\n" UserWarning
" {u'const': u'bar', u'type': u'string'}\n\n" )
"On instance[u'foo']:\n 'baz'", UserWarning
with httmock.HTTMock(handler):
with mock.patch.object(warnings, 'warn') as warn:
cp = slapos.slap.ComputerPartition('computer_id', 'partition_id')
cp._connection_helper = mock.Mock()
cp._connection_helper.POST.side_effect = slapos.slap.ResourceNotReady
cp.request(
'https://example.com/software.cfg', 'default', 'reference',
partition_parameter_kw={'fooo': 'xxx'})
if PY3:
warn.assert_called_with(
"Request parameters do not validate against schema definition:\n"
" $: 'foo' is a required property\n"
" $: Additional properties are not allowed ('fooo' was unexpected)",
UserWarning
)
else: # BBB
warn.assert_called_with(
"Request parameters do not validate against schema definition:\n"
" $: u'foo' is a required property\n"
" $: Additional properties are not allowed ('fooo' was unexpected)",
UserWarning
) )
def test_request_validate_request_parameter_broken_software_release_schema(self): def test_request_validate_request_parameter_broken_software_release_schema(self):
......
...@@ -36,12 +36,15 @@ import warnings ...@@ -36,12 +36,15 @@ import warnings
from pwd import getpwnam from pwd import getpwnam
from six.moves import SimpleHTTPServer from six.moves import SimpleHTTPServer
import jsonschema
import slapos.util import slapos.util
from slapos.testing.utils import ManagedHTTPServer from slapos.testing.utils import ManagedHTTPServer
from slapos.util import (SoftwareReleaseSchema, SoftwareReleaseSerialisation, from slapos.util import (
string_to_boolean, unicode2str) SoftwareReleaseSchema,
SoftwareReleaseSerialisation,
SoftwareReleaseSchemaValidationError,
string_to_boolean,
)
class TestUtil(unittest.TestCase): class TestUtil(unittest.TestCase):
...@@ -300,12 +303,12 @@ class SoftwareReleaseSchemaTestMixin(object): ...@@ -300,12 +303,12 @@ class SoftwareReleaseSchemaTestMixin(object):
# already serialized values are also tolerated # already serialized values are also tolerated
schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)}) schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)})
with self.assertRaises(jsonschema.ValidationError): with self.assertRaises(SoftwareReleaseSchemaValidationError):
schema.validateInstanceParameterDict({"wrong": True}) schema.validateInstanceParameterDict({"wrong": True})
instance_ok['key'] = False # wrong type instance_ok['key'] = False # wrong type
with self.assertRaises(jsonschema.ValidationError): with self.assertRaises(SoftwareReleaseSchemaValidationError):
schema.validateInstanceParameterDict(instance_ok) schema.validateInstanceParameterDict(instance_ok)
with self.assertRaises(jsonschema.ValidationError): with self.assertRaises(SoftwareReleaseSchemaValidationError):
schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)}) schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)})
def test_instance_request_parameter_validate_alternate_software_type(self): def test_instance_request_parameter_validate_alternate_software_type(self):
...@@ -318,12 +321,12 @@ class SoftwareReleaseSchemaTestMixin(object): ...@@ -318,12 +321,12 @@ class SoftwareReleaseSchemaTestMixin(object):
# already serialized values are also tolerated # already serialized values are also tolerated
schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)}) schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)})
with self.assertRaises(jsonschema.ValidationError): with self.assertRaises(SoftwareReleaseSchemaValidationError):
schema.validateInstanceParameterDict({"wrong": True}) schema.validateInstanceParameterDict({"wrong": True})
instance_ok['type'] = 'wrong' instance_ok['type'] = 'wrong'
with self.assertRaises(jsonschema.ValidationError): with self.assertRaises(SoftwareReleaseSchemaValidationError):
schema.validateInstanceParameterDict(instance_ok) schema.validateInstanceParameterDict(instance_ok)
with self.assertRaises(jsonschema.ValidationError): with self.assertRaises(SoftwareReleaseSchemaValidationError):
schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)}) schema.validateInstanceParameterDict({'_': json.dumps(instance_ok)})
def test_instance_request_parameter_schema_alternate_software_type(self): def test_instance_request_parameter_schema_alternate_software_type(self):
......
...@@ -51,7 +51,7 @@ from six.moves.urllib_parse import urljoin ...@@ -51,7 +51,7 @@ from six.moves.urllib_parse import urljoin
from xml_marshaller.xml_marshaller import Marshaller, Unmarshaller from xml_marshaller.xml_marshaller import Marshaller, Unmarshaller
try: try:
from typing import Dict, Optional, IO from typing import Dict, Iterator, List, Optional
except ImportError: except ImportError:
pass pass
...@@ -429,11 +429,59 @@ def _readAsJson(url, set_schema_id=False): ...@@ -429,11 +429,59 @@ def _readAsJson(url, set_schema_id=False):
% (url, type(e).__name__, e)) % (url, type(e).__name__, e))
class _RefResolver(jsonschema.validators.RefResolver):
def resolve_remote(self, uri):
result = _readAsJson(uri)
if self.cache_remote:
self.store[uri] = result
return result
class SoftwareReleaseSerialisation(str, enum.Enum): class SoftwareReleaseSerialisation(str, enum.Enum):
Xml = 'xml' Xml = 'xml'
JsonInXml = 'json-in-xml' JsonInXml = 'json-in-xml'
class SoftwareReleaseSchemaValidationError(ValueError):
"""Error raised when a request is made with invalid parameters.
This collects all the json schema validation errors.
"""
def __init__(self, validation_errors):
# type: (List[jsonschema.ValidationError]) -> None
self.validation_errors = validation_errors
super(SoftwareReleaseSchemaValidationError, self).__init__()
def format_error(self, indent=0):
def _iter_validation_error(err):
# type: (jsonschema.ValidationError) -> Iterator[jsonschema.ValidationError]
if err.context:
for e in err.context:
yield e
# BBB PY3
# yield from _iter_validation_error(e)
for sube in _iter_validation_error(e):
yield sube
msg_list = []
for e in self.validation_errors:
if six.PY2: # BBB
def json_path(e):
path = "$"
for elem in e.absolute_path:
if isinstance(elem, int):
path += "[" + str(elem) + "]"
else:
path += "." + elem
return path
msg_list.append("{e_json_path}: {e.message}".format(e=e, e_json_path=json_path(e)))
else:
msg_list.append("{e.json_path}: {e.message}".format(e=e))
indent_str = "\n" + (" " * indent)
return (" " * indent) + indent_str.join(msg_list)
class SoftwareReleaseSchema(object): class SoftwareReleaseSchema(object):
def __init__(self, software_url, software_type): def __init__(self, software_url, software_type):
...@@ -537,7 +585,8 @@ class SoftwareReleaseSchema(object): ...@@ -537,7 +585,8 @@ class SoftwareReleaseSchema(object):
# type: (Dict) -> None # type: (Dict) -> None
"""Validate instance parameters against the software schema. """Validate instance parameters against the software schema.
Raise jsonschema.ValidationError if parameters does not validate. Raise SoftwareReleaseSchemaValidationError if parameters do not
validate.
""" """
schema = self.getInstanceRequestParameterSchema() schema = self.getInstanceRequestParameterSchema()
if schema: if schema:
...@@ -547,7 +596,14 @@ class SoftwareReleaseSchema(object): ...@@ -547,7 +596,14 @@ class SoftwareReleaseSchema(object):
except KeyError: except KeyError:
pass pass
parameter_dict.pop('$schema', None) parameter_dict.pop('$schema', None)
jsonschema.validate(instance=parameter_dict, schema=schema)
validator = jsonschema.validators.validator_for(schema)(
schema,
resolver=_RefResolver.from_schema(schema),
)
errors = list(validator.iter_errors(parameter_dict))
if errors:
raise SoftwareReleaseSchemaValidationError(errors)
# BBB on python3 we can use pprint.pformat # BBB on python3 we can use pprint.pformat
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment