Merge "MuranoPL forms implementation"
This commit is contained in:
commit
7e9d9a4bec
|
@ -17,3 +17,8 @@ Name: ModelBuilder
|
|||
Usage: Meta
|
||||
Applies: Method
|
||||
Inherited: true
|
||||
|
||||
Properties:
|
||||
enabled:
|
||||
Contract: $.bool().notNull()
|
||||
Default: true
|
|
@ -17,3 +17,8 @@ Name: Hidden
|
|||
Usage: Meta
|
||||
Applies: [Property, Argument]
|
||||
Inherited: true
|
||||
|
||||
Properties:
|
||||
visible:
|
||||
Contract: $.bool().notNull()
|
||||
Default: false
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
# under the License.
|
||||
|
||||
Namespaces:
|
||||
=: io.murano.metadata
|
||||
=: io.murano.metadata.forms
|
||||
|
||||
Name: Position
|
||||
Usage: Meta
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
}
|
|
@ -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):
|
||||
|
|
|
@ -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
|
|
@ -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'))
|
|
@ -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.
|
Loading…
Reference in New Issue