679 lines
21 KiB
Python
679 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from wf_api.schema_projection import (
|
|
project_output_property_to_state_schema,
|
|
project_property_to_schema_path,
|
|
project_schema_path_to_schema_path,
|
|
schema_fragment_at_location,
|
|
schema_fragment_at_path,
|
|
schema_location_is_explicit,
|
|
schema_path_exists,
|
|
validate_json_value_at_schema_path,
|
|
)
|
|
|
|
|
|
def test_schema_location_is_explicit_is_total_for_invalid_array_positions() -> None:
|
|
assert not schema_location_is_explicit(
|
|
{"type": "array"},
|
|
(0,),
|
|
label="source schema",
|
|
)
|
|
assert not schema_location_is_explicit(
|
|
{"type": "object", "properties": {}},
|
|
(0,),
|
|
label="source schema",
|
|
)
|
|
assert not schema_location_is_explicit(
|
|
{"type": "array", "prefixItems": [{"type": "string"}]},
|
|
(-1,),
|
|
label="source schema",
|
|
)
|
|
|
|
|
|
def test_project_output_property_copies_schema_and_defs() -> None:
|
|
state_schema = {
|
|
"type": "object",
|
|
"properties": {"before": {"type": "object"}},
|
|
}
|
|
output_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"after": {"$ref": "#/$defs/Snapshot"},
|
|
},
|
|
"$defs": {
|
|
"Snapshot": {
|
|
"type": "object",
|
|
"properties": {"clicked": {"type": "boolean"}},
|
|
"required": ["clicked"],
|
|
}
|
|
},
|
|
}
|
|
|
|
projected = project_output_property_to_state_schema(
|
|
state_schema=state_schema,
|
|
output_schema=output_schema,
|
|
output_field="after",
|
|
state_field="after",
|
|
)
|
|
|
|
assert projected["properties"]["before"] == {"type": "object"}
|
|
assert projected["properties"]["after"] == {"$ref": "#/$defs/Snapshot"}
|
|
assert projected["$defs"]["Snapshot"]["properties"]["clicked"] == {
|
|
"type": "boolean"
|
|
}
|
|
assert "after" not in state_schema["properties"]
|
|
|
|
|
|
def test_project_output_property_rejects_missing_output_field() -> None:
|
|
with pytest.raises(ValueError, match="output field 'after'"):
|
|
project_output_property_to_state_schema(
|
|
state_schema={"type": "object", "properties": {}},
|
|
output_schema={"type": "object", "properties": {}},
|
|
output_field="after",
|
|
state_field="after",
|
|
)
|
|
|
|
|
|
def test_project_output_property_rejects_conflicting_defs() -> None:
|
|
with pytest.raises(ValueError, match=r"conflicting \$defs.Snapshot"):
|
|
project_output_property_to_state_schema(
|
|
state_schema={
|
|
"type": "object",
|
|
"properties": {},
|
|
"$defs": {"Snapshot": {"type": "string"}},
|
|
},
|
|
output_schema={
|
|
"type": "object",
|
|
"properties": {"after": {"$ref": "#/$defs/Snapshot"}},
|
|
"$defs": {"Snapshot": {"type": "object"}},
|
|
},
|
|
output_field="after",
|
|
state_field="after",
|
|
)
|
|
|
|
|
|
def test_project_output_property_rejects_non_object_state_schema() -> None:
|
|
with pytest.raises(ValueError, match="state_schema must be an object schema"):
|
|
project_output_property_to_state_schema(
|
|
state_schema={"type": "array", "items": {"type": "string"}},
|
|
output_schema={
|
|
"type": "object",
|
|
"properties": {"after": {"type": "object"}},
|
|
},
|
|
output_field="after",
|
|
state_field="after",
|
|
)
|
|
|
|
|
|
def test_project_output_property_rejects_existing_state_field() -> None:
|
|
with pytest.raises(ValueError, match="state field 'after' already exists"):
|
|
project_output_property_to_state_schema(
|
|
state_schema={
|
|
"type": "object",
|
|
"properties": {"after": {"type": "string"}},
|
|
},
|
|
output_schema={
|
|
"type": "object",
|
|
"properties": {"after": {"type": "object"}},
|
|
},
|
|
output_field="after",
|
|
state_field="after",
|
|
)
|
|
|
|
|
|
def test_project_output_property_allows_equivalent_existing_state_field() -> None:
|
|
state_schema = {
|
|
"type": "object",
|
|
"properties": {"after": {"type": "object"}},
|
|
}
|
|
|
|
projected = project_output_property_to_state_schema(
|
|
state_schema=state_schema,
|
|
output_schema={
|
|
"type": "object",
|
|
"properties": {"after": {"type": "object"}},
|
|
},
|
|
output_field="after",
|
|
state_field="after",
|
|
allow_existing_equivalent=True,
|
|
)
|
|
|
|
assert projected == state_schema
|
|
|
|
|
|
def test_project_output_property_rejects_invalid_output_schema() -> None:
|
|
with pytest.raises(ValueError, match="output_schema is not valid JSON Schema"):
|
|
project_output_property_to_state_schema(
|
|
state_schema={"type": "object", "properties": {}},
|
|
output_schema={
|
|
"type": "object",
|
|
"properties": {"after": {"type": "definitely-not-jsonschema"}},
|
|
},
|
|
output_field="after",
|
|
state_field="after",
|
|
)
|
|
|
|
|
|
def test_project_schema_property_inserts_nested_path_and_defs() -> None:
|
|
projected = project_property_to_schema_path(
|
|
target_schema={"type": "object", "properties": {}},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"after": {"$ref": "#/$defs/Snapshot"}},
|
|
"$defs": {
|
|
"Snapshot": {
|
|
"type": "object",
|
|
"properties": {"clicked": {"type": "boolean"}},
|
|
}
|
|
},
|
|
},
|
|
source_field="after",
|
|
target_parts=("session", "after"),
|
|
)
|
|
|
|
assert projected["properties"]["session"]["type"] == "object"
|
|
assert projected["properties"]["session"]["properties"]["after"] == {
|
|
"$ref": "#/$defs/Snapshot"
|
|
}
|
|
assert projected["$defs"]["Snapshot"]["properties"]["clicked"] == {
|
|
"type": "boolean"
|
|
}
|
|
|
|
|
|
def test_project_schema_property_rejects_existing_nested_target() -> None:
|
|
with pytest.raises(ValueError, match="schema path 'session.after' already exists"):
|
|
project_property_to_schema_path(
|
|
target_schema={
|
|
"type": "object",
|
|
"properties": {
|
|
"session": {
|
|
"type": "object",
|
|
"properties": {"after": {"type": "string"}},
|
|
}
|
|
},
|
|
},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"after": {"type": "object"}},
|
|
},
|
|
source_field="after",
|
|
target_parts=("session", "after"),
|
|
)
|
|
|
|
|
|
def test_project_schema_property_rejects_non_object_ancestor() -> None:
|
|
with pytest.raises(ValueError, match="schema path 'session' is not an object"):
|
|
project_property_to_schema_path(
|
|
target_schema={
|
|
"type": "object",
|
|
"properties": {"session": {"type": "string"}},
|
|
},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"after": {"type": "object"}},
|
|
},
|
|
source_field="after",
|
|
target_parts=("session", "after"),
|
|
)
|
|
|
|
|
|
def test_project_schema_path_copies_inline_nested_source() -> None:
|
|
projected = project_schema_path_to_schema_path(
|
|
target_schema={"type": "object", "properties": {}},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {
|
|
"report": {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
}
|
|
},
|
|
},
|
|
source_parts=("report", "title"),
|
|
target_parts=("document", "title"),
|
|
)
|
|
|
|
assert projected["properties"]["document"]["properties"]["title"] == {
|
|
"type": "string"
|
|
}
|
|
|
|
|
|
def test_schema_fragment_selects_inline_nested_field() -> None:
|
|
fragment = schema_fragment_at_path(
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"request": {
|
|
"type": "object",
|
|
"properties": {"format": {"type": "string"}},
|
|
}
|
|
},
|
|
},
|
|
("request", "format"),
|
|
)
|
|
|
|
assert fragment == {"type": "string"}
|
|
|
|
|
|
def test_schema_fragment_preserves_defs_for_selected_reference() -> None:
|
|
fragment = schema_fragment_at_path(
|
|
{
|
|
"type": "object",
|
|
"properties": {"request": {"$ref": "#/$defs/Request"}},
|
|
"$defs": {
|
|
"Request": {
|
|
"type": "object",
|
|
"properties": {"format": {"type": "string"}},
|
|
}
|
|
},
|
|
},
|
|
("request",),
|
|
label="capability input schema",
|
|
)
|
|
|
|
assert fragment["$ref"] == "#/$defs/Request"
|
|
assert fragment["$defs"]["Request"]["properties"]["format"] == {"type": "string"}
|
|
|
|
|
|
def test_schema_fragment_accepts_whole_schema() -> None:
|
|
schema = {"type": "object", "properties": {"title": {"type": "string"}}}
|
|
|
|
assert schema_fragment_at_path(schema, ()) == schema
|
|
|
|
|
|
def test_schema_fragment_navigates_object_properties_and_additional_properties() -> (
|
|
None
|
|
):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"request": {
|
|
"type": "object",
|
|
"properties": {"format": {"type": "string"}},
|
|
"additionalProperties": {"type": "integer"},
|
|
}
|
|
},
|
|
}
|
|
|
|
assert schema_fragment_at_location(schema, ("request", "format")) == {
|
|
"type": "string"
|
|
}
|
|
assert schema_fragment_at_location(schema, ("request", "retries")) == {
|
|
"type": "integer"
|
|
}
|
|
|
|
|
|
def test_schema_fragment_navigates_homogeneous_array_items() -> None:
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"items": {"type": "array", "items": {"type": "string"}}},
|
|
}
|
|
|
|
assert schema_fragment_at_location(schema, ("items", 4)) == {"type": "string"}
|
|
|
|
|
|
def test_schema_fragment_navigates_tuple_prefix_items_and_rejects_out_of_range() -> (
|
|
None
|
|
):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"pair": {
|
|
"type": "array",
|
|
"prefixItems": [{"type": "string"}, {"type": "integer"}],
|
|
"items": False,
|
|
}
|
|
},
|
|
}
|
|
|
|
assert schema_fragment_at_location(schema, ("pair", 0)) == {"type": "string"}
|
|
assert schema_fragment_at_location(schema, ("pair", 1)) == {"type": "integer"}
|
|
with pytest.raises(ValueError, match="array position 2"):
|
|
schema_fragment_at_location(schema, ("pair", 2))
|
|
|
|
|
|
@pytest.mark.parametrize("reference_key", ["$defs", "definitions"])
|
|
def test_schema_fragment_navigates_local_definition_references(
|
|
reference_key: str,
|
|
) -> None:
|
|
definition_name = "Request"
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"request": {"$ref": f"#/{reference_key}/{definition_name}"}},
|
|
reference_key: {
|
|
definition_name: {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
}
|
|
},
|
|
}
|
|
|
|
assert schema_fragment_at_location(schema, ("request", "title"))["type"] == "string"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("reference", "message"),
|
|
[
|
|
("#/$defs/Loop", "cyclic reference"),
|
|
("#/$defs/Missing", "unresolved reference"),
|
|
("https://example.com/schema.json", "unsupported reference"),
|
|
],
|
|
)
|
|
def test_schema_fragment_rejects_unusable_local_or_remote_references(
|
|
reference: str,
|
|
message: str,
|
|
) -> None:
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"value": {"$ref": reference}},
|
|
"$defs": {"Loop": {"$ref": "#/$defs/Loop"}},
|
|
}
|
|
|
|
with pytest.raises(ValueError, match=message):
|
|
schema_fragment_at_location(schema, ("value",))
|
|
|
|
|
|
def test_schema_fragment_rejects_remote_selected_reference() -> None:
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="unsupported reference 'https://example.com/request.json'",
|
|
):
|
|
schema_fragment_at_path(
|
|
{
|
|
"type": "object",
|
|
"properties": {"request": {"$ref": "https://example.com/request.json"}},
|
|
},
|
|
("request",),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("schema", "value"),
|
|
[
|
|
({"type": "string"}, "markdown"),
|
|
(
|
|
{
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
"required": ["title"],
|
|
},
|
|
{"title": "Report"},
|
|
),
|
|
({"type": "array", "items": {"type": "integer"}}, [1, 2]),
|
|
({"type": ["string", "null"]}, None),
|
|
],
|
|
)
|
|
def test_validate_json_value_accepts_matching_literals(
|
|
schema: dict[str, object],
|
|
value: object,
|
|
) -> None:
|
|
validate_json_value_at_schema_path(
|
|
schema={
|
|
"type": "object",
|
|
"properties": {"value": schema},
|
|
},
|
|
parts=("value",),
|
|
value=value,
|
|
label="bindings[0].value",
|
|
)
|
|
|
|
|
|
def test_validate_json_value_at_nested_schema_path() -> None:
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"request": {"$ref": "#/$defs/Request"}},
|
|
"$defs": {
|
|
"Request": {
|
|
"type": "object",
|
|
"properties": {"format": {"enum": ["markdown", "json"]}},
|
|
}
|
|
},
|
|
}
|
|
|
|
validate_json_value_at_schema_path(
|
|
schema=schema,
|
|
parts=("request", "format"),
|
|
value="markdown",
|
|
label="bindings[0].value",
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r"bindings\[0\]\.value does not satisfy schema at 'request.format'",
|
|
):
|
|
validate_json_value_at_schema_path(
|
|
schema=schema,
|
|
parts=("request", "format"),
|
|
value="html",
|
|
label="bindings[0].value",
|
|
)
|
|
|
|
|
|
def test_validate_json_value_uses_caller_schema_label() -> None:
|
|
with pytest.raises(ValueError, match="workflow output schema"):
|
|
validate_json_value_at_schema_path(
|
|
schema={
|
|
"type": "object",
|
|
"properties": {"format": {"type": "string"}},
|
|
},
|
|
parts=("missing",),
|
|
value="markdown",
|
|
label="bindings[0].value",
|
|
schema_label="workflow output schema",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("schema", "value"),
|
|
[
|
|
({"type": "string"}, 7),
|
|
({"type": "object"}, []),
|
|
({"type": "array"}, {}),
|
|
({"type": "null"}, "not-null"),
|
|
],
|
|
)
|
|
def test_validate_json_value_rejects_non_matching_literals(
|
|
schema: dict[str, object],
|
|
value: object,
|
|
) -> None:
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r"bindings\[0\]\.value does not satisfy schema at 'value'",
|
|
):
|
|
validate_json_value_at_schema_path(
|
|
schema={
|
|
"type": "object",
|
|
"properties": {"value": schema},
|
|
},
|
|
parts=("value",),
|
|
value=value,
|
|
label="bindings[0].value",
|
|
)
|
|
|
|
|
|
def test_project_schema_path_accepts_whole_source_schema() -> None:
|
|
projected = project_schema_path_to_schema_path(
|
|
target_schema={"type": "object", "properties": {}},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
"required": ["title"],
|
|
},
|
|
source_parts=(),
|
|
target_parts=("payload",),
|
|
)
|
|
|
|
assert projected["properties"]["payload"]["required"] == ["title"]
|
|
|
|
|
|
def test_project_schema_path_traverses_pydantic_defs_reference() -> None:
|
|
projected = project_schema_path_to_schema_path(
|
|
target_schema={"type": "object", "properties": {}},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"report": {"$ref": "#/$defs/Report"}},
|
|
"$defs": {
|
|
"Report": {
|
|
"type": "object",
|
|
"properties": {"markdown": {"$ref": "#/$defs/Markdown"}},
|
|
},
|
|
"Markdown": {"type": "string", "minLength": 1},
|
|
},
|
|
},
|
|
source_parts=("report", "markdown"),
|
|
target_parts=("report", "markdown"),
|
|
)
|
|
|
|
assert projected["properties"]["report"]["properties"]["markdown"] == {
|
|
"$ref": "#/$defs/Markdown"
|
|
}
|
|
assert projected["$defs"]["Markdown"]["minLength"] == 1
|
|
|
|
|
|
def test_project_schema_path_traverses_legacy_definitions_reference() -> None:
|
|
projected = project_schema_path_to_schema_path(
|
|
target_schema={"type": "object", "properties": {}},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"report": {"$ref": "#/definitions/Report"}},
|
|
"definitions": {
|
|
"Report": {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
}
|
|
},
|
|
},
|
|
source_parts=("report", "title"),
|
|
target_parts=("title",),
|
|
)
|
|
|
|
assert projected["properties"]["title"] == {"type": "string"}
|
|
assert "Report" in projected["definitions"]
|
|
|
|
|
|
def test_project_schema_path_traverses_target_defs_reference() -> None:
|
|
projected = project_schema_path_to_schema_path(
|
|
target_schema={
|
|
"type": "object",
|
|
"properties": {"report": {"$ref": "#/$defs/Report"}},
|
|
"$defs": {
|
|
"Report": {
|
|
"type": "object",
|
|
"properties": {},
|
|
}
|
|
},
|
|
},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
},
|
|
source_parts=("title",),
|
|
target_parts=("report", "title"),
|
|
)
|
|
|
|
assert projected["properties"]["report"] == {"$ref": "#/$defs/Report"}
|
|
assert projected["$defs"]["Report"]["properties"]["title"] == {"type": "string"}
|
|
|
|
|
|
def test_schema_path_exists_follows_local_defs() -> None:
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"report": {"$ref": "#/$defs/Report"}},
|
|
"$defs": {
|
|
"Report": {
|
|
"type": "object",
|
|
"properties": {"title": {"type": "string"}},
|
|
}
|
|
},
|
|
}
|
|
|
|
assert schema_path_exists(schema, ("report", "title")) is True
|
|
assert schema_path_exists(schema, ("report", "missing")) is False
|
|
|
|
|
|
def test_project_schema_path_rejects_unresolved_equivalent_target_reference() -> None:
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r"unresolved reference '#/\$defs/Report'",
|
|
):
|
|
project_schema_path_to_schema_path(
|
|
target_schema={
|
|
"type": "object",
|
|
"properties": {"report": {"$ref": "#/$defs/Report"}},
|
|
},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"report": {"$ref": "#/$defs/Report"}},
|
|
"$defs": {"Report": {"type": "object", "properties": {}}},
|
|
},
|
|
source_parts=("report",),
|
|
target_parts=("report",),
|
|
allow_existing_equivalent=True,
|
|
)
|
|
|
|
|
|
def test_project_schema_path_rejects_missing_nested_source() -> None:
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="source schema path 'report.missing' is not declared",
|
|
):
|
|
project_schema_path_to_schema_path(
|
|
target_schema={"type": "object", "properties": {}},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"report": {"type": "object", "properties": {}}},
|
|
},
|
|
source_parts=("report", "missing"),
|
|
target_parts=("value",),
|
|
)
|
|
|
|
|
|
def test_project_schema_path_rejects_scalar_source_ancestor() -> None:
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="source schema path 'report' is not an object",
|
|
):
|
|
project_schema_path_to_schema_path(
|
|
target_schema={"type": "object", "properties": {}},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"report": {"type": "string"}},
|
|
},
|
|
source_parts=("report", "title"),
|
|
target_parts=("title",),
|
|
)
|
|
|
|
|
|
def test_project_schema_path_rejects_remote_reference() -> None:
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="unsupported reference 'https://example.com/report.json'",
|
|
):
|
|
project_schema_path_to_schema_path(
|
|
target_schema={"type": "object", "properties": {}},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"report": {"$ref": "https://example.com/report.json"}},
|
|
},
|
|
source_parts=("report", "title"),
|
|
target_parts=("title",),
|
|
)
|
|
|
|
|
|
def test_project_schema_path_rejects_remote_reference_at_selected_leaf() -> None:
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="unsupported reference 'https://example.com/report.json'",
|
|
):
|
|
project_schema_path_to_schema_path(
|
|
target_schema={"type": "object", "properties": {}},
|
|
source_schema={
|
|
"type": "object",
|
|
"properties": {"report": {"$ref": "https://example.com/report.json"}},
|
|
},
|
|
source_parts=("report",),
|
|
target_parts=("report",),
|
|
)
|