This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 537feafb369 Fix Variables API handling of non-string JSON values 
(#71018)
537feafb369 is described below

commit 537feafb3697338b3988d826b9c9b45da06b1f23
Author: Kaxil Naik <[email protected]>
AuthorDate: Tue Aug 11 22:54:30 2026 +0100

    Fix Variables API handling of non-string JSON values (#71018)
    
    POST /api/v2/variables with a non-string JSON value (array, object,
    bool, null) silently stored the Python repr of the value, which cannot
    be read back with deserialize_json. PATCH with the same payload failed
    with a masked 500 because the raw value hit the ORM Fernet encryption
    step.
    
    VariableBody now JSON-encodes non-string values once at request
    validation, covering POST, PATCH and bulk consistently, matching the
    behaviour the bulk create path already had for dicts and lists.
    Variable.set_val also raises a clear TypeError instead of the cryptic
    "encoding without a string argument".
---
 .../api_fastapi/core_api/datamodels/variables.py   | 15 +++-
 .../core_api/services/public/variables.py          |  3 +-
 airflow-core/src/airflow/models/variable.py        |  9 ++-
 .../core_api/routes/public/test_variables.py       | 91 ++++++++++++++++++++--
 airflow-core/tests/unit/models/test_variable.py    |  5 ++
 5 files changed, 112 insertions(+), 11 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
index 75fefdd656b..16c98c8588a 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
@@ -20,7 +20,7 @@ from __future__ import annotations
 import json
 from collections.abc import Iterable
 
-from pydantic import Field, JsonValue, model_validator
+from pydantic import Field, JsonValue, field_validator, model_validator
 
 from airflow._shared.secrets_masker import redact
 from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel, 
make_partial_model
@@ -61,6 +61,19 @@ class VariableBody(StrictBaseModel):
     description: str | None = Field(default=None)
     team_name: str | None = Field(max_length=50, default=None)
 
+    @field_validator("value", mode="after")
+    @classmethod
+    def serialize_non_string_value(cls, value: JsonValue) -> str:
+        # Variables are stored as strings. A non-string JSON value (list, 
dict, number, bool,
+        # null) must be JSON-encoded here: POST would otherwise store its 
Python repr, which
+        # deserialize_json cannot read back, and PATCH would fail in the ORM 
Fernet step on
+        # bytes(value, "utf-8") (see #71010). indent=2 matches 
Variable.set(serialize_json=True).
+        # Not allow_nan=False: rejecting here yields a 422 whose response 
embeds the NaN input,
+        # which starlette's JSONResponse cannot serialize -- the request would 
500 instead.
+        if isinstance(value, str):
+            return value
+        return json.dumps(value, indent=2)
+
     @model_validator(mode="after")
     def validate_team_name(self) -> VariableBody:
         if self.team_name is not None and not conf.getboolean("core", 
"multi_team"):
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py 
b/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
index 95d52bfd805..37941fed550 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/public/variables.py
@@ -126,13 +126,12 @@ class BulkVariableService(BulkService[VariableBody]):
 
             for variable in action.entities:
                 if variable.key in create_keys:
-                    should_serialize_json = isinstance(variable.value, (dict, 
list))
+                    # VariableBody already JSON-encodes non-string values, so 
no serialize_json here.
                     Variable.set(
                         key=variable.key,
                         value=variable.value,
                         description=variable.description,
                         session=self.session,
-                        serialize_json=should_serialize_json,
                     )
                     results.success.append(variable.key)
 
diff --git a/airflow-core/src/airflow/models/variable.py 
b/airflow-core/src/airflow/models/variable.py
index b06e73cd5f5..c366a2482b1 100644
--- a/airflow-core/src/airflow/models/variable.py
+++ b/airflow-core/src/airflow/models/variable.py
@@ -111,6 +111,11 @@ class Variable(Base, LoggingMixin):
     def set_val(self, value):
         """Encode the specified value with Fernet Key and store it in 
Variables Table."""
         if value is not None:
+            if not isinstance(value, str):
+                raise TypeError(
+                    f"Variable value must be a string, got 
{type(value).__name__}. "
+                    "Use Variable.set(key, value, serialize_json=True) to 
store non-string values."
+                )
             fernet = get_fernet()
             self._val = fernet.encrypt(bytes(value, "utf-8")).decode()
             self.is_encrypted = fernet.is_encrypted
@@ -214,7 +219,9 @@ class Variable(Base, LoggingMixin):
         This operation overwrites an existing variable using the session's 
dialect-specific upsert operation.
 
         :param key: Variable Key
-        :param value: Value to set for the Variable
+        :param value: Value to set for the Variable. Non-string values are 
coerced with ``str()``
+            unless ``serialize_json=True``, so pass ``serialize_json=True`` to 
store them as JSON
+            that ``deserialize_json=True`` can read back.
         :param description: Description of the Variable
         :param serialize_json: Serialize the value to a JSON string
         :param team_name: Team name associated to the variable (if any)
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
index 234bbe6b1f7..1198f748481 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
@@ -546,6 +546,29 @@ class TestPatchVariable(TestVariableEndpoint):
         }
         check_last_log(session, dag_id=None, event="patch_variable", 
logical_date=None)
 
+    @pytest.mark.parametrize(
+        "value",
+        [
+            ["a", "b"],
+            {"name": "test", "id": 123, "active": True},
+            42,
+            4.2,
+            True,
+            None,
+        ],
+        ids=["list", "dict", "int", "float", "bool", "none"],
+    )
+    def test_patch_non_string_json_value_round_trips(self, test_client, value):
+        """Non-string JSON values must be stored as valid JSON, not raise a 
500 (issue #71010)."""
+        self.create_variables()
+        body = {"key": TEST_VARIABLE_KEY, "value": value, "description": 
TEST_VARIABLE_DESCRIPTION}
+
+        response = test_client.patch(f"/variables/{TEST_VARIABLE_KEY}", 
json=body)
+
+        assert response.status_code == 200
+        assert json.loads(response.json()["value"]) == value
+        assert Variable.get(TEST_VARIABLE_KEY, deserialize_json=True) == value
+
     def test_patch_should_respond_400(self, test_client):
         response = test_client.patch(
             f"/variables/{TEST_VARIABLE_KEY}",
@@ -681,6 +704,30 @@ class TestPostVariable(TestVariableEndpoint):
         assert response.json() == expected_response
         check_last_log(session, dag_id=None, event="post_variable", 
logical_date=None)
 
+    @pytest.mark.parametrize(
+        "value",
+        [
+            ["a", "b"],
+            {"name": "test", "id": 123, "active": True},
+            42,
+            4.2,
+            True,
+            None,
+        ],
+        ids=["list", "dict", "int", "float", "bool", "none"],
+    )
+    def test_post_non_string_json_value_round_trips(self, test_client, value):
+        """Non-string JSON values must be stored as valid JSON, not a Python 
repr (issue #71010)."""
+        body = {"key": "non_string_value", "value": value, "description": 
"non-string JSON value"}
+
+        response = test_client.post("/variables", json=body)
+
+        assert response.status_code == 201
+        assert json.loads(response.json()["value"]) == value
+        stored_raw = Variable.get("non_string_value")
+        assert json.loads(stored_raw) == value
+        assert Variable.get("non_string_value", deserialize_json=True) == value
+
     def test_post_with_team_should_respond_201(self, test_client, 
testing_team, session):
         self.create_variables()
         body = {
@@ -1395,11 +1442,15 @@ class TestBulkVariables(TestVariableEndpoint):
             ),
             ("my_list_var_param", ["alpha", 42, False, {"nested": "item 
param"}], "A list value (param)"),
             ("my_string_var_param", "plain string param", "A plain string 
(param)"),
+            ("my_bool_var_param", True, "A bool value (param)"),
+            ("my_none_var_param", None, "A null value (param)"),
         ],
         ids=[
             "dict_variable",
             "list_variable",
             "string_variable",
+            "bool_variable",
+            "none_variable",
         ],
     )
     def test_bulk_create_entity_serialization(
@@ -1420,20 +1471,46 @@ class TestBulkVariables(TestVariableEndpoint):
         response = test_client.patch("/variables", json=actions)
         assert response.status_code == 200
 
-        if isinstance(entity_value, (dict, list)):
-            retrieved_value_deserialized = Variable.get(entity_key, 
deserialize_json=True)
-            assert retrieved_value_deserialized == entity_value
-            retrieved_value_raw_string = Variable.get(entity_key, 
deserialize_json=False)
-            assert retrieved_value_raw_string == json.dumps(entity_value, 
indent=2)
-        else:
+        if isinstance(entity_value, str):
+            # Strings are stored verbatim; a plain non-JSON string is not 
deserializable.
             retrieved_value_raw = Variable.get(entity_key, 
deserialize_json=False)
-            assert retrieved_value_raw == str(entity_value)
+            assert retrieved_value_raw == entity_value
 
             with pytest.raises(json.JSONDecodeError):
                 Variable.get(entity_key, deserialize_json=True)
+        else:
+            retrieved_value_deserialized = Variable.get(entity_key, 
deserialize_json=True)
+            assert retrieved_value_deserialized == entity_value
+            retrieved_value_raw_string = Variable.get(entity_key, 
deserialize_json=False)
+            assert retrieved_value_raw_string == json.dumps(entity_value, 
indent=2)
 
         check_last_log(session, dag_id=None, event="bulk_variables", 
logical_date=None)
 
+    def test_bulk_update_non_string_json_value_round_trips(self, test_client):
+        """A non-string value in bulk update must not 500 the whole request 
(issue #71010)."""
+        self.create_variables()
+        actions = {
+            "actions": [
+                {
+                    "action": "update",
+                    "entities": [
+                        {
+                            "key": TEST_VARIABLE_KEY,
+                            "value": {"nested": [1, 2, True, None]},
+                            "description": TEST_VARIABLE_DESCRIPTION,
+                        }
+                    ],
+                    "action_on_non_existence": "fail",
+                }
+            ]
+        }
+
+        response = test_client.patch("/variables", json=actions)
+
+        assert response.status_code == 200
+        assert response.json()["update"] == {"success": [TEST_VARIABLE_KEY], 
"errors": []}
+        assert Variable.get(TEST_VARIABLE_KEY, deserialize_json=True) == 
{"nested": [1, 2, True, None]}
+
     def test_bulk_variables_should_respond_401(self, 
unauthenticated_test_client):
         response = unauthenticated_test_client.patch("/variables", json={})
         assert response.status_code == 401
diff --git a/airflow-core/tests/unit/models/test_variable.py 
b/airflow-core/tests/unit/models/test_variable.py
index e56ee51d734..001a29a7c0c 100644
--- a/airflow-core/tests/unit/models/test_variable.py
+++ b/airflow-core/tests/unit/models/test_variable.py
@@ -127,6 +127,11 @@ class TestVariable:
         Variable.set("tested_var_set_id", "Monday morning breakfast")
         assert Variable.get("tested_var_set_id") == "Monday morning breakfast"
 
+    def test_set_val_rejects_non_string_with_clear_error(self):
+        var = Variable(key="a_key")
+        with pytest.raises(TypeError, match="Variable value must be a string, 
got list"):
+            var.val = ["a", "b"]
+
     def test_variable_set_with_env_variable(self, caplog, session):
         caplog.set_level(logging.WARNING, logger=variable.log.name)
         Variable.set(key="key", value="db-value", session=session)

Reply via email to