diff --git a/src/graphon/variables/input_entities.py b/src/graphon/variables/input_entities.py index 3638306..b900d57 100644 --- a/src/graphon/variables/input_entities.py +++ b/src/graphon/variables/input_entities.py @@ -1,3 +1,4 @@ +import json from collections.abc import Sequence from enum import StrEnum from typing import Any @@ -11,6 +12,11 @@ ) +def _reject_non_standard_json_constant(constant: str) -> None: + msg = f"json_schema is not valid JSON: invalid constant {constant}" + raise ValueError(msg) + + class VariableEntityType(StrEnum): TEXT_INPUT = "text-input" SELECT = "select" @@ -53,14 +59,33 @@ def convert_none_description(cls, value: Any) -> str: def convert_none_options(cls, value: Any) -> Sequence[str]: return value or [] - @field_validator("json_schema") + @field_validator("json_schema", mode="before") @classmethod def validate_json_schema( cls, - schema: dict[str, Any] | None, + schema: str | dict[str, Any] | None, ) -> dict[str, Any] | None: + # The schema is persisted as raw editor text on the frontend, so accept + # either a parsed object, a JSON string, or empty/None inputs. if schema is None: return None + if isinstance(schema, str): + schema = schema.strip() + if not schema: + return None + try: + schema = json.loads( + schema, + parse_constant=_reject_non_standard_json_constant, + ) + except json.JSONDecodeError as error: + msg = f"json_schema is not valid JSON: {error.msg}" + raise ValueError(msg) from error + if not isinstance(schema, dict): + # Pydantic only wraps ValueError/AssertionError into ValidationError, + # so we deliberately keep ValueError instead of TypeError here. + msg = f"json_schema must be a JSON object, got {type(schema).__name__}" + raise ValueError(msg) # noqa: TRY004 try: Draft7Validator.check_schema(schema) except SchemaError as error: diff --git a/tests/variables/__init__.py b/tests/variables/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/variables/test_input_entities.py b/tests/variables/test_input_entities.py new file mode 100644 index 0000000..4d96659 --- /dev/null +++ b/tests/variables/test_input_entities.py @@ -0,0 +1,89 @@ +from typing import Any + +import pytest +from pydantic import ValidationError + +from graphon.variables.input_entities import VariableEntity, VariableEntityType + +_VALID_SCHEMA: dict = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "minimum": 0}, + }, + "required": ["name"], +} + + +def _make_payload(json_schema: Any) -> dict[str, Any]: + return { + "variable": "profile", + "label": "profile", + "type": VariableEntityType.JSON_OBJECT, + "json_schema": json_schema, + } + + +class TestValidateJsonSchema: + def test_accepts_dict_input(self) -> None: + entity = VariableEntity.model_validate(_make_payload(_VALID_SCHEMA)) + assert entity.json_schema == _VALID_SCHEMA + + def test_accepts_json_string_input(self) -> None: + # The frontend's code editor persists the schema as a raw JSON string. + raw = ( + "{\n" + ' "type": "object",\n' + ' "properties": {\n' + ' "name": {"type": "string"},\n' + ' "age": {"type": "integer", "minimum": 0}\n' + " },\n" + ' "required": ["name"]\n' + "}" + ) + entity = VariableEntity.model_validate(_make_payload(raw)) + assert entity.json_schema == _VALID_SCHEMA + + def test_treats_none_as_none(self) -> None: + entity = VariableEntity.model_validate(_make_payload(None)) + assert entity.json_schema is None + + def test_treats_empty_string_as_none(self) -> None: + # Frontend "clear schema" sends "" rather than removing the field; + # treating it as None matches the user's intent of "no constraint". + entity = VariableEntity.model_validate(_make_payload("")) + assert entity.json_schema is None + + def test_treats_whitespace_string_as_none(self) -> None: + entity = VariableEntity.model_validate(_make_payload(" \n\t ")) + assert entity.json_schema is None + + def test_rejects_malformed_json_string(self) -> None: + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate(_make_payload('{"type": "object"')) + assert "not valid JSON" in str(exc_info.value) + + @pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) + def test_rejects_non_standard_json_constant(self, constant: str) -> None: + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate( + _make_payload(f'{{"type": "number", "minimum": {constant}}}'), + ) + assert "invalid constant" in str(exc_info.value) + + def test_rejects_non_object_non_string_input(self) -> None: + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate(_make_payload(123)) + assert "must be a JSON object" in str(exc_info.value) + + def test_rejects_semantically_invalid_schema(self) -> None: + bad_schema = {"type": "not_a_real_type"} + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate(_make_payload(bad_schema)) + assert "Invalid JSON schema" in str(exc_info.value) + + def test_rejects_semantically_invalid_schema_from_string(self) -> None: + bad_schema_str = '{"type": "not_a_real_type"}' + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate(_make_payload(bad_schema_str)) + assert "Invalid JSON schema" in str(exc_info.value)