From b04db39bff8f80056924524b189fad6356344400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otto?= Date: Sun, 14 Jul 2024 23:23:08 +0200 Subject: [PATCH] Improve sample generation --- fences/__init__.py | 4 +- fences/core/node.py | 80 +++++++------ fences/core/random.py | 16 --- fences/core/util.py | 12 ++ fences/json_schema/config.py | 9 +- fences/json_schema/normalize.py | 9 +- fences/json_schema/parse.py | 174 +++++++++++++++++++++------ fences/open_api/exceptions.py | 6 +- fences/open_api/generate.py | 140 ++++++++++++---------- fences/open_api/open_api.py | 60 +++++----- fences/xml_schema/parse.py | 4 + test/fixtures/json/aas_small.yaml | 188 ------------------------------ test/fixtures/open_api/aas.yml | 45 ------- test/json_schema/test_generate.py | 14 ++- test/open_api/test_generate.py | 19 +-- 15 files changed, 354 insertions(+), 426 deletions(-) diff --git a/fences/__init__.py b/fences/__init__.py index 53068c0..7961df4 100644 --- a/fences/__init__.py +++ b/fences/__init__.py @@ -1,5 +1,7 @@ +# Some convenience imports from .json_schema.parse import parse as parse_json_schema from .regex.parse import parse as parse_regex from .xml_schema.parse import parse as parse_xml_schema from .grammar.convert import convert as parse_grammar -from .open_api.generate import parse as parse_open_api +from .open_api.generate import parse_operation +from .open_api.open_api import OpenApi diff --git a/fences/core/node.py b/fences/core/node.py index 2f50248..5bec29a 100644 --- a/fences/core/node.py +++ b/fences/core/node.py @@ -17,7 +17,6 @@ class Node: def __init__(self, id: Optional[str] = None) -> None: self.id = id self.incoming_transitions: List["IncomingTransition"] = [] - self._has_valid_leafs: bool = False def apply(self, data: any) -> any: """ @@ -126,31 +125,33 @@ def _execute(self, path: Path, path_idx: int, data: any) -> Tuple[int, any]: idx = path[path_idx] return self.outgoing_transitions[idx].target._execute(path, path_idx+1, data) - def _generate(self, result_path: Path, already_reached: Set): + def _generate(self, result_path: Path, already_reached: Set) -> bool: already_reached.add(id(self)) if not isinstance(self, Decision): - return + return True if not self.outgoing_transitions: - return + return True + + satisfiable = True if self.all_transitions: for transition in self.outgoing_transitions: transition._num_paths += 1 - transition.target._generate(result_path, already_reached) + sub_satisfiable = transition.target._generate(result_path, already_reached) + satisfiable = satisfiable and sub_satisfiable else: selected = None min_paths = float('inf') for idx, transition in enumerate(self.outgoing_transitions): - target = transition.target - if transition._num_paths < min_paths and target._has_valid_leafs: + if transition._num_paths < min_paths and transition._satisfiable: selected = idx min_paths = transition._num_paths # No satisfiable transition found, fallback to an un-satisfiable one if selected is None: - print("No valid leaf detected, falling back to invalid one") + satisfiable = False + # print("No valid leaf detected, falling back to invalid one") for idx, transition in enumerate(self.outgoing_transitions): - target = transition.target if transition._num_paths < min_paths: selected = idx min_paths = transition._num_paths @@ -158,7 +159,9 @@ def _generate(self, result_path: Path, already_reached: Set): result_path.append(selected) transition: OutgoingTransition = self.outgoing_transitions[selected] transition._num_paths += 1 - transition.target._generate(result_path, already_reached) + sub_satisfiable = transition.target._generate(result_path, already_reached) + satisfiable = satisfiable and sub_satisfiable + return satisfiable def _backward(self, path: Path, already_reached: Set) -> "Node": already_reached.add(id(self)) @@ -178,42 +181,40 @@ def _backward(self, path: Path, already_reached: Set) -> "Node": root = predecessor_transition.source._backward(path, already_reached) return root - def _forward(self, backward_path: Path, forward_path: Path, visited: Set): + def _forward(self, backward_path: Path, forward_path: Path, visited: Set) -> bool: if len(backward_path) == 0: - return + return True assert isinstance(self, Decision) path_idx = backward_path.pop(-1) if self.all_transitions: + satisfiable = True for idx, transition in enumerate(self.outgoing_transitions): if idx == path_idx: - transition.target._forward( - backward_path, forward_path, visited) + s = transition.target._forward(backward_path, forward_path, visited) else: transition._num_paths += 1 - transition.target._generate(forward_path, visited) + s = transition.target._generate(forward_path, visited) + satisfiable = satisfiable and s else: transition = self.outgoing_transitions[path_idx] forward_path.append(path_idx) transition._num_paths += 1 - transition.target._forward(backward_path, forward_path, visited) + satisfiable = transition.target._forward(backward_path, forward_path, visited) - def _collect(self, visited: Set[str], valid_leafs: List["Leaf"], invalid_leafs: List["Leaf"]): - if id(self) in visited: - return - visited.add(id(self)) + return satisfiable - if isinstance(self, Leaf): - if self.is_valid: - valid_leafs.append(self) - else: - invalid_leafs.append(self) - self._has_valid_leafs = self.is_valid - else: - assert isinstance(self, Decision) - self._has_valid_leafs = False - for i in self.outgoing_transitions: - i.target._collect(visited, valid_leafs, invalid_leafs) - self._has_valid_leafs = self._has_valid_leafs or i.target._has_valid_leafs + def _mark_satisfiable(self): + + if isinstance(self, Decision): + if self.all_transitions: + if any(not i._satisfiable for i in self.outgoing_transitions): + return + + for i in self.incoming_transitions: + out = i.outgoing_transition() + if not out._satisfiable: + out._satisfiable = True + i.source._mark_satisfiable() def generate_paths(self) -> Generator[ResultEntry, None, None]: """ @@ -222,10 +223,16 @@ def generate_paths(self) -> Generator[ResultEntry, None, None]: """ # Reset counter, collect leafs - visited = set() valid_nodes: List[Leaf] = [] invalid_nodes: List[Leaf] = [] - self._collect(visited, valid_nodes, invalid_nodes) + for i in self.items(): + if isinstance(i, Leaf): + if i.is_valid: + valid_nodes.append(i) + else: + invalid_nodes.append(i) + for leaf in valid_nodes: + leaf._mark_satisfiable() # Visit valid nodes first to_visit = valid_nodes + invalid_nodes @@ -241,10 +248,10 @@ def generate_paths(self) -> Generator[ResultEntry, None, None]: # Follow path to the target node forward_path = [] - root._forward(backward_path, forward_path, visited) + satisfiable = root._forward(backward_path, forward_path, visited) # Yield - yield ResultEntry(next, forward_path, next.is_valid) + yield ResultEntry(next, forward_path, next.is_valid and satisfiable) # Remove the visited nodes path_idx = 0 @@ -282,6 +289,7 @@ class OutgoingTransition: def __init__(self, target: Node) -> None: self.target = target self._num_paths: int = 0 + self._satisfiable: bool = False class IncomingTransition: diff --git a/fences/core/random.py b/fences/core/random.py index ac668ce..559e29b 100644 --- a/fences/core/random.py +++ b/fences/core/random.py @@ -43,19 +43,3 @@ def generate_random_number(min_value: Optional[int] = None, max_value: Optional[ max_value = +1000 assert min_value <= max_value return random.randint(min_value, max_value) - - -def generate_random_format(format: str) -> str: - # From https://json-schema.org/understanding-json-schema/reference/string#built-in-formats - samples = { - "date-time": "2018-11-13T20:20:39+00:00", - "time": "20:20:39+00:00", - "date": "2018-11-13", - "duration": "P3D", - "email": "test@example.com", - "hostname": "example.com", - "ipv4": "127.0.0.1", - "ipv6": "2001:db8::8a2e:370:7334", - "uuid": "3e4666bf-d5e5-4aa7-b8ce-cefe41c7568a", - } - return samples.get(format, "") diff --git a/fences/core/util.py b/fences/core/util.py index c8647a2..8e35023 100644 --- a/fences/core/util.py +++ b/fences/core/util.py @@ -70,3 +70,15 @@ def render(self): def print(self): print_table(self.to_table()) + + def add(self, is_valid: bool, accepted: bool): + if is_valid: + if accepted: + self.valid_accepted += 1 + else: + self.valid_rejected += 1 + else: + if accepted: + self.invalid_accepted += 1 + else: + self.invalid_rejected += 1 diff --git a/fences/json_schema/config.py b/fences/json_schema/config.py index e338a69..8c44f96 100644 --- a/fences/json_schema/config.py +++ b/fences/json_schema/config.py @@ -2,16 +2,21 @@ from typing import Callable, Set, List, Dict from .json_pointer import JsonPointer -from ..core.random import StringProperties - from fences.core.node import Decision Handler = Callable[[dict, "Config", Set[str], JsonPointer], Decision] +@dataclass +class FormatSamples: + valid: List[str] = field(default_factory=list) + invalid: List[str] = field(default_factory=list) + + @dataclass class Config: key_handlers: Dict[str, Handler] type_handlers: Dict[str, Handler] default_samples: Dict[str, List[any]] normalize: bool + format_samples: Dict[str, FormatSamples] diff --git a/fences/json_schema/normalize.py b/fences/json_schema/normalize.py index ab55630..17e7e90 100644 --- a/fences/json_schema/normalize.py +++ b/fences/json_schema/normalize.py @@ -68,7 +68,6 @@ def _invert_items(items: dict): 'properties': _invert_properties, 'multipleOf': lambda x: {'type': ['number'], 'NOT_multipleOf': x}, 'required': lambda x: {'type': ['object'], 'properties': {i: False for i in x}}, - #'required': lambda x: {'type': ['object']}, 'items': _invert_items, 'minItems': lambda x: {'type': 'array', 'maxItems': x}, 'maxItems': lambda x: {'type': 'array', 'minItems': x}, @@ -117,6 +116,9 @@ def _float_gcd(a, b, rtol = 1e-05, atol = 1e-08): a, b = b, a % b return a +def _ignore(_, __) -> None: + return None + _simple_mergers = { 'required': lambda a, b: list(set(a) | set(b)), 'multipleOf': lambda a, b: abs(a*b) // _float_gcd(a, b), @@ -131,10 +133,11 @@ def _float_gcd(a, b, rtol = 1e-05, atol = 1e-08): 'maxLength': lambda a, b: min(a, b), 'enum': lambda a, b: a + b, 'format': lambda a, b: a, # todo - 'deprecated': lambda a, b: a or b, + 'deprecated': _ignore, 'NOT_enum': lambda a, b: a + b, 'enum': _merge_enums, - 'example': lambda _, __: None + 'example': _ignore, + 'discriminator': _ignore, } diff --git a/fences/json_schema/parse.py b/fences/json_schema/parse.py index a2d9ad6..0312a21 100644 --- a/fences/json_schema/parse.py +++ b/fences/json_schema/parse.py @@ -1,14 +1,15 @@ from typing import Set, Dict, List, Optional, Union from .exceptions import JsonSchemaException -from .config import Config +from .config import Config, FormatSamples from .json_pointer import JsonPointer -from ..core.random import generate_random_string, StringProperties, generate_random_format +from ..core.random import generate_random_string, StringProperties from .normalize import normalize from fences.core.node import Decision, Leaf, Node, Reference, NoOpLeaf, NoOpDecision from dataclasses import dataclass +import base64 @dataclass @@ -21,6 +22,8 @@ def set(self, value): self.ref[self.key] = value self.has_value = True + def get(self): + return self.ref[self.key] class SetValueLeaf(Leaf): @@ -106,7 +109,7 @@ def default_config(): return Config( key_handlers={ 'enum': parse_enum, - 'NOT_enum': parse_not_enum, + 'NOT_enum': parse_enum, '$ref': parse_reference, }, type_handlers={ @@ -126,6 +129,46 @@ def default_config(): 'array': [[]] }, normalize=True, + + # From https://json-schema.org/understanding-json-schema/reference/string#built-in-formats + format_samples = { + "datetime": FormatSamples( + valid=["2018-11-13T20:20:39+00:00"], + invalid=[] + ), + "time": FormatSamples( + valid=["20:20:39+00:00"], + invalid=[] + ), + "date": FormatSamples( + valid=["2018-11-13"], + invalid=[] + ), + "duration": FormatSamples( + valid=["P3D"], + invalid=[] + ), + "email": FormatSamples( + valid=["test@example.com"], + invalid=[] + ), + "hostname": FormatSamples( + valid=["example.com"], + invalid=[] + ), + "ipv4": FormatSamples( + valid=["127.0.0.1"], + invalid=[] + ), + "ipv6": FormatSamples( + valid=["2001:db8::8a2e:370:7334"], + invalid=[] + ), + "uuid": FormatSamples( + valid=["3e4666bf-d5e5-4aa7-b8ce-cefe41c7568a"], + invalid=[] + ), + } ) @@ -172,34 +215,19 @@ def _read_list(data: dict, key: str, unparsed_keys: Set[str], pointer: JsonPoint return _read_typesafe(data, key, unparsed_keys, list, 'list', pointer, default) -def parse_const(data, config: Config, unparsed_keys: Set[str], path: JsonPointer) -> Node: - value = data['const'] - unparsed_keys.remove('const') - - root = NoOpDecision(str(path), False) - root.add_transition(SetValueLeaf(None, True, value)) - root.add_transition(SetValueLeaf(None, False, f"{value}_INvALID")) - return root - - -def parse_not_enum(data: dict, config: Config, unparsed_keys: Set[str], path: JsonPointer) -> Node: - values = _read_list(data, 'NOT_enum', unparsed_keys, path) - return _parse_enum(values, path, True) - - def parse_enum(data: dict, config: Config, unparsed_keys: Set[str], path: JsonPointer) -> Node: - values = _read_list(data, 'enum', unparsed_keys, path) - return _parse_enum(values, path, False) - - -def _parse_enum(values: list, path: JsonPointer, invert: bool) -> Node: + invalid_values = set(_read_list(data, 'NOT_enum', unparsed_keys, path, [])) + valid_values = set(_read_list(data, 'enum', unparsed_keys, path, [])) + invalid_values = invalid_values - valid_values + valid_values = valid_values - invalid_values root = NoOpDecision(str(path), False) max_length = 0 - for value in values: - root.add_transition(SetValueLeaf(None, not invert, value)) + for value in valid_values: + root.add_transition(SetValueLeaf(None, True, value)) max_length = max(max_length, len(str(value))) - root.add_transition(SetValueLeaf( - None, invert, "#" * (max_length+1))) + invalid_values.add("#" * (max_length+1)) + for value in invalid_values: + root.add_transition(SetValueLeaf(None, False, value)) return root @@ -271,27 +299,38 @@ def parse_object(data: dict, config: Config, unparsed_keys: Set[str], path: Json def parse_string(data: dict, config: Config, unparsed_keys: Set[str], path: JsonPointer) -> Node: - root = NoOpDecision() pattern = _read_string(data, 'pattern', unparsed_keys, path, '') content_media_type = _read_string(data, 'contentMediaType', unparsed_keys, path, '') content_encoding = _read_string(data, 'contentEncoding', unparsed_keys, path, '') content_schema = _read_dict(data, 'contentSchema', unparsed_keys, path, {}) + min_length=_read_int(data, 'minLength', unparsed_keys, path, 0) + max_length=_read_int(data, 'maxLength', unparsed_keys, path, float("inf")) if pattern: regex = pattern else: regex = None - # TODO: use format format = _read_string(data, 'format', unparsed_keys, path, None) - if format is None: + root = NoOpDecision() + if format is None or format == "byte": properties = StringProperties( - min_length=_read_int(data, 'minLength', unparsed_keys, path, 0), - max_length=_read_int(data, 'maxLength', unparsed_keys, path, float("inf")), + min_length=min_length, + max_length=max_length, # pattern=regex, ) value = generate_random_string(properties) + if format == "byte": + root.add_transition(SetValueLeaf(None, False, value)) + value = base64.urlsafe_b64encode(value.encode()).decode() + root.add_transition(SetValueLeaf(None, True, value)) else: - value = generate_random_format(format) - root.add_transition(SetValueLeaf(None, True, value)) + try: + samples = config.format_samples[format] + except KeyError: + raise JsonSchemaException(f"Unknown format {format}", path) + for i in samples.valid: + root.add_transition(SetValueLeaf(None, True, i)) + for i in samples.invalid: + root.add_transition(SetValueLeaf(None, False, i)) return root def generate_default_samples(config: Config) -> Node: @@ -408,6 +447,7 @@ def parse_any_of_entry(entry: dict, config: Config, pointer: JsonPointer) -> Nod root = NoOpDecision(None, False) if 'type' in entry: types = entry['type'] + unparsed_keys.remove('type') if isinstance(types, str): types = [types] types = set(types) @@ -429,6 +469,12 @@ def parse_any_of_entry(entry: dict, config: Config, pointer: JsonPointer) -> Nod for sample in samples: root.add_transition(SetValueLeaf(None, False, sample)) + for i in ['deprecated', 'discriminator', 'check']: + try: + unparsed_keys.remove(i) + except KeyError: + pass + # if unparsed_keys: # raise JsonSchemaException(f"Unknown keys {unparsed_keys} at '{path}'", path) return root @@ -440,9 +486,65 @@ def parse_dict(data: dict, config: Config, pointer: JsonPointer) -> Node: any_of = _read_list(data, 'anyOf', unparsed_keys, pointer) for idx, entry in enumerate(any_of): - root.add_transition(parse_any_of_entry( + result = parse_any_of_entry( entry, config, pointer + 'anyOf' + idx - )) + ) + if 'check' in entry: + sub_root = NoOpDecision(all_transitions=True) + sub_root.add_transition(result) + check = entry['check'] + for ch in check: + if ch == 'Constraint_AASd-124': + class FixConstraint124(Leaf): + def apply(self, data: KeyReference) -> any: + try: + keys = data.get()['keys'] + except (KeyError, TypeError): + return data + if not isinstance(keys, list): + return data + keys.append({ + 'type': 'GlobalReference', + 'value': 'xyz' + }) + return data + sub_root.add_transition(FixConstraint124(is_valid=True)) + elif ch == 'Constraint_AASd-125': + class FixConstraint125(Leaf): + def apply(self, data: KeyReference) -> any: + try: + keys: list = data.get()['keys'] + except (KeyError, TypeError): + return data + if isinstance(keys, list): + for key in keys[1:]: + if isinstance(key, dict): + key['type'] = 'Range' + return data + sub_root.add_transition(FixConstraint125(is_valid=True)) + elif ch == 'Constraint_AASd-107': + class FixConstraint107(Leaf): + def apply(self, data: KeyReference) -> any: + l: list = data.get() + try: + l['semanticIdListElement'] = l['value'][0]['semanticId'] + except (KeyError, TypeError): + pass + return data + sub_root.add_transition(FixConstraint107(is_valid=True)) + elif ch == 'Constraint_AASd-108': + class FixConstraint108(Leaf): + def apply(self, data: KeyReference) -> any: + l: list = data.get() + try: + l['typeValueListElement'] = l['value'][0]['modelType'] + except (KeyError, TypeError): + pass + return data + sub_root.add_transition(FixConstraint108(is_valid=True)) + result = sub_root + + root.add_transition(result) return root diff --git a/fences/open_api/exceptions.py b/fences/open_api/exceptions.py index 481696a..acdc6bd 100644 --- a/fences/open_api/exceptions.py +++ b/fences/open_api/exceptions.py @@ -1,5 +1,9 @@ -from fences.core.exception import ParseException +from fences.core.exception import ParseException, FencesException class OpenApiException(ParseException): pass + + +class MissingDependencyException(FencesException): + pass diff --git a/fences/open_api/generate.py b/fences/open_api/generate.py index 41d7c5f..09142a0 100644 --- a/fences/open_api/generate.py +++ b/fences/open_api/generate.py @@ -1,7 +1,8 @@ -from typing import List, Dict, Optional, Tuple +from typing import List, Dict, Optional, Tuple, TYPE_CHECKING, Union from .open_api import OpenApi, Operation, ParameterPosition, Parameter from .format import format_parameter_value +from .exceptions import MissingDependencyException from fences.json_schema import parse as json_schema from fences.json_schema.normalize import normalize as normalize_schema @@ -10,7 +11,10 @@ from dataclasses import dataclass, field from urllib.parse import urlencode import json +from enum import IntEnum, auto +if TYPE_CHECKING: + import requests # optional dependency ListOfPairs = List[Tuple[any, any]] @@ -24,28 +28,31 @@ class Request: def dump(self, body_max_chars=80): print(f"{self.operation.method.upper()} {self.path}") - if self.body: + if self.body is not None: if len(self.body) > body_max_chars: b = self.body[:body_max_chars] + '...' else: b = self.body print(f" BODY: {b}") - def execute(self, host: str): - import requests # optional dependency + def execute(self, host: str) -> "requests.models.Response": + try: + import requests # optional dependency + except ImportError: + raise MissingDependencyException("Please install the requests library") if host.endswith('/'): host = host[:-1] - requests.request( + response = requests.request( url=host + self.path, method=self.operation.method, data=self.body, headers=dict(self.headers) ) + return response @dataclass class TestCase: - path: str operation: Operation body: Optional[str] = None query_parameters: ListOfPairs = field(default_factory=list) @@ -54,16 +61,19 @@ class TestCase: cookies: ListOfPairs = field(default_factory=list) def to_request(self) -> Request: - path = self.path + path = self.operation.path for key, value in self.path_parameters: - path = path.replace('{' + key + '}', value) + placeholder = '{' + key + '}' + assert placeholder in path, f"{placeholder}, {path}, {self.operation.operation_id}" + # assert value != "" # TODO + path = path.replace(placeholder, value) if self.query_parameters: path += "?" + urlencode(self.query_parameters) headers: ListOfPairs = self.headers.copy() # TODO: content type should not be hardcoded headers.append(('content-type', 'application/json')) body = None - if self.body: + if self.body is not None: body = json.dumps(self.body) return Request( path=path, @@ -83,27 +93,26 @@ def description(self) -> str: class StartTestCase(Decision): - def __init__(self, path: str, operation: Operation) -> None: + def __init__(self, operation: Operation) -> None: super().__init__(operation.operation_id, True) self.operation = operation - self.path = path def apply(self, data: any) -> any: - return TestCase(self.path, self.operation) + return TestCase(self.operation) def description(self) -> str: return "Start Test Case" class InsertParamLeaf(Leaf): - def __init__(self, is_valid: bool, parameter: Parameter, value: any) -> None: + def __init__(self, is_valid: bool, parameter: Parameter, raw_value: any) -> None: super().__init__(None, is_valid) self.parameter = parameter - self.value = value - self.formatted_value = format_parameter_value(parameter, value) + self.raw_value = raw_value + self.values = format_parameter_value(parameter, raw_value) def description(self) -> str: - return f"Insert {self.parameter.name} = {self.value} into {self.parameter.position.name}" + return f"Insert {self.parameter.name} = {self.raw_value} into {self.parameter.position.name}" def apply(self, data: TestCase) -> any: storage: ListOfPairs = { @@ -112,7 +121,7 @@ def apply(self, data: TestCase) -> any: ParameterPosition.PATH: data.path_parameters, ParameterPosition.COOKIE: data.cookies, }[self.parameter.position] - storage.extend(self.formatted_value) + storage.extend(self.values) return data @@ -143,14 +152,14 @@ def __init__(self) -> None: def _to_key(self, schema: any) -> str: return json.dumps(schema) - def add(self, schema: any, components: Dict[str, any]) -> Samples: + def add(self, schema: any) -> Samples: key = self._to_key(schema) if key in self.samples: return self.samples[key] composite_schema = { - 'components': components, 'minLength': 1, + 'minItems': 1, **schema, } composite_schema = normalize_schema(composite_schema, False) @@ -160,48 +169,61 @@ def add(self, schema: any, components: Dict[str, any]) -> Samples: samples = Samples() for i in graph.generate_paths(): sample = graph.execute(i.path) - if sample is not None: - if i.is_valid: - samples.valid.append(sample) - else: - samples.invalid.append(sample) - if len(samples.valid) + len(samples.invalid) > 50: + if sample is None: + continue + if i.is_valid: + samples.valid.append(sample) + else: + samples.invalid.append(sample) + if len(samples.valid) > 50 and len(samples.invalid) > 50: break if not samples.valid and not samples.invalid: - raise Exception(f"Schema has no string instances") + raise Exception(f"Schema has no instances") self.samples[key] = samples return samples - -def parse(open_api: any) -> Node: - openapi: OpenApi = OpenApi.from_dict(open_api) - sample_cache = SampleCache() - - # Create graph - root = NoOpDecision() - for path in openapi.paths: - for operation in path.operations: - op_root = StartTestCase(path.path, operation) - for param in operation.parameters: - param_root = NoOpDecision(f"{operation.operation_id}/{param.name}") - samples = sample_cache.add(param.schema, openapi.components) - for sample in samples.valid: - param_root.add_transition(InsertParamLeaf(True, param, sample)) - for sample in samples.invalid: - param_root.add_transition(InsertParamLeaf(False, param, sample)) - if param.position != ParameterPosition.PATH: - param_root.add_transition(NoOpLeaf(is_valid=not param.required)) - op_root.add_transition(param_root) - if operation.request_body: - body_root = NoOpDecision('BODY') - bodies = sample_cache.add(operation.request_body.schema, openapi.components) - for body in bodies.valid: - body_root.add_transition(InsertBodyLeaf(True, body)) - for body in bodies.invalid: - body_root.add_transition(InsertBodyLeaf(False, body)) - body_root.add_transition(NoOpLeaf(is_valid=not operation.request_body.required)) - op_root.add_transition(body_root) - op_root.add_transition(ExtractRequestLeaf()) - root.add_transition(op_root) - - return root +def generate_one(operation: Operation, sample_cache: SampleCache) -> Request: + test_case = TestCase(operation) + for param in operation.parameters: + pass + return test_case.to_request() + +def _is_suitable_for_path(sample: any) -> bool: + if isinstance(sample, list): + return len(sample) > 0 + if isinstance(sample, dict): + return len(sample.keys()) > 0 + if isinstance(sample, str): + return len(sample) > 0 + if sample is None: + return False + return True + +def parse_operation(operation: Operation, sample_cache: SampleCache, parameter_overwrites: Optional[Dict[str, any]] = None) -> Node: + op_root = StartTestCase(operation) + for param in operation.parameters: + param_root = NoOpDecision(f"{operation.operation_id}/{param.name}") + if parameter_overwrites and param.name in parameter_overwrites: + samples = Samples(valid=parameter_overwrites[param.name]) + else: + samples = sample_cache.add(param.schema) + + for sample in samples.valid: + if param.position != ParameterPosition.PATH or _is_suitable_for_path(sample): + param_root.add_transition(InsertParamLeaf(True, param, sample)) + for sample in samples.invalid: + if param.position != ParameterPosition.PATH or _is_suitable_for_path(sample): + param_root.add_transition(InsertParamLeaf(False, param, sample)) + param_root.add_transition(NoOpLeaf(is_valid=not param.required)) + op_root.add_transition(param_root) + if operation.request_body: + body_root = NoOpDecision('BODY') + bodies = sample_cache.add(operation.request_body.schema) + for body in bodies.valid: + body_root.add_transition(InsertBodyLeaf(True, body)) + for body in bodies.invalid: + body_root.add_transition(InsertBodyLeaf(False, body)) + body_root.add_transition(NoOpLeaf(is_valid=not operation.request_body.required)) + op_root.add_transition(body_root) + op_root.add_transition(ExtractRequestLeaf()) + return op_root diff --git a/fences/open_api/open_api.py b/fences/open_api/open_api.py index ffdccc5..11d7674 100644 --- a/fences/open_api/open_api.py +++ b/fences/open_api/open_api.py @@ -1,5 +1,7 @@ from typing import Any, Optional, List, Dict, Set, Any, Type -from dataclasses import dataclass +from typing_extensions import Self + +from dataclasses import dataclass, field from enum import Enum import warnings @@ -33,8 +35,8 @@ class Info: title: str @classmethod - def from_dict(cls, data: Any, json_path: str) -> "Info": - return cls( + def from_dict(self, data: Any, json_path: str) -> Self: + return Info( title=safe_dict_lookup(data, 'title', str, json_path) ) @@ -61,15 +63,17 @@ class Parameter: schema: dict @classmethod - def from_dict(cls: "Parameter", data: Any, json_path: str) -> "Parameter": + def from_dict(self, components: Any, data: Any, json_path: str) -> Self: pos = ParameterPosition(safe_dict_lookup(data, 'in', str, json_path)) - return cls( + schema = safe_dict_lookup(data, 'schema', dict, json_path) + schema['components'] = components + return Parameter( name=safe_dict_lookup(data, 'name', str, json_path), position=pos, required=safe_dict_lookup(data, "required", bool, json_path, pos == ParameterPosition.PATH), style=ParameterStyle(safe_dict_lookup(data, 'style', str, json_path, ParameterStyle.SIMPLE.value)), explode=safe_dict_lookup(data, 'explode', bool, json_path, False), - schema=safe_dict_lookup(data, 'schema', dict, json_path), + schema=schema ) @@ -80,7 +84,7 @@ class RequestBody: required: bool @classmethod - def from_dict(cls: "RequestBody", data: Any, json_path: str) -> "RequestBody": + def from_dict(self, components: Any, data: Any, json_path: str) -> Self: assert_type(data, dict, json_path) content = safe_dict_lookup(data, 'content', dict, json_path) json_content_type = 'application/json' @@ -93,10 +97,12 @@ def from_dict(cls: "RequestBody", data: Any, json_path: str) -> "RequestBody": json_content = {} else: json_content = safe_dict_lookup(content, json_content_type, dict, json_path) - return cls( + schema=safe_dict_lookup(json_content, 'schema', dict, json_path, {}) + schema['components'] = components + return RequestBody( description=safe_dict_lookup(data, 'description', str, json_path, ''), required=safe_dict_lookup(data, 'required', bool, json_path, True), - schema=safe_dict_lookup(json_content, 'schema', dict, json_path, {}) + schema=schema, ) @@ -106,7 +112,7 @@ class Response: schema: Optional[Dict] @classmethod - def from_dict(cls: "Response", code: str, data: Any, json_path) -> "Response": + def from_dict(self, code: str, data: Any, json_path) -> Self: content = safe_dict_lookup(data, 'content', dict, json_path, None) schema = None if content is not None: @@ -122,7 +128,7 @@ def from_dict(cls: "Response", code: str, data: Any, json_path) -> "Response": json_content = safe_dict_lookup(content, json_content_type, dict, json_path) schema = safe_dict_lookup(json_content, 'schema', dict, json_path, None) - return cls( + return Response( code=400 if code == 'default' else int(code), schema=schema, ) @@ -130,6 +136,7 @@ def from_dict(cls: "Response", code: str, data: Any, json_path) -> "Response": @dataclass class Operation: + path: str operation_id: str summary: str method: str @@ -139,19 +146,20 @@ class Operation: tags: Set[str] @classmethod - def from_dict(cls: "Operation", method: str, data: Any, json_path: str, resolver: Resolver) -> "Operation": + def from_dict(self, components: dict, path: str, method: str, data: Any, json_path: str) -> Self: assert_type(data, dict, json_path) request_body = safe_dict_lookup(data, 'requestBody', dict, json_path, None) - return cls( + return Operation( + path=path, operation_id=safe_dict_lookup(data, 'operationId', str, json_path), summary=safe_dict_lookup(data, 'summary', str, json_path, ''), method=method, parameters=[ - Parameter.from_dict(i, json_path + '.parameters.' + str(idx)) + Parameter.from_dict(components, i, json_path + '.parameters.' + str(idx)) for idx, i in enumerate(safe_dict_lookup(data, 'parameters', list, json_path, [])) ], - request_body=RequestBody.from_dict(request_body, json_path + '.' + 'requestBody') if request_body is not None else None, + request_body=RequestBody.from_dict(components, request_body, json_path + '.' + 'requestBody') if request_body is not None else None, responses=[ Response.from_dict(k, v, json_path + '.' + k) for k, v in safe_dict_lookup(data, 'responses', dict, json_path).items() @@ -166,12 +174,12 @@ class Path: operations: List[Operation] @classmethod - def from_dict(cls: "Path", path_name: str, data: Any, json_path: str, resolver: Resolver) -> "Path": + def from_dict(self, path_name: str, data: Any, json_path: str, resolver: Resolver) -> Self: assert_type(data, dict, json_path) operations: List[Operation] = [] for k, v in assert_type(data, dict, json_path).items(): operations.append(Operation.from_dict(k, v, json_path + '.' + k, resolver)) - return cls( + return Path( path=path_name, operations=operations ) @@ -180,19 +188,17 @@ def from_dict(cls: "Path", path_name: str, data: Any, json_path: str, resolver: @dataclass class OpenApi: info: Info - paths: List[Path] - components: dict + operations: Dict[str, Operation] = field(default_factory=dict) @classmethod - def from_dict(cls: "OpenApi", data: Any) -> "OpenApi": + def from_dict(self, data: Any) -> Self: assert_type(data, dict, '') - resolver = Resolver(data) - api = cls( + components=safe_dict_lookup(data, 'components', dict, '/', {}) + api = OpenApi( info=Info.from_dict(safe_dict_lookup(data, 'info', dict, '/'), 'info'), - paths=[ - Path.from_dict(path_name, path_info, 'paths.' + path_name, resolver) - for path_name, path_info in safe_dict_lookup(data, 'paths', dict, '/').items() - ], - components=safe_dict_lookup(data, 'components', dict, '/', {}), ) + for path_name, path_info in safe_dict_lookup(data, 'paths', dict, '/').items(): + for method, op_info in path_info.items(): + op = Operation.from_dict(components, path_name, method, op_info, f'/{path_name}/{method}') + api.operations[op.operation_id] = op return api diff --git a/fences/xml_schema/parse.py b/fences/xml_schema/parse.py index 6d3d7eb..cfb75aa 100644 --- a/fences/xml_schema/parse.py +++ b/fences/xml_schema/parse.py @@ -72,6 +72,10 @@ def default_config() -> Config: valid=lambda *_: [generate_random_number()], invalid=lambda *_: ["bar"] ), + 'bcpLangString': TypeGenerator( + valid=lambda *_: ['de', 'en'], + invalid=lambda *_: [ '' ], + ) }, restriction_handlers={ 'pattern': parse_string_restriction, diff --git a/test/fixtures/json/aas_small.yaml b/test/fixtures/json/aas_small.yaml index 0d7aec8..77704ee 100644 --- a/test/fixtures/json/aas_small.yaml +++ b/test/fixtures/json/aas_small.yaml @@ -100,36 +100,8 @@ $defs: DataTypeDefXsd: type: string enum: - - xs:anyURI - - xs:base64Binary - - xs:boolean - - xs:byte - - xs:date - - xs:dateTime - xs:decimal - - xs:double - - xs:duration - - xs:float - - xs:gDay - - xs:gMonth - - xs:gMonthDay - - xs:gYear - - xs:gYearMonth - - xs:hexBinary - - xs:int - xs:integer - - xs:long - - xs:negativeInteger - - xs:nonNegativeInteger - - xs:nonPositiveInteger - - xs:positiveInteger - - xs:short - - xs:string - - xs:time - - xs:unsignedByte - - xs:unsignedInt - - xs:unsignedLong - - xs:unsignedShort ValueWithType: anyOf: - properties: @@ -137,11 +109,6 @@ $defs: const: xs:string value: $ref: "#/$defs/StringType" - - properties: - valueType: - const: xs:boolean - value: - $ref: "#/$defs/BoolType" - properties: valueType: const: xs:decimal @@ -155,161 +122,6 @@ $defs: type: string format: xs:integer - - properties: - valueType: - const: xs:float - value: - type: string - format: xs:float - - properties: - valueType: - const: xs:double - value: - type: string - format: xs:double - - - properties: - valueType: - const: xs:date - value: - type: string - format: xs:date - - properties: - valueType: - const: xs:dateTime - value: - type: string - format: xs:dateTime - - properties: - valueType: - const: xs:time - value: - $ref: "#/$defs/TimeType" - - properties: - valueType: - const: xs:duration - value: - $ref: "#/$defs/DurationType" - - properties: - valueType: - const: xs:gDay - value: - $ref: "#/$defs/DayType" - - properties: - valueType: - const: xs:gMonth - value: - $ref: "#/$defs/MonthType" - - properties: - valueType: - const: xs:gMonthDay - value: - type: string - format: xs:gMonthDay - - properties: - valueType: - const: xs:gYear - value: - $ref: "#/$defs/YearType" - - properties: - valueType: - const: xs:gYearMonth - value: - $ref: "#/$defs/YearMonthType" - - - properties: - valueType: - const: xs:byte - value: - type: string - format: xs:byte - - properties: - valueType: - const: xs:short - value: - type: string - format: xs:short - - properties: - valueType: - const: xs:int - value: - type: string - format: xs:int - - properties: - valueType: - const: xs:long - value: - type: string - format: xs:long - - - properties: - valueType: - const: xs:negativeInteger - value: - type: string - format: xs:negativeInteger - - properties: - valueType: - const: xs:nonNegativeInteger - value: - type: string - format: xs:nonNegativeInteger - - properties: - valueType: - const: xs:nonPositiveInteger - value: - type: string - format: xs:nonPositiveInteger - - properties: - valueType: - const: xs:positiveInteger - value: - type: string - format: xs:positiveInteger - - - properties: - valueType: - const: xs:unsignedByte - value: - type: string - format: xs:unsignedByte - - properties: - valueType: - const: xs:unsignedInt - value: - type: string - format: xs:unsignedInt - - properties: - valueType: - const: xs:unsignedShort - value: - type: string - format: xs:unsignedShort - - properties: - valueType: - const: xs:unsignedLong - value: - type: string - format: xs:unsignedLong - - - properties: - valueType: - const: xs:base64Binary - value: - type: string - format: xs:base64Binary - - properties: - valueType: - const: xs:hexBinary - value: - $ref: "#/$defs/HexBinaryType" - - properties: - valueType: - const: xs:anyURI - value: - type: string - format: xs:anyURI - MinMaxWithType: anyOf: - properties: diff --git a/test/fixtures/open_api/aas.yml b/test/fixtures/open_api/aas.yml index 4999071..a7b2b4f 100644 --- a/test/fixtures/open_api/aas.yml +++ b/test/fixtures/open_api/aas.yml @@ -3011,15 +3011,6 @@ paths: schema: type: string format: byte - - name: aasIdentifier - in: path - description: The Asset Administration Shell’s unique id (UTF8-BASE64-URL-encoded) - required: true - style: simple - explode: false - schema: - type: string - format: byte - name: idShortPath in: path description: IdShort path to the submodel element (dot-separated) @@ -3097,15 +3088,6 @@ paths: schema: type: string format: byte - - name: aasIdentifier - in: path - description: The Asset Administration Shell’s unique id (UTF8-BASE64-URL-encoded) - required: true - style: simple - explode: false - schema: - type: string - format: byte - name: idShortPath in: path description: IdShort path to the submodel element (dot-separated) @@ -3176,15 +3158,6 @@ paths: schema: type: string format: byte - - name: aasIdentifier - in: path - description: The Asset Administration Shell’s unique id (UTF8-BASE64-URL-encoded) - required: true - style: simple - explode: false - schema: - type: string - format: byte - name: idShortPath in: path description: IdShort path to the submodel element (dot-separated) @@ -12703,15 +12676,6 @@ paths: summary: Synchronously invokes an Operation at a specified path operationId: InvokeOperation-ValueOnly_SubmodelRepo parameters: - - name: aasIdentifier - in: path - description: The Asset Administration Shell’s unique id (UTF8-BASE64-URL-encoded) - required: true - style: simple - explode: false - schema: - type: string - format: byte - name: submodelIdentifier in: path description: The Submodel’s unique id (UTF8-BASE64-URL-encoded) @@ -12876,15 +12840,6 @@ paths: summary: Asynchronously invokes an Operation at a specified path operationId: InvokeOperationAsync-ValueOnly_SubmodelRepo parameters: - - name: aasIdentifier - in: path - description: The Asset Administration Shell’s unique id (UTF8-BASE64-URL-encoded) - required: true - style: simple - explode: false - schema: - type: string - format: byte - name: submodelIdentifier in: path description: The Submodel’s unique id (UTF8-BASE64-URL-encoded) diff --git a/test/json_schema/test_generate.py b/test/json_schema/test_generate.py index 8c2bfa2..01ce255 100644 --- a/test/json_schema/test_generate.py +++ b/test/json_schema/test_generate.py @@ -1,4 +1,5 @@ from fences.json_schema import parse, normalize +from fences.json_schema.config import FormatSamples from fences.core.debug import check_consistency from jsonschema import Draft202012Validator, ValidationError import unittest @@ -441,11 +442,16 @@ def test_constraint_aasd_014(self): def test_aas(self): with open(os.path.join(SCRIPT_DIR, '..', 'fixtures', 'json', 'aas_small.yaml')) as file: schema = yaml.safe_load(file) - schema = normalize.normalize(schema, False) - schema['$schema'] = 'https://json-schema.org/draft/2020-12/schema' config = parse.default_config() - config.normalize = False - graph = parse.parse(schema) + config.format_samples = { + 'xs:decimal': FormatSamples( + valid=["1.0"], + ), + 'xs:integer': FormatSamples( + valid=["42"], + ), + } + graph = parse.parse(schema, config) check_consistency(graph) num_samples = 0 for i in graph.generate_paths(): diff --git a/test/open_api/test_generate.py b/test/open_api/test_generate.py index fe7fd99..299e763 100644 --- a/test/open_api/test_generate.py +++ b/test/open_api/test_generate.py @@ -1,4 +1,4 @@ -from fences.open_api.generate import parse, Request +from fences.open_api.generate import parse_operation, Request, SampleCache, OpenApi from fences.core.render import render import unittest @@ -11,14 +11,17 @@ class GenerateTestCase(unittest.TestCase): def check(self, schema, debug=False): - graph = parse(schema) - if debug: - render(graph).write_svg('graph.svg') - - for i in graph.generate_paths(): - request: Request = graph.execute(i.path) + sample_cache = SampleCache() + schema = OpenApi.from_dict(schema) + for operation in schema.operations.values(): + graph = parse_operation(operation, sample_cache) if debug: - request.dump() + render(graph).write_svg(f'graph_{operation.operation_id}.svg') + + for i in graph.generate_paths(): + request: Request = graph.execute(i.path) + if debug: + request.dump() def test_simple(self): schema = {