Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/validation-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ on:
- "ontology/**"
- "examples/flights*.yaml"
- "examples/tpcds_semantic_model.yaml"
- "examples/p2p_behavior_effects_minimal.yaml"
- ".github/workflows/validation-ci.yml"
- "scripts/generate-spec-types.py"
- "scripts/generate-behavior-schema.py"
pull_request:
branches: ["main"]
paths:
Expand All @@ -40,8 +42,10 @@ on:
- "ontology/**"
- "examples/flights*.yaml"
- "examples/tpcds_semantic_model.yaml"
- "examples/p2p_behavior_effects_minimal.yaml"
- ".github/workflows/validation-ci.yml"
- "scripts/generate-spec-types.py"
- "scripts/generate-behavior-schema.py"

permissions:
contents: read
Expand Down Expand Up @@ -80,9 +84,15 @@ jobs:
- name: Check spec.yaml enums are in sync with ossie-schema.json
run: uv run scripts/generate-spec-types.py --check

- name: Check embedded behavior schema is in sync with its canonical source
run: python3 scripts/generate-behavior-schema.py --check

- name: Validate canonical example
run: uv run validation/validate.py examples/tpcds_semantic_model.yaml

- name: Validate behavior example
run: uv run validation/validate.py examples/p2p_behavior_effects_minimal.yaml

- name: Validate flights examples
run: |
uv run validation/validate.py examples/flights.yaml --schema ontology/ontology.json
Expand Down
164 changes: 164 additions & 0 deletions converters/orionbelt/tests/test_ossie_behavior_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.

"""The standalone converter needs only its packaged root schema, offline."""

import json
import socket
import urllib.request
from copy import deepcopy

import jsonschema
import pytest

from ossie_orionbelt.validation import _OSSIE_SCHEMA_PATH, validate_ossie


@pytest.fixture
def isolated_schema(tmp_path, monkeypatch):
def reject_request(*args, **kwargs):
raise AssertionError("Schema validation must not fetch a remote resource")

monkeypatch.setattr(socket.socket, "connect", reject_request)
monkeypatch.setattr(urllib.request, "urlopen", reject_request)
monkeypatch.setattr(jsonschema.validators, "urlopen", reject_request, raising=False)
path = tmp_path / "ossie-schema.json"
path.write_bytes(_OSSIE_SCHEMA_PATH.read_bytes())
assert list(tmp_path.iterdir()) == [path]
return path


@pytest.fixture
def document():
return {
"version": "0.2.0.dev0",
"name": "purchasing",
"datasets": [{"name": "orders", "source": "warehouse.public.orders"}],
"behavior": {
"namespace": "purchasing",
"behavior_layer_version": "1.0",
"actions": [{
"id": "approve_order",
"title": "Approve order",
"kind": "command",
"effects": [{
"entity": "dataset",
"mode": "write",
"impact_type": "state_transition",
"vendor_details": {"system": "ERP"},
}],
"vendor_details": {"system": "ERP"},
}],
"rules": [{
"id": "approval_required",
"title": "Approval required",
"severity": "error",
"when": {"operation": "approve_order"},
"constraint": {"approved": True},
"message": "An approval is required",
"vendor_details": {"system": "ERP"},
}],
"vendor_details": {"system": "ERP"},
},
}


@pytest.mark.parametrize("alias", ["actions", "action_types", "both"])
@pytest.mark.parametrize("empty_lists", [False, True])
def test_behavior_aliases_and_extensions_validate_offline(
isolated_schema, document, alias, empty_lists
):
behavior = document["behavior"]
if empty_lists:
behavior.update(actions=[], rules=[])
if alias == "action_types":
behavior[alias] = behavior.pop("actions")
elif alias == "both":
behavior["action_types"] = deepcopy(behavior["actions"])

# Match consumers that load one schema without a custom resolver/registry.
jsonschema.Draft202012Validator(json.loads(isolated_schema.read_text())).validate(document)
result = validate_ossie(document, schema_path=isolated_schema)

assert result.schema_validation_performed
assert result.valid, result.schema_errors + result.semantic_errors


@pytest.mark.parametrize(
"path,value",
[
(("namespace",), ""),
(("namespace",), 1),
(("behavior_layer_version",), ""),
(("behavior_layer_version",), 1),
(("actions",), {}),
(("rules",), "invalid"),
(("actions", 0, "id"), ""),
(("actions", 0, "kind"), "invalid"),
(("actions", 0, "effects", 0, "entity"), "invalid"),
(("actions", 0, "effects", 0, "mode"), "invalid"),
(("actions", 0, "effects", 0, "impact_type"), "invalid"),
(("rules", 0, "severity"), "invalid"),
(("rules", 0, "when"), "invalid"),
(("rules", 0, "constraint"), "invalid"),
(("rules", 0, "message"), ""),
],
)
def test_invalid_behavior_reports_schema_errors_offline(isolated_schema, document, path, value):
target = document["behavior"]
for part in path[:-1]:
target = target[part]
target[path[-1]] = value

result = validate_ossie(document, schema_path=isolated_schema)

assert result.schema_validation_performed
assert not result.valid
assert result.schema_errors


@pytest.mark.parametrize(
"path",
[
("namespace",), ("behavior_layer_version",), ("actions",), ("rules",),
("actions", 0, "id"), ("actions", 0, "title"),
("actions", 0, "effects", 0, "entity"),
("actions", 0, "effects", 0, "mode"),
("rules", 0, "id"), ("rules", 0, "title"), ("rules", 0, "severity"),
("rules", 0, "when"), ("rules", 0, "constraint"), ("rules", 0, "message"),
],
)
def test_missing_behavior_fields_are_rejected_offline(isolated_schema, document, path):
target = document["behavior"]
for part in path[:-1]:
target = target[part]
del target[path[-1]]

result = validate_ossie(document, schema_path=isolated_schema)

assert result.schema_validation_performed
assert not result.valid
assert result.schema_errors


def test_behavior_remains_optional_offline(isolated_schema, document):
del document["behavior"]

result = validate_ossie(document, schema_path=isolated_schema)

assert result.schema_validation_performed
assert result.valid, result.schema_errors + result.semantic_errors
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/

package org.apache.ossie.validator;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import java.io.InputStream;
import java.util.Map;
import java.util.stream.Stream;
import org.apache.ossie.exception.ValidationException;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.*;

class SchemaValidatorBehaviorTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static JsonSchema offlineSchema;

@BeforeAll
static void loadOnlyPackagedRootSchema() throws Exception {
try (InputStream input = SchemaValidatorBehaviorTest.class.getResourceAsStream(
SchemaValidator.OSSIE_SCHEMA_PATH)) {
assertNotNull(input);
// Keep the production factory's default draft; the explicit $schema
// selects 2020-12. Any external resource load fails immediately.
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7,
builder -> builder.schemaLoaders(loaders -> loaders.add(iri -> {
throw new AssertionError("Unexpected schema resource load: " + iri);
})));
offlineSchema = factory.getSchema(MAPPER.readTree(input));
}
}

@ParameterizedTest
@ValueSource(strings = {"actions", "action_types", "both"})
void acceptsAliasesAndVendorExtensionsOffline(String alias) throws Exception {
ObjectNode document = document();
ObjectNode behavior = (ObjectNode) document.get("behavior");
if (alias.equals("action_types")) {
behavior.set(alias, behavior.remove("actions"));
} else if (alias.equals("both")) {
behavior.set("action_types", behavior.get("actions").deepCopy());
}
assertValid(document);
}

@ParameterizedTest
@ValueSource(strings = {"actions", "action_types", "both"})
void acceptsEmptyListsOffline(String alias) throws Exception {
ObjectNode document = document();
ObjectNode behavior = (ObjectNode) document.get("behavior");
behavior.remove("actions");
behavior.putArray(alias.equals("both") ? "actions" : alias);
if (alias.equals("both")) {
behavior.putArray("action_types");
}
behavior.putArray("rules");
assertValid(document);
}

@Test
void behaviorRemainsOptionalOffline() throws Exception {
ObjectNode document = document();
document.remove("behavior");
assertValid(document);
}

@ParameterizedTest
@MethodSource("invalidValues")
void rejectsInvalidBehaviorOffline(String pointer, Object value) throws Exception {
ObjectNode document = document();
ObjectNode target = (ObjectNode) document.at(pointer.substring(0, pointer.lastIndexOf('/')));
target.set(pointer.substring(pointer.lastIndexOf('/') + 1), MAPPER.valueToTree(value));
assertInvalid(document);
}

static Stream<Arguments> invalidValues() {
return Stream.of(
Arguments.of("/behavior/namespace", ""),
Arguments.of("/behavior/namespace", 1),
Arguments.of("/behavior/behavior_layer_version", ""),
Arguments.of("/behavior/behavior_layer_version", 1),
Arguments.of("/behavior/actions", Map.of()),
Arguments.of("/behavior/rules", "invalid"),
Arguments.of("/behavior/actions/0/id", ""),
Arguments.of("/behavior/actions/0/kind", "invalid"),
Arguments.of("/behavior/actions/0/effects/0/entity", "invalid"),
Arguments.of("/behavior/actions/0/effects/0/mode", "invalid"),
Arguments.of("/behavior/actions/0/effects/0/impact_type", "invalid"),
Arguments.of("/behavior/rules/0/severity", "invalid"),
Arguments.of("/behavior/rules/0/when", "invalid"),
Arguments.of("/behavior/rules/0/constraint", "invalid"),
Arguments.of("/behavior/rules/0/message", ""));
}

@ParameterizedTest
@ValueSource(strings = {
"/behavior/namespace", "/behavior/behavior_layer_version", "/behavior/actions",
"/behavior/rules", "/behavior/actions/0/id", "/behavior/actions/0/title",
"/behavior/actions/0/effects/0/entity", "/behavior/actions/0/effects/0/mode",
"/behavior/rules/0/id", "/behavior/rules/0/title", "/behavior/rules/0/severity",
"/behavior/rules/0/when", "/behavior/rules/0/constraint", "/behavior/rules/0/message"
})
void rejectsMissingRequiredFieldsOffline(String pointer) throws Exception {
ObjectNode document = document();
ObjectNode target = (ObjectNode) document.at(pointer.substring(0, pointer.lastIndexOf('/')));
target.remove(pointer.substring(pointer.lastIndexOf('/') + 1));
assertInvalid(document);
}

private void assertValid(ObjectNode document) {
assertTrue(offlineSchema.validate(document).isEmpty());
new SchemaValidator(MAPPER, SchemaValidator.OSSIE_SCHEMA_PATH).validate(asMap(document));
}

private void assertInvalid(ObjectNode document) {
assertFalse(offlineSchema.validate(document).isEmpty());
ValidationException error = assertThrows(ValidationException.class,
() -> new SchemaValidator(MAPPER, SchemaValidator.OSSIE_SCHEMA_PATH).validate(asMap(document)));
assertTrue(error.getMessage().contains("Schema validation failed"));
}

private Map<String, Object> asMap(JsonNode document) {
return MAPPER.convertValue(document, new TypeReference<>() {});
}

private ObjectNode document() throws Exception {
return (ObjectNode) MAPPER.readTree("""
{
"version": "0.2.0.dev0",
"name": "purchasing",
"datasets": [{"name": "orders", "source": "warehouse.public.orders"}],
"behavior": {
"namespace": "purchasing",
"behavior_layer_version": "1.0",
"actions": [{
"id": "approve_order", "title": "Approve order", "kind": "command",
"effects": [{"entity": "dataset", "mode": "write",
"impact_type": "state_transition", "vendor_details": {"system": "ERP"}}],
"vendor_details": {"system": "ERP"}
}],
"rules": [{
"id": "approval_required", "title": "Approval required", "severity": "error",
"when": {"operation": "approve_order"}, "constraint": {"approved": true},
"message": "An approval is required", "vendor_details": {"system": "ERP"}
}],
"vendor_details": {"system": "ERP"}
}
}
""");
}
}
Loading
Loading