Merge "MuranoPL forms implementation"

This commit is contained in:
Jenkins 2016-08-01 14:56:07 +00:00 committed by Gerrit Code Review
commit 7e9d9a4bec
18 changed files with 1032 additions and 70 deletions

View File

@ -17,3 +17,8 @@ Name: ModelBuilder
Usage: Meta
Applies: Method
Inherited: true
Properties:
enabled:
Contract: $.bool().notNull()
Default: true

View File

@ -17,3 +17,8 @@ Name: Hidden
Usage: Meta
Applies: [Property, Argument]
Inherited: true
Properties:
visible:
Contract: $.bool().notNull()
Default: false

View File

@ -11,7 +11,7 @@
# under the License.
Namespaces:
=: io.murano.metadata
=: io.murano.metadata.forms
Name: Position
Usage: Meta

View File

@ -73,9 +73,9 @@ Classes:
io.murano.metadata.Description: metadata/Description.yaml
io.murano.metadata.HelpText: metadata/HelpText.yaml
io.murano.metadata.ModelBuilder: metadata/ModelBuilder.yaml
io.murano.metadata.Position: metadata/Position.yaml
io.murano.metadata.Title: metadata/Title.yaml
io.murano.metadata.forms.Hidden: metadata/forms/Hidden.yaml
io.murano.metadata.forms.Position: metadata/forms/Position.yaml
io.murano.metadata.forms.Section: metadata/forms/Section.yaml
io.murano.metadata.engine.Serialize: metadata/engine/Serialize.yaml
io.murano.metadata.engine.Synchronize: metadata/engine/Synchronize.yaml

View File

@ -28,7 +28,7 @@ import six
from webob import exc
import murano.api.v1
from murano.api.v1 import schemas
from murano.api.v1 import validation_schemas
from murano.common import exceptions
from murano.common import policy
import murano.common.utils as murano_utils
@ -235,7 +235,8 @@ class Controller(object):
file_obj, package_meta = _validate_body(body)
if package_meta:
try:
jsonschema.validate(package_meta, schemas.PKG_UPLOAD_SCHEMA)
jsonschema.validate(package_meta,
validation_schemas.PKG_UPLOAD_SCHEMA)
except jsonschema.ValidationError as e:
msg = _("Package schema is not valid: {reason}").format(
reason=e)

View File

@ -20,6 +20,7 @@ from murano.api.v1 import deployments
from murano.api.v1 import environments
from murano.api.v1 import instance_statistics
from murano.api.v1 import request_statistics
from murano.api.v1 import schemas
from murano.api.v1 import services
from murano.api.v1 import sessions
from murano.api.v1 import static_actions
@ -275,5 +276,14 @@ class API(wsgi.Router):
controller=req_stats_resource,
action='get',
conditions={'method': ['GET']})
schemas_resource = schemas.create_resource()
mapper.connect('/schemas/{class_name}/{method_names}',
controller=schemas_resource,
action='get_schema',
conditions={'method': ['GET']})
mapper.connect('/schemas/{class_name}',
controller=schemas_resource,
action='get_schema',
conditions={'method': ['GET']})
super(API, self).__init__(mapper)

View File

@ -1,4 +1,4 @@
# Copyright (c) 2013 Mirantis, Inc.
# Copyright (c) 2016 Mirantis, Inc.
#
# 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
@ -12,63 +12,48 @@
# License for the specific language governing permissions and limitations
# under the License.
# TODO(all): write detailed schema.
ENV_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
from oslo_log import log as logging
from oslo_messaging.rpc import client
import six
from webob import exc
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"}
},
"required": ["id", "name"]
}
from murano.api.v1 import request_statistics
from murano.common import policy
from murano.common import rpc
from murano.common import wsgi
PKG_UPLOAD_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"tags": {
"type": "array",
"minItems": 0,
"items": {"type": "string"},
"uniqueItems": True
},
"categories": {
"type": "array",
"minItems": 0,
"items": {"type": "string"},
"uniqueItems": True
},
"description": {"type": "string"},
"name": {"type": "string"},
"is_public": {"type": "boolean"},
"enabled": {"type": "boolean"}
},
"additionalProperties": False
}
LOG = logging.getLogger(__name__)
API_NAME = 'Schemas'
PKG_UPDATE_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": True
},
"categories": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": True
},
"description": {"type": "string"},
"name": {"type": "string"},
"is_public": {"type": "boolean"},
"enabled": {"type": "boolean"}
},
"additionalProperties": False,
"minProperties": 1,
}
class Controller(object):
@request_statistics.stats_count(API_NAME, 'GetSchema')
def get_schema(self, request, class_name, method_names=None):
LOG.debug('GetSchema:GetSchema')
target = {"class_name": class_name}
policy.check("get_schema", request.context, target)
class_version = request.GET.get('classVersion')
package_name = request.GET.get('packageName')
credentials = {
'token': request.context.auth_token,
'project_id': request.context.tenant
}
try:
methods = (list(
six.moves.map(six.text_type.strip, method_names.split(',')))
if method_names else [])
return rpc.engine().generate_schema(
credentials, class_name, methods,
class_version, package_name)
except client.RemoteError as e:
if e.exc_type in ('NoClassFound',
'NoPackageForClassFound',
'NoPackageFound'):
raise exc.HTTPNotFound(e.value)
raise
def create_resource():
return wsgi.Resource(Controller())

View File

@ -0,0 +1,74 @@
# Copyright (c) 2013 Mirantis, Inc.
#
# 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.
# TODO(all): write detailed schema.
ENV_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"}
},
"required": ["id", "name"]
}
PKG_UPLOAD_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"tags": {
"type": "array",
"minItems": 0,
"items": {"type": "string"},
"uniqueItems": True
},
"categories": {
"type": "array",
"minItems": 0,
"items": {"type": "string"},
"uniqueItems": True
},
"description": {"type": "string"},
"name": {"type": "string"},
"is_public": {"type": "boolean"},
"enabled": {"type": "boolean"}
},
"additionalProperties": False
}
PKG_UPDATE_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": True
},
"categories": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": True
},
"description": {"type": "string"},
"name": {"type": "string"},
"is_public": {"type": "boolean"},
"enabled": {"type": "boolean"}
},
"additionalProperties": False,
"minProperties": 1,
}

View File

@ -33,6 +33,7 @@ from murano.dsl import context_manager
from murano.dsl import dsl_exception
from murano.dsl import executor as dsl_executor
from murano.dsl import helpers
from murano.dsl import schema_generator
from murano.dsl import serializer
from murano.engine import execution_session
from murano.engine import package_loader
@ -57,7 +58,11 @@ class EngineService(service.Service):
self.server = None
def start(self):
endpoints = [TaskProcessingEndpoint(), StaticActionEndpoint()]
endpoints = [
TaskProcessingEndpoint(),
StaticActionEndpoint(),
SchemaEndpoint()
]
transport = messaging.get_transport(CONF)
s_target = target.Target('murano', 'tasks', server=str(uuid.uuid4()))
@ -103,6 +108,17 @@ class ContextManager(context_manager.ContextManager):
return context
class SchemaEndpoint(object):
@classmethod
def generate_schema(cls, context, *args, **kwargs):
session = execution_session.ExecutionSession()
session.token = context['token']
session.project_id = context['project_id']
with package_loader.CombinedPackageLoader(session) as pkg_loader:
return schema_generator.generate_schema(
pkg_loader, ContextManager(), *args, **kwargs)
class TaskProcessingEndpoint(object):
@classmethod
def handle_task(cls, context, task):

View File

@ -43,6 +43,16 @@ class EngineClient(object):
def call_static_action(self, task):
return self._client.call({}, 'call_static_action', task=task)
def generate_schema(self, credentials, class_name, method_names=None,
class_version=None, package_name=None):
return self._client.call(
credentials, 'generate_schema',
class_name=class_name,
method_names=method_names,
class_version=class_version,
package_name=package_name
)
def api():
global TRANSPORT

View File

@ -38,7 +38,7 @@ import six
import webob.dec
import webob.exc
from murano.api.v1 import schemas
from murano.api.v1 import validation_schemas
from murano.common import config
from murano.common import exceptions
from murano.common.i18n import _, _LE, _LW
@ -918,7 +918,8 @@ class JSONPatchDeserializer(TextDeserializer):
property_to_update = {change_path: change['value']}
try:
jsonschema.validate(property_to_update, schemas.PKG_UPDATE_SCHEMA)
jsonschema.validate(property_to_update,
validation_schemas.PKG_UPDATE_SCHEMA)
except jsonschema.ValidationError as e:
LOG.error(_LE("Schema validation error occured: {error}")
.format(error=e))

View File

@ -29,8 +29,8 @@ class MetaProvider(object):
class MetaData(MetaProvider):
def __init__(self, definition, target, scope_type):
scope_type = weakref.ref(scope_type)
def __init__(self, definition, target, declaring_type):
declaring_type = weakref.proxy(declaring_type)
definition = helpers.list_value(definition)
factories = []
used_types = set()
@ -43,7 +43,7 @@ class MetaData(MetaProvider):
else:
name = d
props = {}
type_obj = helpers.resolve_type(name, scope_type())
type_obj = helpers.resolve_type(name, declaring_type)
if type_obj.usage != dsl_types.ClassUsages.Meta:
raise ValueError('Only Meta classes can be attached')
if target not in type_obj.targets:
@ -56,10 +56,15 @@ class MetaData(MetaProvider):
'with cardinality One')
used_types.add(type_obj)
factory_maker = lambda template: \
lambda context: helpers.get_object_store().load(
template, owner=None,
context=context, scope_type=scope_type())
def factory_maker(template):
def instantiate(context):
obj = helpers.get_object_store().load(
template, owner=None,
context=context, scope_type=declaring_type)
obj.declaring_type = declaring_type
return obj
return instantiate
factories.append(factory_maker({type_obj: props}))
self._meta_factories = factories
@ -107,3 +112,15 @@ def merge_providers(initial_class, producer, context):
meta = merger([initial_class], set())
return list(six.moves.map(operator.itemgetter(1), meta))
def aggregate_meta(provider, context, group_by_name=True):
key_func = lambda m: m.type.name if group_by_name else m.type
meta = provider.get_meta(context)
result = {}
for item in meta:
if item.type.cardinality == dsl_types.MetaCardinality.One:
result[key_func(item)] = item
else:
result.setdefault(key_func(item), []).append(item)
return result

View File

@ -538,3 +538,21 @@ def _create_meta_class(cls, name, ns_resolver, data, package, *args, **kwargs):
meta_cls.cardinality = cardinality
meta_cls.inherited = inherited
return meta_cls
def weigh_type_hierarchy(cls):
"""Weighs classes in type hierarchy by their distance from the root
:param cls: root of hierarchy
:return: dictionary that has class name as keys and distance from the root
a values. Root class has always a distance of 0. If the class
(or different versions of that class) is achievable through
several paths the shortest distance is used.
"""
result = {}
for c, w in helpers.traverse(
[(cls, 0)], lambda t: six.moves.map(
lambda p: (p, t[1] + 1), t[0].parents(cls))):
result.setdefault(c.name, w)
return result

View File

@ -0,0 +1,508 @@
# Copyright (c) 2016 Mirantis Inc.
#
# 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 six
from yaql.language import exceptions as yaql_exceptions
from yaql.language import expressions
from yaql.language import specs
from yaql.language import utils
from yaql.language import yaqltypes
from murano.dsl import constants
from murano.dsl import dsl
from murano.dsl import dsl_types
from murano.dsl import executor
from murano.dsl import helpers
from murano.dsl import meta as meta_module
from murano.dsl import murano_type
def generate_schema(pkg_loader, context_manager,
class_name, method_names=None,
class_version=None, package_name=None):
"""Generate JSON schema
JSON Schema is generated either for the class with all model builders
or for specified model builders only. The return value is a dictionary
with keys being model builder names and the values are JSON schemas for
them. The class itself is represented by an empty string key.
"""
if method_names and not isinstance(method_names, (list, tuple)):
method_names = (method_names,)
version = helpers.parse_version_spec(class_version)
if package_name:
package = pkg_loader.load_package(package_name, version)
else:
package = pkg_loader.load_class_package(class_name, version)
cls = package.find_class(class_name, search_requirements=False)
exc = executor.MuranoDslExecutor(pkg_loader, context_manager)
with helpers.with_object_store(exc.object_store):
context = prepare_context(exc, cls)
model_builders = set(list_model_builders(cls, context))
method_names = model_builders.intersection(
method_names or model_builders)
result = {
name: generate_entity_schema(
get_entity(cls, name), context, cls,
get_meta(cls, name, context))
for name in method_names
}
return result
def list_model_builders(cls, context):
"""List model builder names of the class
Yield names of all model builders (static actions marked with appropriate
metadata) plus empty string for the class itself.
"""
yield ''
for method_name in cls.all_method_names:
try:
method = cls.find_single_method(method_name)
if not method.is_action or not method.is_static:
continue
meta = meta_module.aggregate_meta(method, context)
is_builder = meta.get('io.murano.metadata.ModelBuilder')
if is_builder and is_builder.get_property('enabled'):
yield method.name
except Exception:
pass
def get_meta(cls, method_name, context):
"""Get metadata dictionary for the method or class"""
if not method_name:
return meta_module.aggregate_meta(cls, context)
method = cls.find_single_method(method_name)
return meta_module.aggregate_meta(method, context)
def get_entity(cls, method_name):
"""Get MuranoMethod of the class by its name"""
if not method_name:
return cls
method = cls.find_single_method(method_name)
return method
def get_properties(entity):
"""Get properties/arg scheme of the class/method"""
if isinstance(entity, dsl_types.MuranoType):
properties = entity.all_property_names
result = {}
for prop_name in properties:
prop = entity.find_single_property(prop_name)
if prop.usage not in (dsl_types.PropertyUsages.In,
dsl_types.PropertyUsages.InOut):
continue
result[prop_name] = prop
return result
return entity.arguments_scheme
def prepare_context(exc, cls):
"""Registers alternative implementations of contract YAQL functions"""
context = exc.create_object_context(cls).create_child_context()
context[constants.CTX_NAMES_SCOPE] = cls
context.register_function(string_)
context.register_function(int_)
context.register_function(bool_)
context.register_function(not_null)
context.register_function(check)
context.register_function(class_factory(context))
context.register_function(owned)
context.register_function(not_owned)
context.register_function(finalize)
return context
def generate_entity_schema(entity, context, declaring_type, meta):
"""Generate schema for single class or method by it DSL entity"""
properties = get_properties(entity)
type_weights = murano_type.weigh_type_hierarchy(declaring_type)
schema = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'type': 'object',
'properties': {
name: generate_property_schema(prop, context, type_weights)
for name, prop in six.iteritems(properties)
},
'additionalProperties': False,
'formSections': generate_sections(meta, type_weights)
}
schema.update(generate_ui_hints(entity, context, type_weights))
return schema
def generate_sections(meta, type_weights):
"""Builds sections definitions for the schema
Sections are UI hint for UI for grouping inputs into tabs/group-boxes.
The code collects section definitions from type hierarchy considering that
the section might be redefined in ancestor with the different section
index and then re-enumerates them in a way that sections from the most
base classes in hierarchy will get lower index values and there be no
two sections with the same index.
"""
section_list = meta.get('io.murano.metadata.forms.Section', [])
sections_map = {}
for section in section_list:
name = section.get_property('name')
ex_section = sections_map.get(name)
if not ex_section:
pass
elif (type_weights[ex_section.declaring_type.name] <
type_weights[section.declaring_type.name]):
continue
elif (type_weights[ex_section.declaring_type.name] ==
type_weights[section.declaring_type.name]):
index = section.get_property('index')
if index is None:
continue
ex_index = ex_section.get_property('index')
if ex_index is not None and ex_index <= index:
continue
sections_map[name] = section
ordered_sections, unordered_sections = sort_by_index(
sections_map.values(), type_weights)
sections = {}
index = 0
for section in ordered_sections:
name = section.get_property('name')
if name not in sections:
sections[name] = {
'title': section.get_property('title'),
'index': index
}
index += 1
for section in unordered_sections:
name = section.get_property('name')
if name not in sections:
sections[name] = {
'title': section.get_property('title')
}
return sections
def generate_property_schema(prop, context, type_weights):
"""Generate schema for single property/argument"""
schema = translate(prop.contract.spec, context)
if prop.has_default:
schema['default'] = prop.default
schema.update(generate_ui_hints(prop, context, type_weights))
return schema
def generate_ui_hints(entity, context, type_weights):
"""Translate know property/arg meta into json-schema UI hints"""
schema = {}
meta = meta_module.aggregate_meta(entity, context)
for cls_name, schema_prop, meta_prop in (
('io.murano.metadata.Title', 'title', 'text'),
('io.murano.metadata.Description', 'description', 'text'),
('io.murano.metadata.HelpText', 'helpText', 'text'),
('io.murano.metadata.forms.Hidden', 'visible', 'visible')):
value = meta.get(cls_name)
if value is not None:
schema[schema_prop] = value.get_property(meta_prop)
position = meta.get('io.murano.metadata.forms.Position')
if position:
schema['formSection'] = position.get_property('section')
index = position.get_property('index')
if index is not None:
schema['formIndex'] = (
(position.get_property('index') + 1) * len(type_weights) -
type_weights[position.declaring_type.name])
return schema
def sort_by_index(meta, type_weights, property_name='index'):
"""Sorts meta definitions by its distance in the class hierarchy"""
has_index = six.moves.filter(
lambda m: m.get_property(property_name) is not None, meta)
has_no_index = six.moves.filter(
lambda m: m.get_property(property_name) is None, meta)
return (
sorted(has_index,
key=lambda m: (
(m.get_property(property_name) + 1) *
len(type_weights) -
type_weights[m.declaring_type.name])),
has_no_index)
class Schema(object):
"""Container object to define YAQL contracts on"""
def __init__(self, data):
self.data = data
def __repr__(self):
return repr(self.data)
@specs.parameter('schema', Schema)
@specs.method
def string_(schema):
"""Implementation of string() contract that generates schema instead"""
types = 'string'
if '_notNull' not in schema.data:
types = [types] + ['null']
return Schema({
'type': types
})
def class_factory(context):
"""Factory for class() contract function that generates schema instead"""
@specs.parameter('schema', Schema)
@specs.parameter('name', dsl.MuranoTypeParameter(
nullable=False, context=context))
@specs.parameter('default_name', dsl.MuranoTypeParameter(
nullable=True, context=context))
@specs.parameter('version_spec', yaqltypes.String(True))
@specs.method
def class_(schema, name, default_name=None, version_spec=None):
types = 'muranoObject'
if '_notNull' not in schema.data:
types = [types] + ['null']
return Schema({
'type': types,
'muranoType': name.type.name
})
return class_
@specs.parameter('schema', Schema)
@specs.method
def not_owned(schema):
"""Implementation of notOwned() contract that generates schema instead"""
schema.data['owned'] = False
return schema
@specs.parameter('schema', Schema)
@specs.method
def owned(schema):
"""Implementation of owned() contract that generates schema instead"""
schema.data['owned'] = True
return schema
@specs.parameter('schema', Schema)
@specs.method
def int_(schema):
"""Implementation of int() contract that generates schema instead"""
types = 'integer'
if '_notNull' not in schema.data:
types = [types] + ['null']
return Schema({
'type': types
})
@specs.parameter('schema', Schema)
@specs.method
def bool_(schema):
"""Implementation of bool() contract that generates schema instead"""
types = 'boolean'
if '_notNull' not in schema.data:
types = [types] + ['null']
return Schema({
'type': types
})
@specs.parameter('schema', Schema)
@specs.method
def not_null(schema):
"""Implementation of notNull() contract that generates schema instead"""
types = schema.data.get('type')
if isinstance(types, list) and 'null' in types:
types.remove('null')
if len(types) == 1:
types = types[0]
schema.data['type'] = types
schema.data['_notNull'] = True
return schema
@specs.inject('up', yaqltypes.Super())
@specs.name('#finalize')
def finalize(obj, up):
"""Wrapper around YAQL contracts that removes temporary schema data"""
res = up(obj)
if isinstance(res, Schema):
res = res.data
if isinstance(res, dict):
res.pop('_notNull', None)
return res
@specs.parameter('expr', yaqltypes.YaqlExpression())
@specs.parameter('schema', Schema)
@specs.method
def check(schema, expr, engine, context):
"""Implementation of check() contract that generates schema instead"""
rest = [True]
while rest:
if (isinstance(expr, expressions.BinaryOperator) and
expr.operator == 'and'):
rest = expr.args[1]
expr = expr.args[0]
else:
rest = []
res = extract_pattern(expr, engine, context)
if res is not None:
schema.data.update(res)
expr = rest
return schema
def extract_pattern(expr, engine, context):
"""Translation of certain known patterns of check() contract expressions"""
if isinstance(expr, expressions.BinaryOperator):
ops = ('>', '<', '>=', '<=')
if expr.operator in ops:
op_index = ops.index(expr.operator)
if is_dollar(expr.args[0]):
constant = evaluate_constant(expr.args[1], engine, context)
if constant is None:
return None
elif is_dollar(expr.args[1]):
constant = evaluate_constant(expr.args[0], engine, context)
if constant is None:
return None
op_index = -1 - op_index
else:
return None
op = ops[op_index]
if op == '>':
return {'minimum': constant, 'exclusiveMinimum': True}
elif op == '>=':
return {'minimum': constant, 'exclusiveMinimum': False}
if op == '<':
return {'maximum': constant, 'exclusiveMaximum': True}
elif op == '<=':
return {'maximum': constant, 'exclusiveMaximum': False}
elif expr.operator == 'in' and is_dollar(expr.args[0]):
lst = evaluate_constant(expr.args[1], engine, context)
if isinstance(lst, tuple):
return {'enum': list(lst)}
elif (expr.operator == '.' and is_dollar(expr.args[0]) and
isinstance(expr.args[1], expressions.Function)):
func = expr.args[1]
if func.name == 'matches':
constant = evaluate_constant(func.args[0], engine, context)
if constant is not None:
return {'pattern': constant}
def is_dollar(expr):
"""Check $-expressions in YAQL AST"""
return (isinstance(expr, expressions.GetContextValue) and
expr.path.value in ('$', '$1'))
def evaluate_constant(expr, engine, context):
"""Evaluate yaql expression into constant value if possible"""
if isinstance(expr, expressions.Constant):
return expr.value
context = context.create_child_context()
trap = utils.create_marker('trap')
context['$'] = trap
@specs.parameter('name', yaqltypes.StringConstant())
@specs.name('#get_context_data')
def get_context_data(name, context):
res = context[name]
if res is trap:
raise yaql_exceptions.ResolutionError()
return res
context.register_function(get_context_data)
try:
return expressions.Statement(expr, engine).evaluate(context=context)
except yaql_exceptions.YaqlException:
return None
def translate(contract, context):
"""Translates contracts into json-schema equivalents"""
if isinstance(contract, dict):
return translate_dict(contract, context)
elif isinstance(contract, list):
return translate_list(contract, context)
elif isinstance(contract, (dsl_types.YaqlExpression,
expressions.Statement)):
context = context.create_child_context()
context['$'] = Schema({})
return contract(context=context)
def translate_dict(contract, context):
"""Translates dictionary contracts into json-schema objects"""
properties = {}
additional_properties = False
for key, value in six.iteritems(contract):
if isinstance(key, dsl_types.YaqlExpression):
additional_properties = translate(value, context)
else:
properties[key] = translate(value, context)
return {
'type': 'object',
'properties': properties,
'additionalProperties': additional_properties
}
def translate_list(contract, context):
"""Translates list contracts into json-schema arrays"""
items = []
for value in contract:
if isinstance(value, int):
pass
else:
items.append(translate(value, context))
if len(items) == 0:
return {'type': 'array'}
elif len(items) == 1:
return {
'type': 'array',
'items': items[0],
}
else:
return {
'type': 'array',
'items': items,
'additionalItems': items[-1]
}

View File

@ -31,6 +31,10 @@ class TypeScheme(object):
def __init__(self, spec):
self._spec = spec
@property
def spec(self):
return self._spec
@staticmethod
def prepare_transform_context(root_context, this, owner, default,
calling_type):

View File

@ -0,0 +1,125 @@
Namespaces:
m: io.murano.metadata
mf: io.murano.metadata.forms
Name: TestSchema
Meta:
- mf:Section:
name: mySection
title: Section Title
index: 1
Properties:
stringProperty:
Contract: $.string()
stringNotNullProperty:
Contract: $.string().notNull()
intProperty:
Contract: $.int()
intNotNullProperty:
Contract: $.int().notNull()
boolProperty:
Contract: $.bool()
boolNotNullProperty:
Contract: $.bool().notNull()
listProperty:
Contract:
- $.string().notNull()
dictProperty:
Contract:
key1: $.string().notNull()
key2: $.string().notNull()
$.string().notNull(): $.int()
classProperty:
Contract: $.class(SampleClass1)
defaultProperty:
Contract: $.int()
Default: 999
complexProperty:
Contract:
$.string(): [$.int().notNull()]
minimumContract:
Contract: $.int().notNull().check($ >= 5)
maximumContract:
Contract: $.int().notNull().check($ < 15)
rangeContract:
Contract: $.int().notNull().check($ > 0 and $ <= 10)
chainContract:
Contract: $.int().notNull().check($ > 0).check($ <= 10)
regexContract:
Contract: $.string().notNull().check($.matches(`\d+`))
enumContract:
Contract: $.string().notNull().check($ in [a, b])
enumFuncContract:
Contract: $.string().notNull().check($ in $this.staticEnumMethod())
decoratedProperty:
Contract: $.string().notNull()
Meta:
- m:Title:
text: Title!
- m:Description:
text: Description!
- m:HelpText:
text: Help!
- mf:Hidden
- mf:Position:
index: 1
section: mySection
Methods:
staticEnumMethod:
Body:
Return:
- x
- y
Usage: Static
modelBuilder:
Meta:
- m:ModelBuilder
- m:Title:
text: Model Builder!
Usage: Static
Scope: Public
Arguments:
- arg1:
Meta:
m:Title:
text: Arg1!
Contract: $.string().notNull()
- arg2:
Contract: $.int().notNull()
invalidModelBuilder1:
Meta:
- m:ModelBuilder
invalidModelBuilder2:
Meta:
- m:ModelBuilder
Usage: Static
invalidModelBuilder3:
Meta:
- m:ModelBuilder
Scope: Public

View File

@ -0,0 +1,175 @@
# Copyright (c) 2016 Mirantis, Inc.
#
# 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.
from testtools import matchers
from murano.dsl import schema_generator
from murano.tests.unit.dsl.foundation import runner
from murano.tests.unit.dsl.foundation import test_case
class TestSchemaGeneration(test_case.DslTestCase):
def setUp(self):
super(TestSchemaGeneration, self).setUp()
schema = schema_generator.generate_schema(
self.package_loader, runner.TestContextManager({}),
'TestSchema')
self._class_schema = schema.pop('')
self._model_builders_schema = schema
def test_general_structure(self):
self.assertIn('$schema', self._class_schema)
self.assertIn('type', self._class_schema)
self.assertIn('properties', self._class_schema)
self.assertEqual(
'http://json-schema.org/draft-04/schema#',
self._class_schema['$schema'])
self.assertEqual('object', self._class_schema['type'])
def _test_simple_property(self, name_or_schema, types):
if not isinstance(name_or_schema, dict):
props = self._class_schema['properties']
self.assertIn(name_or_schema, props)
schema = props[name_or_schema]
else:
schema = name_or_schema
self.assertIn('type', schema)
if isinstance(types, list):
self.assertItemsEqual(schema['type'], types)
else:
self.assertEqual(schema['type'], types)
return schema
def test_string_property(self):
self._test_simple_property('stringProperty', ['null', 'string'])
def test_not_null_string_property(self):
self._test_simple_property('stringNotNullProperty', 'string')
def test_int_property(self):
self._test_simple_property('intProperty', ['null', 'integer'])
def test_not_null_int_property(self):
self._test_simple_property('intNotNullProperty', 'integer')
def test_bool_property(self):
self._test_simple_property('boolProperty', ['null', 'boolean'])
def test_not_null_bool_property(self):
self._test_simple_property('boolNotNullProperty', 'boolean')
def test_class_property(self):
schema = self._test_simple_property(
'classProperty', ['null', 'muranoObject'])
self.assertEqual('SampleClass1', schema.get('muranoType'))
def test_default_property(self):
schema = self._test_simple_property(
'defaultProperty', ['null', 'integer'])
self.assertEqual(999, schema.get('default'))
def test_list_property(self):
schema = self._test_simple_property('listProperty', 'array')
self.assertIn('items', schema)
items = schema['items']
self._test_simple_property(items, 'string')
def test_dict_property(self):
schema = self._test_simple_property('dictProperty', 'object')
self.assertIn('properties', schema)
props = schema['properties']
self.assertIn('key1', props)
self._test_simple_property(props['key1'], 'string')
self.assertIn('key2', props)
self._test_simple_property(props['key2'], 'string')
self.assertIn('additionalProperties', schema)
extra_props = schema['additionalProperties']
self._test_simple_property(extra_props, ['null', 'integer'])
def test_complex_property(self):
schema = self._test_simple_property('complexProperty', 'object')
self.assertIn('properties', schema)
self.assertEqual({}, schema['properties'])
self.assertIn('additionalProperties', schema)
extra_props = schema['additionalProperties']
self._test_simple_property(extra_props, 'array')
self.assertIn('items', extra_props)
items = extra_props['items']
self._test_simple_property(items, 'integer')
def test_minimum_contract(self):
schema = self._test_simple_property('minimumContract', 'integer')
self.assertFalse(schema.get('exclusiveMinimum', True))
self.assertEqual(5, schema.get('minimum'))
def test_maximum_contract(self):
schema = self._test_simple_property('maximumContract', 'integer')
self.assertTrue(schema.get('exclusiveMaximum', False))
self.assertEqual(15, schema.get('maximum'))
def test_range_contract(self):
schema = self._test_simple_property('rangeContract', 'integer')
self.assertFalse(schema.get('exclusiveMaximum', True))
self.assertTrue(schema.get('exclusiveMinimum', False))
self.assertEqual(0, schema.get('minimum'))
self.assertEqual(10, schema.get('maximum'))
def test_chain_contract(self):
schema = self._test_simple_property('chainContract', 'integer')
self.assertFalse(schema.get('exclusiveMaximum', True))
self.assertTrue(schema.get('exclusiveMinimum', False))
self.assertEqual(0, schema.get('minimum'))
self.assertEqual(10, schema.get('maximum'))
def test_regex_contract(self):
schema = self._test_simple_property('regexContract', 'string')
self.assertEqual(r'\d+', schema.get('pattern'))
def test_enum_contract(self):
schema = self._test_simple_property('enumContract', 'string')
self.assertEqual(['a', 'b'], schema.get('enum'))
def test_enum_func_contract(self):
schema = self._test_simple_property('enumFuncContract', 'string')
self.assertEqual(['x', 'y'], schema.get('enum'))
def test_ui_hints(self):
schema = self._test_simple_property('decoratedProperty', 'string')
self.assertEqual('Title!', schema.get('title'))
self.assertEqual('Description!', schema.get('description'))
self.assertEqual('Help!', schema.get('helpText'))
self.assertFalse(schema.get('visible'))
self.assertThat(schema.get('formIndex'), matchers.GreaterThan(-1))
self.assertEqual('mySection', schema.get('formSection'))
sections = self._class_schema.get('formSections')
self.assertIsInstance(sections, dict)
section = sections.get('mySection')
self.assertIsInstance(section, dict)
self.assertThat(section.get('index'), matchers.GreaterThan(-1))
self.assertEqual('Section Title', section.get('title'))
def test_model_builders(self):
self.assertEqual(1, len(self._model_builders_schema))
schema = self._model_builders_schema.get('modelBuilder')
self.assertIsInstance(schema, dict)
self._class_schema = schema
self.test_general_structure()
self.assertEqual('Model Builder!', schema.get('title'))
args = schema['properties']
self._test_simple_property(args.get('arg1'), 'string')
self._test_simple_property(args.get('arg2'), 'integer')
arg1 = args['arg1']
self.assertEqual('Arg1!', arg1.get('title'))

View File

@ -0,0 +1,8 @@
---
features:
- New engine RPC call to generate json-schema from MuranoPL class.
Schema may be generated either from entire class or for specific
model builders - static actions that can be used to generate
object model from their input. Class schema is built by inspecting
class properties and method schema using the same algorithm but applied
to its arguments.