1# -*- coding: utf-8 -*- #
2# Copyright 2018 Google LLC. All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#    http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""JSON schema YAML validator module.
17
18Usage:
19
20  # Get a validator for the JSON schema in the file schema_path.
21  validator = yaml_validator.Validator(schema_path)
22  # Validate parsed YAML data.
23  validator.Validate(parsed_yaml_data)
24"""
25
26from __future__ import absolute_import
27from __future__ import division
28from __future__ import unicode_literals
29
30import io
31import os
32
33from googlecloudsdk.core import exceptions
34from googlecloudsdk.core import yaml
35from googlecloudsdk.core.util import pkg_resources
36
37import jsonschema
38
39
40class Error(exceptions.Error):
41  """Errors for this module."""
42
43
44class InvalidSchemaError(Error):
45  """JSON schema is invalid."""
46
47
48class InvalidSchemaVersionError(Error):
49  """JSON schema version is invalid."""
50
51
52class RefError(Error):
53  """Ref error -- YAML $ref path not found."""
54
55
56class ValidationError(Error):
57  """Validation error -- YAML data does not match the schema.
58
59  Attributes:
60      message: A user-readable error message describing the validation error.
61  """
62
63  def __init__(self, error):
64    super(ValidationError, self).__init__(error)
65    self.message = error.message
66
67
68class Validator(object):
69  """JSON schema validator."""
70
71  def __init__(self, schema_path):
72    """"Initilaizes the schema and validator for schema_path.
73
74    The validator resolves references to all other schemas in the directory
75    of schema_path.
76
77    Yes, it's really this ugly defining a validator with a resolver to
78    pkg_resources resources.
79
80    Raises:
81      IOError: if schema not found in installed resources.
82      files.Error: if schema file not found.
83      SchemaError: if the schema is invalid.
84
85    Args:
86      schema_path: JSON schema file path.
87
88    Returns:
89      The schema to validate and the validator.
90    """
91    schema_dir = os.path.dirname(schema_path)
92
93    class RefResolver(jsonschema.RefResolver):
94      """$ref: resolver that consults pkg_resources."""
95
96      @staticmethod
97      def resolve_remote(ref):
98        """pkg_resources $ref override -- schema_dir closure needed here."""
99        path = os.path.join(schema_dir, ref)
100        data = pkg_resources.GetResourceFromFile(path)
101        try:
102          schema = yaml.load(data)
103        except Exception as e:  # pylint: disable=broad-except, avoid crash
104          raise InvalidSchemaError(e)
105        self.ValidateSchemaVersion(schema, path)
106        return schema
107
108    try:
109      schema = yaml.load(pkg_resources.GetResourceFromFile(schema_path))
110    except Exception as e:  # pylint: disable=broad-except, avoid crash
111      raise InvalidSchemaError(e)
112    self.ValidateSchemaVersion(schema, schema_path)
113    resolver = RefResolver.from_schema(schema)
114    self._validator = jsonschema.validators.validator_for(schema)(
115        schema, resolver=resolver)
116    self._validate = self._validator.validate
117
118  def ValidateSchemaVersion(self, schema, path):
119    """Validates the parsed_yaml JSON schema version."""
120    try:
121      version = schema.get('$schema')
122    except AttributeError:
123      version = None
124    if (not version or
125        not version.startswith('http://json-schema.org/') or
126        not version.endswith('/schema#')):
127      raise InvalidSchemaVersionError(
128          'Schema [{}] version [{}] is invalid. Expected "$schema: '
129          'http://json-schema.org/*/schema#".'.format(path, version))
130
131  def Validate(self, parsed_yaml):
132    """Validates parsed_yaml against JSON schema.
133
134    Args:
135      parsed_yaml: YAML to validate
136
137    Raises:
138      ValidationError: if the template doesn't obey the schema.
139    """
140    try:
141      self._validate(parsed_yaml)
142    except jsonschema.RefResolutionError as e:
143      raise RefError(e)
144    except jsonschema.ValidationError as e:
145      raise ValidationError(e)
146
147  def ValidateWithDetailedError(self, parsed_yaml):
148    """Validates parsed_yaml against JSON schema.
149
150    Provides details of validation failure in the returned error message.
151    Args:
152      parsed_yaml: YAML to validate
153
154    Raises:
155      ValidationError: if the template doesn't obey the schema.
156    """
157    try:
158      self._validate(parsed_yaml)
159    except jsonschema.RefResolutionError as e:
160      raise RefError(e)
161    except jsonschema.exceptions.ValidationError as ve:
162      msg = io.StringIO()
163      msg.write('ERROR: Schema validation failed: {}\n\n'.format(ve))
164
165      if ve.cause:
166        additional_exception = 'Root Exception: {}'.format(ve.cause)
167      else:
168        additional_exception = ''
169
170      root_error = ve.context[-1] if ve.context else None
171      if root_error:
172        error_path = ''.join(
173            ('[{}]'.format(elem) for elem in root_error.absolute_path))
174      else:
175        error_path = ''
176
177      msg.write('Additional Details:\n'
178                'Error Message: {msg}\n\n'
179                'Failing Validation Schema: {schema}\n\n'
180                'Failing Element: {instance}\n\n'
181                'Failing Element Path: {path}\n\n'
182                '{additional_cause}\n'.format(
183                    msg=root_error.message if root_error else None,
184                    instance=root_error.instance if root_error else None,
185                    schema=root_error.schema if root_error else None,
186                    path=error_path,
187                    additional_cause=additional_exception))
188      ve.message = msg.getvalue()
189      raise ValidationError(ve)
190
191  def Iterate(self, parsed_yaml):
192    """Validates parsed_yaml against JSON schema and returns all errors.
193
194    Args:
195      parsed_yaml: YAML to validate
196
197    Raises:
198      ValidationError: if the template doesn't obey the schema.
199
200    Returns:
201      A list of all errors, empty if no validation errors.
202    """
203    return self._validator.iter_errors(parsed_yaml)
204