From 6e15ece72444f2dc53663f6becfc8bde41c12784 Mon Sep 17 00:00:00 2001 From: Graham Welch Date: Tue, 24 Nov 2015 15:00:43 -0800 Subject: [PATCH] Use new schema syntax and recursively set default values. Includes unit tests. --- expandybird/expansion/schema_validation.py | 99 +-- .../expansion/schema_validation_test.py | 616 ++++++++++++++++++ .../expansion/schema_validation_utils.py | 81 +-- expandybird/test/schemas/bad.jinja.schema | 9 + .../test/schemas/default_ref.jinja.schema | 14 + .../test/schemas/defaults.jinja.schema | 12 + expandybird/test/schemas/defaults.py.schema | 12 + .../test/schemas/invalid_default.jinja.schema | 11 + .../test/schemas/invalid_reference.py.schema | 10 + .../invalid_reference_schema.py.schema | 8 + expandybird/test/schemas/metadata.py.schema | 20 + .../test/schemas/missing_quote.py.schema | 11 + .../test/schemas/nested_defaults.py.schema | 33 + expandybird/test/schemas/numbers.py.schema | 27 + .../schemas/ref_nested_defaults.py.schema | 36 + .../test/schemas/reference.jinja.schema | 14 + .../test/schemas/req_default_ref.py.schema | 14 + .../test/schemas/required.jinja.schema | 10 + .../schemas/required_default.jinja.schema | 11 + 19 files changed, 950 insertions(+), 98 deletions(-) create mode 100644 expandybird/expansion/schema_validation_test.py create mode 100644 expandybird/test/schemas/bad.jinja.schema create mode 100644 expandybird/test/schemas/default_ref.jinja.schema create mode 100644 expandybird/test/schemas/defaults.jinja.schema create mode 100644 expandybird/test/schemas/defaults.py.schema create mode 100644 expandybird/test/schemas/invalid_default.jinja.schema create mode 100644 expandybird/test/schemas/invalid_reference.py.schema create mode 100644 expandybird/test/schemas/invalid_reference_schema.py.schema create mode 100644 expandybird/test/schemas/metadata.py.schema create mode 100644 expandybird/test/schemas/missing_quote.py.schema create mode 100644 expandybird/test/schemas/nested_defaults.py.schema create mode 100644 expandybird/test/schemas/numbers.py.schema create mode 100644 expandybird/test/schemas/ref_nested_defaults.py.schema create mode 100644 expandybird/test/schemas/reference.jinja.schema create mode 100644 expandybird/test/schemas/req_default_ref.py.schema create mode 100644 expandybird/test/schemas/required.jinja.schema create mode 100644 expandybird/test/schemas/required_default.jinja.schema diff --git a/expandybird/expansion/schema_validation.py b/expandybird/expansion/schema_validation.py index e361d1e17..f82b94ff8 100644 --- a/expandybird/expansion/schema_validation.py +++ b/expandybird/expansion/schema_validation.py @@ -26,13 +26,12 @@ PROPERTIES = "properties" # This validator will set default values in properties. # This does not return a complete set of errors; use only for setting defaults. # Pass this object a schema to get a validator for that schema. -DEFAULT_VALIDATOR = schema_validation_utils.OnlyValidateProperties( - schema_validation_utils.ExtendWithDefault(jsonschema.Draft4Validator)) +DEFAULT_SETTER = schema_validation_utils.ExtendWithDefault( + jsonschema.Draft4Validator) -# This is a regular validator, use after using the DEFAULT_VALIDATOR +# This is a regular validator, use after using the DEFAULT_SETTER # Pass this object a schema to get a validator for that schema. -VALIDATOR = schema_validation_utils.OnlyValidateProperties( - jsonschema.Draft4Validator) +VALIDATOR = jsonschema.Draft4Validator # This is a validator using the default Draft4 metaschema, # use it to validate user schemas. @@ -61,29 +60,61 @@ IMPORT_SCHEMA_VALIDATOR = jsonschema.Draft4Validator( yaml.safe_load(IMPORT_SCHEMA)) +def _ValidateSchema(schema, validating_imports, schema_name, template_name): + """Validate that the passed in schema file is correctly formatted. + + Args: + schema: contents of the schema file + validating_imports: boolean, if we should validate the 'imports' + section of the schema + schema_name: name of the schema file to validate + template_name: name of the template whose properties are being validated + + Raises: + ValidationErrors: A list of ValidationError errors that occured when + validating the schema file + """ + schema_errors = [] + + # Validate the syntax of the optional "imports:" section of the schema + if validating_imports: + schema_errors.extend(IMPORT_SCHEMA_VALIDATOR.iter_errors(schema)) + + # Validate the syntax of the jsonSchema section of the schema + try: + schema_errors.extend(SCHEMA_VALIDATOR.iter_errors(schema)) + except jsonschema.RefResolutionError as e: + # Calls to iter_errors could throw a RefResolution exception + raise ValidationErrors(schema_name, template_name, + [e], is_schema_error=True) + + if schema_errors: + raise ValidationErrors(schema_name, template_name, + schema_errors, is_schema_error=True) + + def Validate(properties, schema_name, template_name, imports): """Given a set of properties, validates it against the given schema. Args: properties: dict, the properties to be validated schema_name: name of the schema file to validate - template_name: name of the template whose's properties are being validated - imports: map from string to string, the map of imported files names - and contents + template_name: name of the template whose properties are being validated + imports: the map of imported files names to file contents Returns: Dict containing the validated properties, with defaults filled in Raises: ValidationErrors: A list of ValidationError errors that occurred when - validating the properties and schema + validating the properties and schema, + or if the schema file was not found """ if schema_name not in imports: raise ValidationErrors(schema_name, template_name, - ["Could not find schema file '" - + schema_name + "'."]) - else: - raw_schema = imports[schema_name] + ["Could not find schema file '%s'." % schema_name]) + + raw_schema = imports[schema_name] if properties is None: properties = {} @@ -91,33 +122,14 @@ def Validate(properties, schema_name, template_name, imports): schema = yaml.safe_load(raw_schema) # If the schema is empty, do nothing. - if schema is None: + if not schema: return properties - schema_errors = [] validating_imports = IMPORTS in schema and schema[IMPORTS] - # Validate the syntax of the optional "imports:" section of the schema - if validating_imports: - schema_errors.extend(list(IMPORT_SCHEMA_VALIDATOR.iter_errors(schema))) - - # Validate the syntax of the optional "properties:" section of the schema - if PROPERTIES in schema and schema[PROPERTIES]: - try: - schema_errors.extend( - list(SCHEMA_VALIDATOR.iter_errors(schema[PROPERTIES]))) - except jsonschema.RefResolutionError as e: - # Calls to iter_errors could throw a RefResolution exception - raise ValidationErrors(schema_name, template_name, - list(e), is_schema_error=True) - - if schema_errors: - raise ValidationErrors(schema_name, template_name, - schema_errors, is_schema_error=True) + # If this doesn't raise any exceptions, we can assume we have a valid schema + _ValidateSchema(schema, validating_imports, schema_name, template_name) - ###### - # Assume we have a valid schema - ###### errors = [] # Validate that all files specified as "imports:" were included @@ -131,24 +143,25 @@ def Validate(properties, schema_name, template_name, imports): import_name = import_object["path"] if import_name not in imports: - errors.append(("File '" + import_name + "' requested in schema '" - + schema_name + "' but not included with imports.")) + errors.append(("File '%s' requested in schema '%s' " + "but not included with imports." + % (import_name, schema_name))) try: - # This code block uses DEFAULT_VALIDATOR and VALIDATOR for two very + # This code block uses DEFAULT_SETTER and VALIDATOR for two very # different purposes. - # DEFAULT_VALIDATOR is based on JSONSchema 4, but uses modified validators: + # DEFAULT_SETTER is based on JSONSchema 4, but uses modified validators: # - The 'required' validator does nothing # - The 'properties' validator sets default values on user properties # With these changes, the validator does not report errors correctly. # # So, we do error reporting in two steps: - # 1) Use DEFAULT_VALIDATOR to set default values in the user's properties + # 1) Use DEFAULT_SETTER to set default values in the user's properties # 2) Use the unmodified VALIDATOR to report all of the errors # Calling iter_errors mutates properties in place, adding default values. # You must call list()! This is a generator, not a function! - list(DEFAULT_VALIDATOR(schema).iter_errors(properties)) + list(DEFAULT_SETTER(schema).iter_errors(properties)) # Now that we have default values, validate the properties errors.extend(list(VALIDATOR(schema).iter_errors(properties))) @@ -158,7 +171,7 @@ def Validate(properties, schema_name, template_name, imports): except jsonschema.RefResolutionError as e: # Calls to iter_errors could throw a RefResolution exception raise ValidationErrors(schema_name, template_name, - list(e), is_schema_error=True) + [e], is_schema_error=True) except TypeError as e: raise ValidationErrors( schema_name, template_name, @@ -191,7 +204,7 @@ class ValidationErrors(Exception): message = "Invalid properties for '%s':\n" % self.template_name for error in self.errors: - if type(error) is jsonschema.exceptions.ValidationError: + if isinstance(error, jsonschema.exceptions.ValidationError): error_message = error.message location = list(error.path) if location and len(location): diff --git a/expandybird/expansion/schema_validation_test.py b/expandybird/expansion/schema_validation_test.py new file mode 100644 index 000000000..3f8f4b38f --- /dev/null +++ b/expandybird/expansion/schema_validation_test.py @@ -0,0 +1,616 @@ +###################################################################### +# Copyright 2015 The Kubernetes Authors All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +###################################################################### + +import os +import unittest +import schema_validation +import yaml + +INVALID_PROPERTIES = "Invalid properties for 'template.py'" + + +def GetFilePath(): + """Find our source and data files.""" + return os.path.dirname(os.path.abspath(__file__)) + + +def ReadTestFile(filename): + """Returns contents of a file from the testdata/ directory.""" + + full_path = os.path.join(GetFilePath(), '..', 'test', 'schemas', filename) + return open(full_path, 'r').read() + + +def RawValidate(raw_properties, schema_name, raw_schema): + return ImportsRawValidate(raw_properties, schema_name, + {schema_name: raw_schema}) + + +def ImportsRawValidate(raw_properties, schema_name, import_map): + """Takes raw properties, calls validate and returns yaml properties.""" + properties = yaml.safe_load(raw_properties) + return schema_validation.Validate(properties, schema_name, 'template.py', + import_map) + + +class SchemaValidationTest(unittest.TestCase): + """Tests of the schema portion of the template expansion library.""" + + def testDefaults(self): + schema_name = 'defaults.jinja.schema' + schema = ReadTestFile(schema_name) + empty_properties = '' + expected_properties = """ + alpha: alpha + one: 1 + """ + self.assertEqual(yaml.safe_load(expected_properties), + RawValidate(empty_properties, schema_name, schema)) + + def testNestedDefaults(self): + schema_name = 'nested_defaults.py.schema' + schema = ReadTestFile(schema_name) + properties = """ + zone: us-central1-a + disks: + - name: backup # diskType and sizeGb set by default + - name: cache # sizeGb set by default + diskType: pd-ssd + - name: data # Nothing set by default + diskType: pd-ssd + sizeGb: 150 + - name: swap # diskType set by default + sizeGb: 200 + """ + expected_properties = """ + zone: us-central1-a + disks: + - sizeGb: 100 + diskType: pd-standard + name: backup + - sizeGb: 100 + diskType: pd-ssd + name: cache + - sizeGb: 150 + diskType: pd-ssd + name: data + - sizeGb: 200 + diskType: pd-standard + name: swap + """ + self.assertEqual(yaml.safe_load(expected_properties), + RawValidate(properties, schema_name, schema)) + + def testNestedRefDefaults(self): + schema_name = 'ref_nested_defaults.py.schema' + schema = ReadTestFile(schema_name) + properties = """ + zone: us-central1-a + disks: + - name: backup # diskType and sizeGb set by default + - name: cache # sizeGb set by default + diskType: pd-ssd + - name: data # Nothing set by default + diskType: pd-ssd + sizeGb: 150 + - name: swap # diskType set by default + sizeGb: 200 + """ + expected_properties = """ + zone: us-central1-a + disks: + - sizeGb: 100 + diskType: pd-standard + name: backup + - sizeGb: 100 + diskType: pd-ssd + name: cache + - sizeGb: 150 + diskType: pd-ssd + name: data + - sizeGb: 200 + diskType: pd-standard + name: swap + """ + self.assertEqual(yaml.safe_load(expected_properties), + RawValidate(properties, schema_name, schema)) + + def testInvalidDefault(self): + schema_name = 'invalid_default.jinja.schema' + schema = ReadTestFile(schema_name) + empty_properties = '' + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn(INVALID_PROPERTIES, e.message) + self.assertIn("'string' is not of type 'integer' at ['number']", + e.message) + + def testRequiredDefault(self): + schema_name = 'required_default.jinja.schema' + schema = ReadTestFile(schema_name) + empty_properties = '' + expected_properties = """ + name: my_name + """ + self.assertEqual(yaml.safe_load(expected_properties), + RawValidate(empty_properties, schema_name, schema)) + + def testRequiredDefaultReference(self): + schema_name = 'req_default_ref.py.schema' + schema = ReadTestFile(schema_name) + empty_properties = '' + + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn(INVALID_PROPERTIES, e.message) + self.assertIn("'my_name' is not of type 'integer' at ['number']", + e.message) + + def testDefaultReference(self): + schema_name = 'default_ref.jinja.schema' + schema = ReadTestFile(schema_name) + empty_properties = '' + expected_properties = 'number: 1' + + self.assertEqual(yaml.safe_load(expected_properties), + RawValidate(empty_properties, schema_name, schema)) + + def testMissingQuoteInReference(self): + schema_name = 'missing_quote.py.schema' + schema = ReadTestFile(schema_name) + properties = 'number: 1' + + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(2, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn("type 'NoneType' is not iterable", e.message) + self.assertIn('around your reference', e.message) + + def testRequiredPropertyMissing(self): + schema_name = 'required.jinja.schema' + schema = ReadTestFile(schema_name) + empty_properties = '' + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn(INVALID_PROPERTIES, e.message) + self.assertIn("'name' is a required property", e.errors[0].message) + + def testRequiredPropertyValid(self): + schema_name = 'required.jinja.schema' + schema = ReadTestFile(schema_name) + properties = """ + name: my-name + """ + self.assertEqual(yaml.safe_load(properties), + RawValidate(properties, schema_name, schema)) + + def testMultipleErrors(self): + schema_name = 'defaults.py.schema' + schema = ReadTestFile(schema_name) + properties = """ + one: not a number + alpha: 12345 + """ + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(2, len(e.errors)) + self.assertIn(INVALID_PROPERTIES, e.message) + self.assertIn("'not a number' is not of type 'integer' at ['one']", + e.message) + self.assertIn("12345 is not of type 'string' at ['alpha']", e.message) + + def testNumbersValid(self): + schema_name = 'numbers.py.schema' + schema = ReadTestFile(schema_name) + properties = """ + minimum0: 0 + exclusiveMin0: 1 + maximum10: 10 + exclusiveMax10: 9 + even: 20 + odd: 21 + """ + self.assertEquals(yaml.safe_load(properties), + RawValidate(properties, schema_name, schema)) + + def testNumbersInvalid(self): + schema_name = 'numbers.py.schema' + schema = ReadTestFile(schema_name) + properties = """ + minimum0: -1 + exclusiveMin0: 0 + maximum10: 11 + exclusiveMax10: 10 + even: 21 + odd: 20 + """ + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(6, len(e.errors)) + self.assertIn(INVALID_PROPERTIES, e.message) + self.assertIn("-1 is less than the minimum of 0 at ['minimum0']", + e.message) + self.assertIn(('0 is less than or equal to the minimum of 0' + " at ['exclusiveMin0']"), e.message) + self.assertIn("11 is greater than the maximum of 10 at ['maximum10']", + e.message) + self.assertIn(('10 is greater than or equal to the maximum of 10' + " at ['exclusiveMax10']"), e.message) + self.assertIn("21 is not a multiple of 2 at ['even']", e.message) + self.assertIn("{'multipleOf': 2} is not allowed for 20 at ['odd']", + e.message) + + def testReference(self): + schema_name = 'reference.jinja.schema' + schema = ReadTestFile(schema_name) + properties = """ + odd: 6 + """ + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn('even', e.message) + self.assertIn('is not allowed for 6', e.message) + + def testBadSchema(self): + schema_name = 'bad.jinja.schema' + schema = ReadTestFile(schema_name) + empty_properties = '' + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(2, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn("u'minimum' is a dependency of u'exclusiveMinimum'", + e.message) + self.assertIn("0 is not of type u'boolean'", e.message) + + def testInvalidReference(self): + schema_name = 'invalid_reference.py.schema' + schema = ReadTestFile(schema_name) + properties = 'odd: 1' + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn('Unresolvable JSON pointer', e.message) + + def testInvalidReferenceInSchema(self): + schema_name = 'invalid_reference_schema.py.schema' + schema = ReadTestFile(schema_name) + empty_properties = '' + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn('Unresolvable JSON pointer', e.message) + + def testMetadata(self): + schema_name = 'metadata.py.schema' + schema = ReadTestFile(schema_name) + properties = """ + one: 2 + alpha: beta + """ + self.assertEquals(yaml.safe_load(properties), + RawValidate(properties, schema_name, schema)) + + def testInvalidInput(self): + schema_name = 'schema' + schema = """ + info: + title: Invalid Input + properties: invalid + """ + properties = """ + one: 2 + alpha: beta + """ + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn("'invalid' is not of type u'object'", e.message) + + def testPattern(self): + schema_name = 'schema' + schema = r""" + properties: + bad-zone: + pattern: \w+-\w+-\w+ + zone: + pattern: \w+-\w+-\w+ + """ + properties = """ + bad-zone: abc + zone: us-central1-a + """ + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn('Invalid properties', e.message) + self.assertIn("'abc' does not match", e.message) + self.assertIn('bad-zone', e.message) + + def testUniqueItems(self): + schema_name = 'schema' + schema = """ + properties: + bad-list: + type: array + uniqueItems: true + list: + type: array + uniqueItems: true + """ + properties = """ + bad-list: + - a + - b + - a + list: + - a + - b + - c + """ + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn('Invalid properties', e.message) + self.assertIn('has non-unique elements', e.message) + self.assertIn('bad-list', e.message) + + def testUniqueItemsOnString(self): + schema_name = 'schema' + schema = """ + properties: + ok-string: + type: string + uniqueItems: true + string: + type: string + uniqueItems: true + """ + properties = """ + ok-string: aaa + string: abc + """ + self.assertEquals(yaml.safe_load(properties), + RawValidate(properties, schema_name, schema)) + + def testRequiredTopLevel(self): + schema_name = 'schema' + schema = """ + info: + title: Invalid Input + required: + - name + """ + properties = """ + one: 2 + alpha: beta + """ + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn(INVALID_PROPERTIES, e.message) + self.assertIn("'name' is a required property", e.message) + + def testEmptySchemaProperties(self): + schema_name = 'schema' + schema = """ + info: + title: Empty Input + properties: + """ + properties = """ + one: 2 + alpha: beta + """ + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn("None is not of type u'object' at [u'properties']", + e.message) + + def testNoInput(self): + schema = """ + info: + title: No other sections + """ + properties = """ + one: 2 + alpha: beta + """ + self.assertEquals(yaml.safe_load(properties), + RawValidate(properties, 'schema', schema)) + + def testEmptySchema(self): + schema = '' + properties = """ + one: 2 + alpha: beta + """ + self.assertEquals(yaml.safe_load(properties), + RawValidate(properties, 'schema', schema)) + + def testImportPathSchema(self): + schema = """ + imports: + - path: a + - path: path/to/b + name: b + """ + properties = """ + one: 2 + alpha: beta + """ + + import_map = {'schema': schema, + 'a': '', + 'b': ''} + + self.assertEquals(yaml.safe_load(properties), + ImportsRawValidate(properties, 'schema', import_map)) + + def testImportSchemaMissing(self): + schema = '' + empty_properties = '' + + try: + properties = yaml.safe_load(empty_properties) + schema_validation.Validate(properties, 'schema', 'template', + {'wrong_name': schema}) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("Could not find schema file 'schema'", e.message) + + def testImportsMalformedNotAList(self): + schema_name = 'schema' + schema = """ + imports: not-a-list + """ + empty_properties = '' + + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn("is not of type 'array' at ['imports']", e.message) + + def testImportsMalformedMissingPath(self): + schema_name = 'schema' + schema = """ + imports: + - name: no_path.yaml + """ + empty_properties = '' + + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn("'path' is a required property", e.message) + + def testImportsMalformedNonunique(self): + schema_name = 'schema' + schema = """ + imports: + - path: a.yaml + name: a + - path: a.yaml + name: a + """ + empty_properties = '' + + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn('non-unique elements', e.message) + + def testImportsMalformedAdditionalProperties(self): + schema_name = 'schema' + schema = """ + imports: + - path: a.yaml + gnome: a + """ + empty_properties = '' + + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn('Additional properties are not allowed' + " ('gnome' was unexpected)", e.message) + + def testImportAndInputErrors(self): + schema = """ + imports: + - path: file + required: + - name + """ + empty_properties = '' + + try: + RawValidate(empty_properties, 'schema', schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(2, len(e.errors)) + self.assertIn("'file' requested in schema 'schema'", e.message) + self.assertIn("'name' is a required property", e.message) + + def testImportAndInputSchemaErrors(self): + schema_name = 'schema' + schema = """ + imports: not-a-list + required: not-a-list + """ + empty_properties = '' + + try: + RawValidate(empty_properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(2, len(e.errors)) + self.assertIn("Invalid schema '%s'" % schema_name, e.message) + self.assertIn("is not of type 'array' at ['imports']", e.message) + self.assertIn("is not of type u'array' at [u'required']", e.message) + +if __name__ == '__main__': + unittest.main() diff --git a/expandybird/expansion/schema_validation_utils.py b/expandybird/expansion/schema_validation_utils.py index b374824eb..2aac82677 100644 --- a/expandybird/expansion/schema_validation_utils.py +++ b/expandybird/expansion/schema_validation_utils.py @@ -15,35 +15,10 @@ import jsonschema -DEFAULT = "default" -PROPERTIES = "properties" -REF = "$ref" -REQUIRED = "required" - - -def OnlyValidateProperties(validator_class): - """Takes a validator and makes it process only the 'properties' top level. - - Args: - validator_class: A class to add a new validator to - - Returns: - A validator_class that will validate properties against things - under the top level "properties" field - """ - - def PropertiesValidator(unused_validator, inputs, instance, schema): - if inputs is None: - inputs = {} - for error in validator_class(schema).iter_errors(instance, inputs): - yield error - - # This makes sure the only keyword jsonschema will validate is 'properties' - new_validators = ClearValidatorMap(validator_class.VALIDATORS) - new_validators.update({PROPERTIES: PropertiesValidator}) - - return jsonschema.validators.extend( - validator_class, new_validators) +DEFAULT = 'default' +PROPERTIES = 'properties' +REF = '$ref' +REQUIRED = 'required' def ExtendWithDefault(validator_class): @@ -55,33 +30,33 @@ def ExtendWithDefault(validator_class): Returns: A validator_class that will set default values and ignore required fields """ + validate_properties = validator_class.VALIDATORS['properties'] - def SetDefaultsInProperties(validator, properties, instance, unused_schema): - if properties is None: - properties = {} - SetDefaults(validator, properties, instance) + def SetDefaultsInProperties(validator, user_schema, user_properties, + parent_schema): + SetDefaults(validator, user_schema or {}, user_properties, parent_schema, + validate_properties) return jsonschema.validators.extend( validator_class, {PROPERTIES: SetDefaultsInProperties, REQUIRED: IgnoreKeyword}) -def SetDefaults(validator, properties, instance): +def SetDefaults(validator, user_schema, user_properties, parent_schema, + validate_properties): """Populate the default values of properties. Args: - validator: A generator that validates the "properties" keyword - properties: User properties on which to set defaults - instance: Piece of user schema containing "properties" + validator: A generator that validates the "properties" keyword of the schema + user_schema: Schema which might define defaults, might be a nested part of + the entire schema file. + user_properties: User provided values which we are setting defaults on + parent_schema: Schema object that contains the schema being evaluated on + this pass, user_schema. + validate_properties: Validator function, called recursively. """ - if not properties: - return - - for dm_property, subschema in properties.iteritems(): - # If the property already has a value, we don't need it's default - if dm_property in instance: - return + for schema_property, subschema in user_schema.iteritems(): # The ordering of these conditions assumes that '$ref' blocks override # all other schema info, which is what the jsonschema library assumes. @@ -89,17 +64,21 @@ def SetDefaults(validator, properties, instance): # see if that reference defines a 'default' value if REF in subschema: out = ResolveReferencedDefault(validator, subschema[REF]) - instance.setdefault(dm_property, out) + user_properties.setdefault(schema_property, out) # Otherwise, see if the subschema has a 'default' value elif DEFAULT in subschema: - instance.setdefault(dm_property, subschema[DEFAULT]) + user_properties.setdefault(schema_property, subschema[DEFAULT]) + + # Recursively apply defaults. This is a generator, so we must wrap with list() + list(validate_properties(validator, user_schema, + user_properties, parent_schema)) def ResolveReferencedDefault(validator, ref): """Resolves a reference, and returns any default value it defines. Args: - validator: A generator the validates the "$ref" keyword + validator: A generator that validates the "$ref" keyword ref: The target of the "$ref" keyword Returns: @@ -110,14 +89,6 @@ def ResolveReferencedDefault(validator, ref): return resolved[DEFAULT] -def ClearValidatorMap(validators): - """Remaps all JsonSchema validators to make them do nothing.""" - ignore_validators = {} - for keyword in validators: - ignore_validators.update({keyword: IgnoreKeyword}) - return ignore_validators - - def IgnoreKeyword( unused_validator, unused_required, unused_instance, unused_schema): """Validator for JsonSchema that does nothing.""" diff --git a/expandybird/test/schemas/bad.jinja.schema b/expandybird/test/schemas/bad.jinja.schema new file mode 100644 index 000000000..825f8dcf7 --- /dev/null +++ b/expandybird/test/schemas/bad.jinja.schema @@ -0,0 +1,9 @@ +info: + title: Schema with a lots of errors in it + +imports: + +properties: + exclusiveMin: + type: integer + exclusiveMinimum: 0 diff --git a/expandybird/test/schemas/default_ref.jinja.schema b/expandybird/test/schemas/default_ref.jinja.schema new file mode 100644 index 000000000..51f83d2c8 --- /dev/null +++ b/expandybird/test/schemas/default_ref.jinja.schema @@ -0,0 +1,14 @@ +info: + title: Schema with a property that has a referenced default value + +imports: + +properties: + number: + $ref: '#/level/mult' + +level: + mult: + type: integer + multipleOf: 1 + default: 1 diff --git a/expandybird/test/schemas/defaults.jinja.schema b/expandybird/test/schemas/defaults.jinja.schema new file mode 100644 index 000000000..bcb7ee34e --- /dev/null +++ b/expandybird/test/schemas/defaults.jinja.schema @@ -0,0 +1,12 @@ +info: + title: Schema with properties that have default values + +imports: + +properties: + one: + type: integer + default: 1 + alpha: + type: string + default: alpha diff --git a/expandybird/test/schemas/defaults.py.schema b/expandybird/test/schemas/defaults.py.schema new file mode 100644 index 000000000..bcb7ee34e --- /dev/null +++ b/expandybird/test/schemas/defaults.py.schema @@ -0,0 +1,12 @@ +info: + title: Schema with properties that have default values + +imports: + +properties: + one: + type: integer + default: 1 + alpha: + type: string + default: alpha diff --git a/expandybird/test/schemas/invalid_default.jinja.schema b/expandybird/test/schemas/invalid_default.jinja.schema new file mode 100644 index 000000000..e60d11148 --- /dev/null +++ b/expandybird/test/schemas/invalid_default.jinja.schema @@ -0,0 +1,11 @@ +info: + title: Schema with a required integer property that has a default string value + +imports: + +required: + - number +properties: + number: + type: integer + default: string diff --git a/expandybird/test/schemas/invalid_reference.py.schema b/expandybird/test/schemas/invalid_reference.py.schema new file mode 100644 index 000000000..7c3fa3e10 --- /dev/null +++ b/expandybird/test/schemas/invalid_reference.py.schema @@ -0,0 +1,10 @@ +info: + title: Schema with references to something that doesnt exist + +imports: + +properties: + odd: + type: integer + not: + $ref: '#/wheeeeeee' diff --git a/expandybird/test/schemas/invalid_reference_schema.py.schema b/expandybird/test/schemas/invalid_reference_schema.py.schema new file mode 100644 index 000000000..6c824568c --- /dev/null +++ b/expandybird/test/schemas/invalid_reference_schema.py.schema @@ -0,0 +1,8 @@ +info: + title: Schema with references to something that doesnt exist + +imports: + +properties: + odd: + $ref: '#/wheeeeeee' diff --git a/expandybird/test/schemas/metadata.py.schema b/expandybird/test/schemas/metadata.py.schema new file mode 100644 index 000000000..3d6e1e346 --- /dev/null +++ b/expandybird/test/schemas/metadata.py.schema @@ -0,0 +1,20 @@ +info: + title: Schema with properties that have extra metadata + +imports: + +properties: + one: + type: integer + default: 1 + metadata: + gcloud: is great! + compute: is awesome + alpha: + type: string + default: alpha + metadata: + - you + - can + - do + - anything diff --git a/expandybird/test/schemas/missing_quote.py.schema b/expandybird/test/schemas/missing_quote.py.schema new file mode 100644 index 000000000..ddd4b5bfd --- /dev/null +++ b/expandybird/test/schemas/missing_quote.py.schema @@ -0,0 +1,11 @@ +info: + title: Schema with references + +imports: + +properties: + number: + $ref: #/number + +number: + type: integer diff --git a/expandybird/test/schemas/nested_defaults.py.schema b/expandybird/test/schemas/nested_defaults.py.schema new file mode 100644 index 000000000..b5288c91b --- /dev/null +++ b/expandybird/test/schemas/nested_defaults.py.schema @@ -0,0 +1,33 @@ +info: + title: VM with Disks + author: Kubernetes + description: Creates a single vm, then attaches disks to it. + +required: +- zone + +properties: + zone: + type: string + description: GCP zone + default: us-central1-a + disks: + type: array + items: + type: object + required: + - name + properties: + name: + type: string + description: Suffix for this disk + sizeGb: + type: integer + default: 100 + diskType: + type: string + enum: + - pd-standard + - pd-ssd + default: pd-standard + additionalProperties: false diff --git a/expandybird/test/schemas/numbers.py.schema b/expandybird/test/schemas/numbers.py.schema new file mode 100644 index 000000000..eff245182 --- /dev/null +++ b/expandybird/test/schemas/numbers.py.schema @@ -0,0 +1,27 @@ +info: + title: Schema with a lots of number properties and restrictions + +imports: + +properties: + minimum0: + type: integer + minimum: 0 + exclusiveMin0: + type: integer + minimum: 0 + exclusiveMinimum: true + maximum10: + type: integer + maximum: 10 + exclusiveMax10: + type: integer + maximum: 10 + exclusiveMaximum: true + even: + type: integer + multipleOf: 2 + odd: + type: integer + not: + multipleOf: 2 diff --git a/expandybird/test/schemas/ref_nested_defaults.py.schema b/expandybird/test/schemas/ref_nested_defaults.py.schema new file mode 100644 index 000000000..80813b73d --- /dev/null +++ b/expandybird/test/schemas/ref_nested_defaults.py.schema @@ -0,0 +1,36 @@ +info: + title: VM with Disks + author: Kubernetes + description: Creates a single vm, then attaches disks to it. + +required: +- zone + +properties: + zone: + type: string + description: GCP zone + default: us-central1-a + disks: + type: array + items: + $ref: '#/disk' + +disk: + type: object + required: + - name + properties: + name: + type: string + description: Suffix for this disk + sizeGb: + type: integer + default: 100 + diskType: + type: string + enum: + - pd-standard + - pd-ssd + default: pd-standard + additionalProperties: false diff --git a/expandybird/test/schemas/reference.jinja.schema b/expandybird/test/schemas/reference.jinja.schema new file mode 100644 index 000000000..e90251c39 --- /dev/null +++ b/expandybird/test/schemas/reference.jinja.schema @@ -0,0 +1,14 @@ +info: + title: Schema with references + +imports: + +properties: + odd: + type: integer + not: + $ref: '#/even' + + +even: + multipleOf: 2 diff --git a/expandybird/test/schemas/req_default_ref.py.schema b/expandybird/test/schemas/req_default_ref.py.schema new file mode 100644 index 000000000..08b1da3e9 --- /dev/null +++ b/expandybird/test/schemas/req_default_ref.py.schema @@ -0,0 +1,14 @@ +info: + title: Schema with a required property that has a referenced default value + +imports: + +required: + - number +properties: + number: + $ref: '#/default_val' + +default_val: + type: integer + default: my_name diff --git a/expandybird/test/schemas/required.jinja.schema b/expandybird/test/schemas/required.jinja.schema new file mode 100644 index 000000000..94c8e39f8 --- /dev/null +++ b/expandybird/test/schemas/required.jinja.schema @@ -0,0 +1,10 @@ +info: + title: Schema with a required property + +imports: + +required: + - name +properties: + name: + type: string diff --git a/expandybird/test/schemas/required_default.jinja.schema b/expandybird/test/schemas/required_default.jinja.schema new file mode 100644 index 000000000..d739e2c20 --- /dev/null +++ b/expandybird/test/schemas/required_default.jinja.schema @@ -0,0 +1,11 @@ +info: + title: Schema with a required property that has a default value + +imports: + +required: + - name +properties: + name: + type: string + default: my_name