Test Failed
Push — master ( e380d0...f5671d )
by W
02:58
created

st2common/st2common/util/schema/__init__.py (1 issue)

1
# Licensed to the StackStorm, Inc ('StackStorm') under one or more
2
# contributor license agreements.  See the NOTICE file distributed with
3
# this work for additional information regarding copyright ownership.
4
# The ASF licenses this file to You under the Apache License, Version 2.0
5
# (the "License"); you may not use this file except in compliance with
6
# the License.  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
from __future__ import absolute_import
17
import os
18
import copy
19
20
import six
21
import jsonschema
22
from jsonschema import _validators
23
from jsonschema.validators import create
24
25
from st2common.exceptions.action import InvalidActionParameterException
26
from st2common.util import jsonify
27
from st2common.util.misc import deep_update
28
29
__all__ = [
30
    'get_validator',
31
    'get_draft_schema',
32
    'get_action_parameters_schema',
33
    'get_schema_for_action_parameters',
34
    'get_schema_for_resource_parameters',
35
    'is_property_type_single',
36
    'is_property_type_list',
37
    'is_property_type_anyof',
38
    'is_property_type_oneof',
39
    'is_property_nullable',
40
    'is_attribute_type_array',
41
    'is_attribute_type_object',
42
    'validate'
43
]
44
45
# https://github.com/json-schema/json-schema/blob/master/draft-04/schema
46
# The source material is licensed under the AFL or BSD license.
47
# Both draft 4 and custom schema has additionalProperties set to false by default.
48
# The custom schema differs from draft 4 with the extension of position, immutable,
49
# and draft 3 version of required.
50
PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)))
51
SCHEMAS = {
52
    'draft4': jsonify.load_file(os.path.join(PATH, 'draft4.json')),
53
    'custom': jsonify.load_file(os.path.join(PATH, 'custom.json')),
54
55
    # Custom schema for action params which doesn't allow parameter "type" attribute to be array
56
    'action_params': jsonify.load_file(os.path.join(PATH, 'action_params.json'))
57
}
58
59
SCHEMA_ANY_TYPE = {
60
    "anyOf": [
61
        {"type": "array"},
62
        {"type": "boolean"},
63
        {"type": "integer"},
64
        {"type": "number"},
65
        {"type": "object"},
66
        {"type": "string"}
67
    ]
68
}
69
70
RUNNER_PARAM_OVERRIDABLE_ATTRS = [
71
    'default',
72
    'description',
73
    'enum',
74
    'immutable',
75
    'required'
76
]
77
78
79
def get_draft_schema(version='custom', additional_properties=False):
80
    schema = copy.deepcopy(SCHEMAS[version])
81
    if additional_properties and 'additionalProperties' in schema:
82
        del schema['additionalProperties']
83
    return schema
84
85
86
def get_action_parameters_schema(additional_properties=False):
87
    """
88
    Return a generic schema which is used for validating action parameters definition.
89
    """
90
    return get_draft_schema(version='action_params', additional_properties=additional_properties)
91
92
93
CustomValidator = create(
94
    meta_schema=get_draft_schema(version='custom', additional_properties=True),
95
    validators={
96
        u"$ref": _validators.ref,
97
        u"additionalItems": _validators.additionalItems,
98
        u"additionalProperties": _validators.additionalProperties,
99
        u"allOf": _validators.allOf_draft4,
100
        u"anyOf": _validators.anyOf_draft4,
101
        u"dependencies": _validators.dependencies,
102
        u"enum": _validators.enum,
103
        u"format": _validators.format,
104
        u"items": _validators.items,
105
        u"maxItems": _validators.maxItems,
106
        u"maxLength": _validators.maxLength,
107
        u"maxProperties": _validators.maxProperties_draft4,
108
        u"maximum": _validators.maximum,
109
        u"minItems": _validators.minItems,
110
        u"minLength": _validators.minLength,
111
        u"minProperties": _validators.minProperties_draft4,
112
        u"minimum": _validators.minimum,
113
        u"multipleOf": _validators.multipleOf,
114
        u"not": _validators.not_draft4,
115
        u"oneOf": _validators.oneOf_draft4,
116
        u"pattern": _validators.pattern,
117
        u"patternProperties": _validators.patternProperties,
118
        u"properties": _validators.properties_draft3,
119
        u"type": _validators.type_draft4,
120
        u"uniqueItems": _validators.uniqueItems,
121
    },
122
    version="custom_validator",
123
)
124
125
126
def is_property_type_single(property_schema):
127
    return (isinstance(property_schema, dict) and
128
            'anyOf' not in list(property_schema.keys()) and
129
            'oneOf' not in list(property_schema.keys()) and
130
            not isinstance(property_schema.get('type', 'string'), list))
131
132
133
def is_property_type_list(property_schema):
134
    return (isinstance(property_schema, dict) and
135
            isinstance(property_schema.get('type', 'string'), list))
136
137
138
def is_property_type_anyof(property_schema):
139
    return isinstance(property_schema, dict) and 'anyOf' in list(property_schema.keys())
140
141
142
def is_property_type_oneof(property_schema):
143
    return isinstance(property_schema, dict) and 'oneOf' in list(property_schema.keys())
144
145
146
def is_property_nullable(property_type_schema):
147
    # For anyOf and oneOf, the property_schema is a list of types.
148
    if isinstance(property_type_schema, list):
149
        return len([t for t in property_type_schema
150
                    if ((isinstance(t, six.string_types) and t == 'null') or
151
                        (isinstance(t, dict) and t.get('type', 'string') == 'null'))]) > 0
152
153
    return (isinstance(property_type_schema, dict) and
154
            property_type_schema.get('type', 'string') == 'null')
155
156
157
def is_attribute_type_array(attribute_type):
158
    return (attribute_type == 'array' or
159
            (isinstance(attribute_type, list) and 'array' in attribute_type))
160
161
162
def is_attribute_type_object(attribute_type):
163
    return (attribute_type == 'object' or
164
            (isinstance(attribute_type, list) and 'object' in attribute_type))
165
166
167
def assign_default_values(instance, schema):
168
    """
169
    Assign default values on the provided instance based on the schema default specification.
170
    """
171
    instance = copy.deepcopy(instance)
172
    instance_is_dict = isinstance(instance, dict)
173
    instance_is_array = isinstance(instance, list)
174
175
    if not instance_is_dict and not instance_is_array:
176
        return instance
177
178
    properties = schema.get('properties', {})
179
180
    for property_name, property_data in six.iteritems(properties):
181
        has_default_value = 'default' in property_data
182
        default_value = property_data.get('default', None)
183
184
        # Assign default value on the instance so the validation doesn't fail if requires is true
185
        # but the value is not provided
186
        if has_default_value:
187
            if instance_is_dict and instance.get(property_name, None) is None:
188
                instance[property_name] = default_value
189
            elif instance_is_array:
190
                for index, _ in enumerate(instance):
191
                    if instance[index].get(property_name, None) is None:
192
                        instance[index][property_name] = default_value
193
194
        # Support for nested properties (array and object)
195
        attribute_type = property_data.get('type', None)
196
        schema_items = property_data.get('items', {})
197
198
        # Array
199
        if (is_attribute_type_array(attribute_type) and
200
                schema_items and schema_items.get('properties', {})):
201
            array_instance = instance.get(property_name, None)
202
            array_schema = schema['properties'][property_name]['items']
203
204
            if array_instance is not None:
205
                # Note: We don't perform subschema assignment if no value is provided
206
                instance[property_name] = assign_default_values(instance=array_instance,
207
                                                                schema=array_schema)
208
209
        # Object
210
        if is_attribute_type_object(attribute_type) and property_data.get('properties', {}):
211
            object_instance = instance.get(property_name, None)
212
            object_schema = schema['properties'][property_name]
213
214
            if object_instance is not None:
215
                # Note: We don't perform subschema assignment if no value is provided
216
                instance[property_name] = assign_default_values(instance=object_instance,
217
                                                                schema=object_schema)
218
219
    return instance
220
221
222
def modify_schema_allow_default_none(schema):
223
    """
224
    Manipulate the provided schema so None is also an allowed value for each attribute which
225
    defines a default value of None.
226
    """
227
    schema = copy.deepcopy(schema)
228
    properties = schema.get('properties', {})
229
230
    for property_name, property_data in six.iteritems(properties):
231
        is_optional = not property_data.get('required', False)
232
        has_default_value = 'default' in property_data
233
        default_value = property_data.get('default', None)
234
        property_schema = schema['properties'][property_name]
235
236
        if (has_default_value or is_optional) and default_value is None:
237
            # If property is anyOf and oneOf then it has to be process differently.
238
            if (is_property_type_anyof(property_schema) and
239
                    not is_property_nullable(property_schema['anyOf'])):
240
                property_schema['anyOf'].append({'type': 'null'})
241
            elif (is_property_type_oneof(property_schema) and
242
                    not is_property_nullable(property_schema['oneOf'])):
243
                property_schema['oneOf'].append({'type': 'null'})
244
            elif (is_property_type_list(property_schema) and
245
                    not is_property_nullable(property_schema.get('type'))):
246
                property_schema['type'].append('null')
247
            elif (is_property_type_single(property_schema) and
248
                    not is_property_nullable(property_schema.get('type'))):
249
                property_schema['type'] = [property_schema.get('type', 'string'), 'null']
250
251
        # Support for nested properties (array and object)
252
        attribute_type = property_data.get('type', None)
253
        schema_items = property_data.get('items', {})
254
255
        # Array
256
        if (is_attribute_type_array(attribute_type) and
257
                schema_items and schema_items.get('properties', {})):
258
            array_schema = schema_items
259
            array_schema = modify_schema_allow_default_none(schema=array_schema)
260
            schema['properties'][property_name]['items'] = array_schema
261
262
        # Object
263
        if is_attribute_type_object(attribute_type) and property_data.get('properties', {}):
264
            object_schema = property_data
265
            object_schema = modify_schema_allow_default_none(schema=object_schema)
266
            schema['properties'][property_name] = object_schema
267
268
    return schema
269
270
271
def validate(instance, schema, cls=None, use_default=True, allow_default_none=False, *args,
272
             **kwargs):
273
    """
274
    Custom validate function which supports default arguments combined with the "required"
275
    property.
276
277
    Note: This function returns cleaned instance with default values assigned.
278
279
    :param use_default: True to support the use of the optional "default" property.
280
    :type use_default: ``bool``
281
    """
282
283
    instance = copy.deepcopy(instance)
284
    schema_type = schema.get('type', None)
285
    instance_is_dict = isinstance(instance, dict)
286
287
    if use_default and allow_default_none:
288
        schema = modify_schema_allow_default_none(schema=schema)
289
290
    if use_default and schema_type == 'object' and instance_is_dict:
291
        instance = assign_default_values(instance=instance, schema=schema)
292
293
    # pylint: disable=assignment-from-no-return
294
    jsonschema.validate(instance=instance, schema=schema, cls=cls, *args, **kwargs)
295
296
    return instance
297
298
299
VALIDATORS = {
300
    'draft4': jsonschema.Draft4Validator,
301
    'custom': CustomValidator
302
}
303
304
305
def get_validator(version='custom'):
306
    validator = VALIDATORS[version]
307
    return validator
308
309
310
def validate_runner_parameter_attribute_override(action_ref, param_name, attr_name,
311
                                                 runner_param_attr_value, action_param_attr_value):
312
    """
313
    Validate that the provided parameter from the action schema can override the
314
    runner parameter.
315
    """
316
    param_values_are_the_same = action_param_attr_value == runner_param_attr_value
317
    if (attr_name not in RUNNER_PARAM_OVERRIDABLE_ATTRS and not param_values_are_the_same):
0 ignored issues
show
Unused Code Coding Style introduced by
There is an unnecessary parenthesis after if.
Loading history...
318
        raise InvalidActionParameterException(
319
            'The attribute "%s" for the runner parameter "%s" in action "%s" '
320
            'cannot be overridden.' % (attr_name, param_name, action_ref))
321
322
    return True
323
324
325
def get_schema_for_action_parameters(action_db):
326
    """
327
    Dynamically construct JSON schema for the provided action from the parameters metadata.
328
329
    Note: This schema is used to validate parameters which are passed to the action.
330
    """
331
    from st2common.util.action_db import get_runnertype_by_name
332
    runner_type = get_runnertype_by_name(action_db.runner_type['name'])
333
334
    # Note: We need to perform a deep merge because user can only specify a single parameter
335
    # attribute when overriding it in an action metadata.
336
    parameters_schema = {}
337
    deep_update(parameters_schema, runner_type.runner_parameters)
338
    deep_update(parameters_schema, action_db.parameters)
339
340
    # Perform validation, make sure user is not providing parameters which can't
341
    # be overriden
342
    runner_parameter_names = list(runner_type.runner_parameters.keys())
343
344
    for name, schema in six.iteritems(action_db.parameters):
345
        if name not in runner_parameter_names:
346
            continue
347
348
        for attribute, value in six.iteritems(schema):
349
            runner_param_value = runner_type.runner_parameters[name].get(attribute)
350
            validate_runner_parameter_attribute_override(action_ref=action_db.ref,
351
                                                         param_name=name,
352
                                                         attr_name=attribute,
353
                                                         runner_param_attr_value=runner_param_value,
354
                                                         action_param_attr_value=value)
355
356
    schema = get_schema_for_resource_parameters(parameters_schema=parameters_schema)
357
358
    if parameters_schema:
359
        schema['title'] = action_db.name
360
        if action_db.description:
361
            schema['description'] = action_db.description
362
363
    return schema
364
365
366
def get_schema_for_resource_parameters(parameters_schema, allow_additional_properties=False):
367
    """
368
    Dynamically construct JSON schema for the provided resource from the parameters metadata.
369
    """
370
    def normalize(x):
371
        return {k: v if v else SCHEMA_ANY_TYPE for k, v in six.iteritems(x)}
372
373
    schema = {}
374
    properties = {}
375
    properties.update(normalize(parameters_schema))
376
    if properties:
377
        schema['type'] = 'object'
378
        schema['properties'] = properties
379
        schema['additionalProperties'] = allow_additional_properties
380
381
    return schema
382