Completed
Push — master ( 93f1d5...fe49eb )
by W
05:30
created

st2common.util.schema.get_schema_for_action_parameters()   B

Complexity

Conditions 6

Size

Total Lines 30

Duplication

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